package award import ( "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)) } } 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)) } }