fix: ADIF import silently dropped a file with no header

Reported with screenshots: a dozen records lifted out of wsjtx_log.adi into a new
.adi imported as "0 imported, 0 ignored, 0 total". The parser only emitted
records after <EOH>, and a hand-assembled file has no header — so the whole file
was read as header text and thrown away. The count says the file was empty; there
is nothing on screen to suggest a missing tag, which is why this was reported as
"import does nothing".

An <eor> is proof that a record ended, header or no header. Fields are now
collected always and DISCARDED at <eoh> — that tag is exactly the statement
"everything before this was the header", so real headers stay out of the data
just as reliably, and a headerless file keeps its first record instead of losing
it to the same rule.

An existing test asserted the old behaviour. It encoded a defensible reading of
the spec, but the file it rejects is one operators really make and the rejection
is silent, so the test is rewritten with the evidence that changed it rather than
deleted.
This commit is contained in:
2026-07-29 15:06:41 +02:00
parent 6fb7f06375
commit ee1f9ccf35
3 changed files with 96 additions and 15 deletions
+69 -6
View File
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
}
func TestParseNoHeader(t *testing.T) {
// Some loggers omit the header entirely — records before <EOH> are
// discarded by design. Verify nothing is emitted in that case.
// A file without a header is IMPORTED, not discarded.
//
// This test asserted the opposite until 2026-07-29, when an operator pasted a
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
// reading of the spec, but the file it rejects is one people really make, and
// the rejection is silent: the count says the file was empty, not that a tag
// was missing. An <eor> is proof enough that a record ended.
src := `<CALL:5>F4XYZ<EOR>`
var got int
var got []Record
err := Parse(strings.NewReader(src), func(r Record) error {
got++
got = append(got, r)
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != 0 {
t.Errorf("expected 0 records without <EOH>, got %d", got)
if len(got) != 1 {
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
}
if got[0]["call"] != "F4XYZ" {
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
}
}
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
t.Errorf("freq mismatch: %q", got["freq"])
}
}
// A file with NO header must still import.
//
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
// empty file rather than as a missing header. Records were only emitted after
// <EOH>, so a hand-assembled file was invisible in its entirety.
func TestParseHeaderlessFile(t *testing.T) {
// The operator's exact shape: records straight from a WSJT-X log, no header.
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
var got []Record
if err := Parse(strings.NewReader(in), func(r Record) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("Parse: %v", err)
}
if len(got) != 2 {
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
}
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
}
if got[0]["gridsquare"] != "GG67" {
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
}
}
// And a normal file must be unaffected: its header is still discarded, so header
// fields never leak into the first record.
func TestParseHeaderStillDiscarded(t *testing.T) {
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
"<call:4>F4AA <mode:2>CW <eor>\n"
var got []Record
if err := Parse(strings.NewReader(in), func(r Record) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("Parse: %v", err)
}
if len(got) != 1 {
t.Fatalf("parsed %d records, want 1", len(got))
}
if _, leaked := got[0]["programid"]; leaked {
t.Error("a header field leaked into the record")
}
if got[0]["call"] != "F4AA" {
t.Errorf("call = %q, want F4AA", got[0]["call"])
}
}