QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:
- the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
OE6CLD saw "E" everywhere OpsLog shows the manager;
- QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
was not stored, not kept among the extras, and not exported — dropped
outright on import;
- neither was ever written on export, so an import followed by an export
destroyed both;
- and OpsLog polluted the field itself: the QSL Manager panel wrote
"Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.
Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.
Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
package adif
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"hamlog/internal/qso"
|
|
)
|
|
|
|
// TestPromotedFieldsRoundTrip writes a QSO carrying the ADIF 3.1.7 promoted
|
|
// fields, parses it back, and checks they survive — guarding the export
|
|
// writeRecord ↔ import recordToQSO field-name mapping against typos.
|
|
func TestPromotedFieldsRoundTrip(t *testing.T) {
|
|
dist := 1234.5
|
|
rxp := 5.0
|
|
a := 12.0
|
|
in := qso.QSO{
|
|
Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
|
|
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
|
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
|
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
|
|
Distance: &dist, RXPower: &rxp, AIndex: &a,
|
|
SKCC: "12345S", FISTS: "999", TenTen: "55555",
|
|
ContactedOp: "EA8XYZ", EqCall: "EA8OLD", PFX: "EA8", MyName: "Greg",
|
|
Class: "1A", DarcDOK: "A01", MyDarcDOK: "B02", Region: "IV",
|
|
SilentKey: "N", SWL: "N", QSOComplete: "Y", QSORandom: "Y",
|
|
CreditGranted: "DXCC", CreditSubmitted: "WAS",
|
|
MyARRLSect: "EMA", MyVUCCGrids: "FN20,FN21",
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
bw := bufio.NewWriter(&buf)
|
|
bw.WriteString("<EOH>\n")
|
|
writeRecord(bw, in, true, nil)
|
|
bw.Flush()
|
|
|
|
var rec Record
|
|
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
out, ok := recordToQSO(rec)
|
|
if !ok {
|
|
t.Fatal("recordToQSO returned !ok")
|
|
}
|
|
|
|
checks := map[string]struct{ got, want string }{
|
|
"SIG": {out.SIG, in.SIG},
|
|
"SIG_INFO": {out.SIGInfo, in.SIGInfo},
|
|
"MY_SIG": {out.MySIG, in.MySIG},
|
|
"MY_SIG_INFO": {out.MySIGInfo, in.MySIGInfo},
|
|
"WWFF_REF": {out.WWFFRef, in.WWFFRef},
|
|
"MY_WWFF_REF": {out.MyWWFFRef, in.MyWWFFRef},
|
|
"SKCC": {out.SKCC, in.SKCC},
|
|
"FISTS": {out.FISTS, in.FISTS},
|
|
"TEN_TEN": {out.TenTen, in.TenTen},
|
|
"CONTACTED_OP": {out.ContactedOp, in.ContactedOp},
|
|
"EQ_CALL": {out.EqCall, in.EqCall},
|
|
"PFX": {out.PFX, in.PFX},
|
|
"MY_NAME": {out.MyName, in.MyName},
|
|
"CLASS": {out.Class, in.Class},
|
|
"DARC_DOK": {out.DarcDOK, in.DarcDOK},
|
|
"MY_DARC_DOK": {out.MyDarcDOK, in.MyDarcDOK},
|
|
"REGION": {out.Region, in.Region},
|
|
"SILENT_KEY": {out.SilentKey, in.SilentKey},
|
|
"SWL": {out.SWL, in.SWL},
|
|
"QSO_COMPLETE": {out.QSOComplete, in.QSOComplete},
|
|
"QSO_RANDOM": {out.QSORandom, in.QSORandom},
|
|
"CREDIT_GRANTED": {out.CreditGranted, in.CreditGranted},
|
|
"CREDIT_SUBMITTED": {out.CreditSubmitted, in.CreditSubmitted},
|
|
"MY_ARRL_SECT": {out.MyARRLSect, in.MyARRLSect},
|
|
"MY_VUCC_GRIDS": {out.MyVUCCGrids, in.MyVUCCGrids},
|
|
}
|
|
for tag, c := range checks {
|
|
if c.got != c.want {
|
|
t.Errorf("%s round-trip = %q, want %q", tag, c.got, c.want)
|
|
}
|
|
}
|
|
if out.Distance == nil || *out.Distance != dist {
|
|
t.Errorf("DISTANCE round-trip = %v, want %v", out.Distance, dist)
|
|
}
|
|
if out.RXPower == nil || *out.RXPower != rxp {
|
|
t.Errorf("RX_PWR round-trip = %v, want %v", out.RXPower, rxp)
|
|
}
|
|
if out.AIndex == nil || *out.AIndex != a {
|
|
t.Errorf("A_INDEX round-trip = %v, want %v", out.AIndex, a)
|
|
}
|
|
}
|
|
|
|
// TestStandardExportDropsNonStandard verifies that standard mode strips
|
|
// vendor/APP tags while full mode keeps them.
|
|
func TestStandardExportDropsNonStandard(t *testing.T) {
|
|
q := qso.QSO{
|
|
Callsign: "F4BPO", Band: "20m", Mode: "CW",
|
|
Extras: map[string]string{
|
|
"APP_LOG4OM_FOO": "x",
|
|
"DARC_DOK": "A01", // standard → kept in both
|
|
"MY_VENDOR_TAG": "y", // non-standard → dropped in standard mode
|
|
},
|
|
}
|
|
|
|
standard := renderRecord(q, false)
|
|
if strings.Contains(standard, "APP_LOG4OM_FOO") || strings.Contains(standard, "MY_VENDOR_TAG") {
|
|
t.Errorf("standard export should drop non-standard tags:\n%s", standard)
|
|
}
|
|
|
|
full := renderRecord(q, true)
|
|
if !strings.Contains(full, "APP_LOG4OM_FOO") || !strings.Contains(full, "MY_VENDOR_TAG") {
|
|
t.Errorf("full export should keep all extras:\n%s", full)
|
|
}
|
|
}
|
|
|
|
func renderRecord(q qso.QSO, includeApp bool) string {
|
|
var buf bytes.Buffer
|
|
bw := bufio.NewWriter(&buf)
|
|
writeRecord(bw, q, includeApp, nil)
|
|
bw.Flush()
|
|
return buf.String()
|
|
}
|
|
|
|
// TestQSLViaFieldsRoundTrip covers the case reported as issue #16: a log
|
|
// exported by another logger carries a manager in QSL_VIA and a routing method
|
|
// in QSL_SENT_VIA, and the two must stay apart.
|
|
//
|
|
// Before this, QSL_SENT_VIA was folded into QSL_VIA whenever QSL_VIA was empty
|
|
// — so a Log4OM log, which defaults QSL_SENT_VIA to E, showed "E" wherever
|
|
// OpsLog displays the manager — and QSL_RCVD_VIA was thrown away outright: it
|
|
// was listed as a promoted field with no column behind it, so it was not even
|
|
// kept among the extras. Neither was ever exported, which made an import
|
|
// followed by an export destroy both.
|
|
func TestQSLViaFieldsRoundTrip(t *testing.T) {
|
|
in := qso.QSO{
|
|
Callsign: "3B9FR", Band: "20m", Mode: "CW",
|
|
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
|
QSLVia: "M0OXO", // the manager
|
|
QSLSentVia: "D", // sent direct
|
|
QSLRcvdVia: "B", // came back via the bureau
|
|
}
|
|
var buf bytes.Buffer
|
|
bw := bufio.NewWriter(&buf)
|
|
bw.WriteString("<EOH>\n")
|
|
writeRecord(bw, in, true, nil)
|
|
bw.Flush()
|
|
|
|
var rec Record
|
|
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
out, ok := recordToQSO(rec)
|
|
if !ok {
|
|
t.Fatal("recordToQSO returned !ok")
|
|
}
|
|
for name, c := range map[string]struct{ got, want string }{
|
|
"QSL_VIA": {out.QSLVia, in.QSLVia},
|
|
"QSL_SENT_VIA": {out.QSLSentVia, in.QSLSentVia},
|
|
"QSL_RCVD_VIA": {out.QSLRcvdVia, in.QSLRcvdVia},
|
|
} {
|
|
if c.got != c.want {
|
|
t.Errorf("%s: got %q, want %q", name, c.got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestQSLSentViaDoesNotBecomeManager pins the exact shape a Log4OM export has:
|
|
// no QSL_VIA at all, QSL_SENT_VIA defaulted to E. The manager field must come
|
|
// back empty rather than holding "E".
|
|
func TestQSLSentViaDoesNotBecomeManager(t *testing.T) {
|
|
const rec = "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW" +
|
|
"<QSL_SENT_VIA:1>E<EOR>\n"
|
|
var got Record
|
|
if err := Parse(strings.NewReader("<EOH>\n"+rec), func(r Record) error { got = r; return nil }); err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
q, ok := recordToQSO(got)
|
|
if !ok {
|
|
t.Fatal("recordToQSO returned !ok")
|
|
}
|
|
if q.QSLVia != "" {
|
|
t.Errorf("QSL_VIA = %q — the routing method leaked into the manager field again", q.QSLVia)
|
|
}
|
|
if q.QSLSentVia != "E" {
|
|
t.Errorf("QSL_SENT_VIA = %q, want %q", q.QSLSentVia, "E")
|
|
}
|
|
}
|
|
|
|
// M is import-only in the ADIF QSL Via enumeration: keep it when given, never
|
|
// write it back out.
|
|
func TestQSLViaManagerIsImportOnly(t *testing.T) {
|
|
in := qso.QSO{
|
|
Callsign: "3B9FR", Band: "20m", Mode: "CW",
|
|
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
|
QSLSentVia: "M", QSLRcvdVia: "M",
|
|
}
|
|
var buf bytes.Buffer
|
|
bw := bufio.NewWriter(&buf)
|
|
writeRecord(bw, in, true, nil)
|
|
bw.Flush()
|
|
if s := buf.String(); strings.Contains(s, "QSL_SENT_VIA") || strings.Contains(s, "QSL_RCVD_VIA") {
|
|
t.Errorf("exported an import-only value:\n%s", s)
|
|
}
|
|
}
|