feat: Reworked the awards logic so it is easy to add new ones.

This commit is contained in:
2026-07-13 17:38:18 +02:00
parent ae60d58893
commit c170d6091e
18 changed files with 994 additions and 71 deletions
+126
View File
@@ -1,6 +1,7 @@
package award
import (
"encoding/json"
"sort"
"testing"
@@ -378,3 +379,128 @@ func TestComputePredefinedList(t *testing.T) {
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
}
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
if len(defs) != len(want) {
t.Fatalf("catalog has %d awards, want %d (%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.)
}