511 lines
20 KiB
Go
511 lines
20 KiB
Go
package award
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"testing"
|
|
|
|
"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))
|
|
}
|
|
}
|
|
|
|
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.)
|
|
}
|