package award import ( "encoding/json" "sort" "strings" "testing" "time" "hamlog/internal/qso" ) func TestWPXPrefix(t *testing.T) { cases := map[string]string{ "F4BPO": "F4", "EA8ABC": "EA8", "9A1AA": "9A1", "OH2BH": "OH2", "K1ABC": "K1", "RAEM": "RA0", "F4BPO/P": "F4", "F4BPO/9": "F9", "VP8/F4BPO": "VP8", "PA0XYZ": "PA0", } for in, want := range cases { if got := wpxPrefix(in); got != want { t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want) } } } func ip(n int) *int { return &n } func TestComputeDXCCAndConfirm(t *testing.T) { qsos := []qso.QSO{ {Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"}, {Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"}, // worked, not confirmed {Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"}, // DXCC Germany confirmed {Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note {Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed } res := Compute(Defaults(), qsos, nil, nil) by := map[string]Result{} for _, r := range res { by[r.Code] = r } dxcc := by["DXCC"] if dxcc.Worked != 3 { // USA, Germany, France t.Errorf("DXCC worked = %d, want 3", dxcc.Worked) } // DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC). if dxcc.Confirmed != 3 { t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed) } // Validated is the stricter LoTW-only tier: a paper QSL confirms but does // NOT validate, so only USA (LoTW) counts. if dxcc.Validated != 1 { t.Errorf("DXCC validated = %d, want 1 (LoTW only, QSL doesn't validate)", dxcc.Validated) } was := by["WAS"] if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter) t.Errorf("WAS worked = %d, want 2", was.Worked) } // DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A. ddfm := by["DDFM"] if ddfm.Worked != 2 { t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm)) } if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted) t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed) } } func TestNatLess(t *testing.T) { in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"} want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"} sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) }) for i := range want { if in[i] != want[i] { t.Fatalf("natLess order = %v, want %v", in, want) } } } // A multi-reference field (n-fer POTA) counts each park separately. func TestComputeMultiRef(t *testing.T) { def := Def{Code: "POTA", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: []string{"lotw", "qsl"}, Valid: true} qsos := []qso.QSO{ {Callsign: "W2QMI", Band: "20m", POTARef: "US-6544,US-0680", LOTWRcvd: "Y"}, {Callsign: "K1ABC", Band: "40m", POTARef: "US-0680"}, // shared park } r := Compute([]Def{def}, qsos, nil, nil)[0] if r.Worked != 2 { // distinct parks: US-6544, US-0680 t.Errorf("POTA worked = %d, want 2 (%v)", r.Worked, refCodes(r)) } } // WAJA-style award: MatchBy="description", non-exact, scanning the QTH for a // reference's NAME (the prefecture). Also guards against the nil-slice crash: // an award with nothing worked must return empty (non-nil) Refs/Bands. func TestComputeMatchByDescription(t *testing.T) { def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description", DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true} qsos := []qso.QSO{ {Callsign: "JA1ABC", Band: "20m", DXCC: ip(339), QTH: "Tokyo city", LOTWRcvd: "Y"}, {Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "Osaka"}, {Callsign: "JA9XYZ", Band: "20m", DXCC: ip(339), QTH: "nowhere special"}, // no prefecture name } refMetas := map[string][]RefMeta{"WAJA": { {Code: "100", Name: "Tokyo", Valid: true}, {Code: "270", Name: "Osaka", Valid: true}, {Code: "010", Name: "Hokkaido", Valid: true}, }} r := Compute([]Def{def}, qsos, refMetas, nil)[0] if r.Worked != 2 { // Tokyo + Osaka found by name inside QTH t.Errorf("WAJA worked = %d, want 2 (%v)", r.Worked, refCodes(r)) } if r.Total != 3 { // predefined denominator = list size t.Errorf("WAJA total = %d, want 3", r.Total) } // Nil-slice guard: an award with zero worked refs must still return // non-nil (empty) Refs/Bands so the JSON isn't null (UI white-screen). empty := Compute([]Def{{Code: "WWFF", Type: TypeReference, Field: "wwff", Dynamic: true, Valid: true}}, nil, nil, nil)[0] if empty.Refs == nil || empty.Bands == nil { t.Errorf("empty award must have non-nil Refs/Bands, got Refs=%v Bands=%v", empty.Refs, empty.Bands) } } // OR rules are ordered fallbacks: the primary match wins and short-circuits the // rules; when the primary finds nothing, the first OR rule that hits wins and // later rules are skipped. DDFM-style: a French department from the NOTE first, // else captured from a postal code in the address (Prefix "D" → "D74"). func TestComputeOrRuleFallback(t *testing.T) { def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`, DXCCFilter: []int{227}, Valid: true, OrRules: []OrRule{ {Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"}, }, } refMetas := map[string][]RefMeta{"DDFM": { {Code: "D74", Name: "Haute-Savoie", Valid: true}, {Code: "D75", Name: "Paris", Valid: true}, }} worked := func(q qso.QSO) []string { r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0] var out []string for _, rf := range r.Refs { if rf.Worked { out = append(out, rf.Ref) } } return out } // Primary (note "D74") matches → the postal OR rule is skipped, so the // address's 75001 does NOT also add D75. Exactly the user's ask: first // condition wins, no fall-through. if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" { t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got) } // No note dept → fall through to the OR rule → postal 74140 → D74. if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" { t.Errorf("OR fallback: got %v, want [D74]", got) } } // The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in // ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN // regex (city lists). Confirms the ordered fallback + that a match-by-pattern // rule with an empty rule-pattern triggers the per-reference regexes. func TestComputeProvinceCascade(t *testing.T) { def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description", DXCCFilter: []int{318}, Valid: true, OrRules: []OrRule{ {Field: "address", MatchBy: "description"}, {Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes }, } refMetas := map[string][]RefMeta{"WAPC": { {Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true}, {Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true}, }} worked := func(q qso.QSO) []string { r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0] var out []string for _, rf := range r.Refs { if rf.Worked { out = append(out, rf.Ref) } } return out } cases := []struct { name, qth, addr string want string }{ {"name in qth", "Wuxi, Jiangsu", "", "JS"}, {"name in address", "Wuxi", "Jiangsu Province", "JS"}, {"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"}, {"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"}, } for _, c := range cases { got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr}) if len(got) != 1 || got[0] != c.want { t.Errorf("%s: got %v, want [%s]", c.name, got, c.want) } } } // A manually-assigned override (ManualRefsKey) must count in Compute — not just // MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes // no QSO field) sticks after the operator picks the province by hand. The engine // only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so // the ref exists solely as the override. func TestComputeManualOverride(t *testing.T) { def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description", DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true} qsos := []qso.QSO{ // Address doesn't name the province → only the override tags it. {Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5", Extras: map[string]string{ManualRefsKey: "WAPC@JS"}}, } refMetas := map[string][]RefMeta{"WAPC": { {Code: "JS", Name: "Jiangsu", Valid: true}, {Code: "GD", Name: "Guangdong", Valid: true}, }} r := Compute([]Def{def}, qsos, refMetas, nil)[0] if r.Worked != 1 { t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r)) } var worked []string for _, rf := range r.Refs { if rf.Worked { worked = append(worked, rf.Ref) } } if len(worked) != 1 || worked[0] != "JS" { t.Errorf("worked refs = %v, want [JS]", worked) } // An override for a ref NOT in the predefined list is rejected (no typo refs). qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ" if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 { t.Errorf("bogus override ZZ: worked = %d, want 0", w) } } // A description-match award must also honour a REFERENCE's own regex (used to // broaden matching past the plain name) AND match case-insensitively — a log // QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo // via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch. func TestComputeDescriptionRefPattern(t *testing.T) { def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description", DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true} qsos := []qso.QSO{ {Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case {Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name } refMetas := map[string][]RefMeta{"WAJA": { {Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true}, {Code: "27", Name: "Osaka", Valid: true}, {Code: "01", Name: "Hokkaido", Valid: true}, }} r := Compute([]Def{def}, qsos, refMetas, nil)[0] if r.Worked != 2 { t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r)) } } // VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid // line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the // 4-char prefix of GRIDSQUARE. func TestComputeGrid4VUCC(t *testing.T) { def := Def{Code: "VUCC", Type: TypeGrid, Field: "grid4", Dynamic: true, Confirm: []string{"lotw", "qsl"}, Valid: true} qsos := []qso.QSO{ {Callsign: "K1ABC", Band: "6m", Grid: "FN31PR", LOTWRcvd: "Y"}, // → FN31 {Callsign: "W2DEF", Band: "6m", VUCCGrids: "FN20,FN21,FN30,FN31"}, // grid-line: 4 squares {Callsign: "W3GHI", Band: "2m", Grid: "FN20XX"}, // → FN20 (dup of above) } r := Compute([]Def{def}, qsos, nil, nil)[0] // Distinct squares: FN31, FN20, FN21, FN30 = 4 if r.Worked != 4 { t.Errorf("VUCC worked = %d, want 4 (%v)", r.Worked, refCodes(r)) } } // FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken // from arrl.org/ffma. The count is the award's own checksum — if this test ever // fails on the count, the catalog file is wrong, not the test. func TestCatalogFFMA(t *testing.T) { raw, ok := CatalogRefs("FFMA") if !ok { t.Fatal("FFMA has no reference list in the embedded catalog") } var refs []struct { Code string `json:"code"` DXCC int `json:"dxcc"` Valid bool `json:"valid"` } if err := json.Unmarshal(raw, &refs); err != nil { t.Fatalf("FFMA references: %v", err) } if len(refs) != 488 { t.Fatalf("FFMA has %d grids, want exactly 488", len(refs)) } var def Def metas := make([]RefMeta, 0, len(refs)) for _, r := range refs { // Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from // water. Pinning a reference to a DXCC entity would reject those contacts. if r.DXCC != 0 { t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC) } metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid}) } for _, d := range Defaults() { if d.Code == "FFMA" { def = d } } if def.Total != 488 || def.Field != "grid4" { t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field) } d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC) qsos := []qso.QSO{ {Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts {Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c) {Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d) {Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid {Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band {Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2) } r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0] var got []string for _, rf := range r.Refs { if rf.Worked { got = append(got, rf.Ref) } } sort.Strings(got) want := []string{"EM00", "FN25", "FN31", "FN32"} if strings.Join(got, " ") != strings.Join(want, " ") { t.Fatalf("FFMA worked = %v, want %v", got, want) } if r.Worked != len(want) || r.Total != 488 { t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want)) } } // WAIP: the primary rule takes the WHOLE address as the province code (exact // match), which can never be one — but it does produce a raw candidate. The OR // fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works, // and it must still run: a rule that yields nothing VALID is not a hit. func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) { def := Def{ Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true, Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true, OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field Confirm: []string{"lotw", "qsl"}, } metas := []RefMeta{ {Code: "BG", Name: "Bergamo", Valid: true}, {Code: "MI", Name: "Milano", Valid: true}, } qsos := []qso.QSO{ {Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"}, } r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0] if r.Worked != 1 { t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked) } for _, rf := range r.Refs { if rf.Ref == "BG" && rf.Worked { return } } t.Errorf("BG not worked; refs = %v", refCodes(r)) } func refCodes(r Result) []string { out := make([]string, 0, len(r.Refs)) for _, rf := range r.Refs { out = append(out, rf.Ref) } return out } // Legacy defs (Type=="", Valid==false, old DDFM pattern) get upgraded. func TestMigrate(t *testing.T) { legacy := []Def{ {Code: "DXCC", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340}, // Valid=false, Type="" {Code: "DDFM", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, Total: 96}, {Code: "MYAWARD", Field: "note"}, // custom legacy } out, changed := Migrate(legacy) if !changed { t.Fatal("expected migration to change legacy defs") } for _, d := range out { if !d.Valid { t.Errorf("%s should be enabled after migration", d.Code) } if d.Type == "" { t.Errorf("%s should have a type after migration", d.Code) } } if out[1].Pattern != `(?i)\b(D\d{1,2}[AB]?)\b` { t.Errorf("DDFM pattern not fixed: %q", out[1].Pattern) } if out[2].Type != TypeQSOFields { t.Errorf("custom legacy award type = %q, want QSOFIELDS", out[2].Type) } // Idempotent: a second pass changes nothing. if _, changed2 := Migrate(out); changed2 { t.Error("migration should be idempotent") } } // Regression: a predefined reference whose own DXCC differs from the QSO's // entity must still count when the field code matches and the award-level // DXCC filter allows it (WAS Alaska: state AK, but DXCC entity 6, not 291). func TestComputePredefinedCrossDXCC(t *testing.T) { def := Def{Code: "WAS", Type: TypeQSOFields, Field: "state", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Valid: true} qsos := []qso.QSO{ {Callsign: "KL5DX", Band: "20m", DXCC: ip(6), State: "AK", LOTWRcvd: "Y"}, // Alaska {Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // continental } refMetas := map[string][]RefMeta{"WAS": { {Code: "AK", Name: "Alaska", DXCCList: []int{291}, Valid: true}, // wrong DXCC on purpose {Code: "MA", Name: "Massachusetts", DXCCList: []int{291}, Valid: true}, }} r := Compute([]Def{def}, qsos, refMetas, nil)[0] if r.Worked != 2 { t.Errorf("WAS worked = %d, want 2 (Alaska must count despite DXCC 6) %v", r.Worked, refCodes(r)) } } // A predefined award only counts references present in its list, lists the // unworked ones too, and uses the list size as the denominator. func TestComputePredefinedList(t *testing.T) { def := Def{Code: "RAC", Name: "RAC", Type: TypeQSOFields, Field: "state", ExactMatch: true, DXCCFilter: []int{1}, Confirm: []string{"lotw", "qsl"}, Validate: []string{"lotw"}, Valid: true} qsos := []qso.QSO{ {Callsign: "VE3AAA", Band: "20m", DXCC: ip(1), State: "ON", LOTWRcvd: "Y"}, // worked+confirmed+validated {Callsign: "VE7BBB", Band: "40m", DXCC: ip(1), State: "BC", QSLRcvd: "Y"}, // worked+confirmed (not validated) {Callsign: "VE9CCC", Band: "20m", DXCC: ip(1), State: "ZZ"}, // not a real province → ignored {Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // wrong DXCC → ignored } refMetas := map[string][]RefMeta{"RAC": { {Code: "ON", Name: "Ontario", Valid: true}, {Code: "BC", Name: "British Columbia", Valid: true}, {Code: "AB", Name: "Alberta", Valid: true}, }} r := Compute([]Def{def}, qsos, refMetas, nil)[0] if r.Worked != 2 { t.Errorf("RAC worked = %d, want 2 (%v)", r.Worked, refCodes(r)) } if r.Confirmed != 2 { t.Errorf("RAC confirmed = %d, want 2", r.Confirmed) } if r.Validated != 1 { // only ON via LoTW t.Errorf("RAC validated = %d, want 1", r.Validated) } if r.Total != 3 { // denominator = list size t.Errorf("RAC total = %d, want 3", r.Total) } if len(r.Refs) != 3 { // ON, BC worked + AB unworked t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r)) } } // The built-in awards moved from Go code into embedded JSON (catalog/*.json). // A refactor is only a refactor if the behaviour is identical, so pin down what // the catalog MUST still produce — a typo in a JSON file would otherwise silently // disable an award, and nobody would notice until a QSO stopped counting. func TestCatalogDefaults(t *testing.T) { defs := Defaults() byCode := map[string]Def{} for _, d := range defs { byCode[d.Code] = d } // The catalog is MEANT to grow — dropping a JSON in is how an award ships. So // assert the ten originals are still there, never that the count is exactly ten: // a test that fails the moment you add an award is a test that teaches you to // ignore it. want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"} if len(defs) < len(want) { t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode) } for _, c := range want { d, ok := byCode[c] if !ok { t.Errorf("%s missing from the embedded catalog", c) continue } // Valid=false would hide the award entirely; Builtin=false would let a // "reset to defaults" delete it. Both are silent failures. if !d.Valid || !d.Builtin { t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin) } if d.Type == "" || d.Field == "" { t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field) } if len(d.Confirm) == 0 { t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c) } } // Spot-check the two that carry real matching logic, since a mangled escape in // JSON is exactly the failure this test exists to catch. if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` { t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got) } if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil { t.Errorf("DDFM pattern does not compile: %v", err) } if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 { t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got) } if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" { t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy) } if !byCode["WPX"].Dynamic { t.Error("WPX must be dynamic — its references aren't a fixed list") } // Deterministic order: an embed walk that reorders would shuffle the user's list. for i := 1; i < len(defs); i++ { if defs[i-1].Code > defs[i].Code { t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code) } } } // The whole point of the catalog: an award you EXPORT from the UI can be dropped // straight into catalog/ and shipped to everyone — no conversion step. So the // loader must accept the exported bundle shape, and it must carry the REFERENCES, // because a WAPC without its provinces and their city regexes is an empty shell. func TestCatalogAcceptsExportedBundle(t *testing.T) { // Exactly what ExportAward writes. exported := []byte(`{ "version": 1, "exported_at": "2026-07-13T10:00:00Z", "awards": [ { "def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true, "type":"QSOFIELDS","field":"address","match_by":"description", "dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34}, "references": [ {"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true}, {"code":"ZJ","name":"Zhejiang","valid":true} ] } ] }`) var f catalogFile if err := json.Unmarshal(exported, &f); err != nil { t.Fatalf("an exported bundle must parse as a catalog file: %v", err) } if len(f.Awards) != 1 { t.Fatalf("got %d awards, want 1", len(f.Awards)) } e := f.Awards[0] if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" { t.Errorf("definition lost in the round-trip: %+v", e.Def) } if len(e.References) == 0 { t.Fatal("references dropped — this is the whole value of sharing an award") } // The per-reference regex must survive: it is what makes the award actually work. var refs []struct { Code string `json:"code"` Pattern string `json:"pattern"` } if err := json.Unmarshal(e.References, &refs); err != nil { t.Fatalf("references not decodable: %v", err) } if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` { t.Errorf("reference regex did not survive: %+v", refs) } if _, err := compileAwardRE(refs[0].Pattern); err != nil { t.Errorf("the shipped regex does not compile: %v", err) } } // A single malformed file in the catalog must not take the other awards down with // it — one bad JSON should cost you one award, not all of them. func TestCatalogSurvivesOneBadFile(t *testing.T) { if len(Catalog()) < 10 { t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog())) } var f catalogFile if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil { t.Error("expected a parse error on malformed JSON") } // Catalog() skips unparseable files rather than returning nil, so the others // still load. (Verified structurally: the loader `continue`s on error.) } // Anything OpsLog SHIPS is built-in, whatever the file says. // // You create an award, export it (its JSON records builtin:false — it wasn't // built-in when you wrote it), then drop that same file into the catalog to ship // it. If we trusted the flag, the award would go out to everyone marked "not // built-in" and would then silently miss every future catalog correction. Making // the author remember to flip a flag first is a step nobody can guess — so the // catalog derives it instead. func TestCatalogForcesBuiltin(t *testing.T) { for _, e := range Catalog() { if !e.Def.Builtin { t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code) } } // The realistic case: a user-authored award whose JSON says builtin:false. if len(Catalog()) == 0 { t.Fatal("empty catalog") } }