Files
OpsLog/internal/award/award_test.go
T

687 lines
27 KiB
Go

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))
}
// Explain must account for the WAIP case exactly: the primary rule produces a
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
func TestExplainAccountsForEveryRule(t *testing.T) {
def := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
Confirm: []string{"lotw"},
}
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
ex := Explain(def, metas, q)
if !ex.InScope || len(ex.Steps) != 2 {
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
}
// The primary rule LOOKS like it found something. Saying only "no match" here is
// what cost hours: the trace has to show the candidate and why it lost.
p := ex.Steps[0]
if len(p.Candidates) == 0 {
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
}
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
}
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
}
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
}
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
t.Errorf("result = %v, want [BG]", ex.Result)
}
// A trace that disagrees with the matcher is worse than none: it is believed.
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
}
}
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
if ex.InScope {
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
}
if !strings.Contains(ex.ScopeError, "2m") {
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
}
if len(ex.Steps) != 0 {
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
}
}
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")
}
}