feat: added FFMA award support
This commit is contained in:
@@ -200,6 +200,14 @@ func Catalog() []CatalogEntry {
|
||||
}
|
||||
}
|
||||
}
|
||||
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||
// quietly miss every future catalog correction. Making the author remember to
|
||||
// flip a flag is exactly the kind of step nobody can guess.
|
||||
for i := range out {
|
||||
out[i].Def.Builtin = true
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package award
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
@@ -287,6 +289,71 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
func refCodes(r Result) []string {
|
||||
out := make([]string, 0, len(r.Refs))
|
||||
for _, rf := range r.Refs {
|
||||
@@ -508,3 +575,23 @@ func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T17:00:44Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "RAC",
|
||||
"name": "RAC Canadian Provinces",
|
||||
"description": "RAC Canadian Provinces",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"url": "https://www.rac.ca/canadaward/",
|
||||
"valid_from": "1977-01-07",
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "state",
|
||||
"match_by": "code",
|
||||
"pattern": "",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "description"
|
||||
},
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "description"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
1
|
||||
],
|
||||
"emission": [
|
||||
"CW",
|
||||
"PHONE",
|
||||
"DIGITAL"
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "AB",
|
||||
"name": "Alberta",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "BC",
|
||||
"name": "British Columbia",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "MB",
|
||||
"name": "Manitoba",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NB",
|
||||
"name": "New Brunswick",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NL",
|
||||
"name": "Newfoundland and Labrador",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NS",
|
||||
"name": "Nova Scotia",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NT",
|
||||
"name": "Northwest Territories",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "NU",
|
||||
"name": "Nunavut",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "ON",
|
||||
"name": "Ontario",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "PE",
|
||||
"name": "Prince Edward Island",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "QC",
|
||||
"name": "Quebec",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "SK",
|
||||
"name": "Saskatchewan",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "YT",
|
||||
"name": "Yukon",
|
||||
"dxcc": 0,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-07-13T17:00:44Z",
|
||||
"awards": [
|
||||
{
|
||||
"def": {
|
||||
"code": "VUCC",
|
||||
"name": "VHF/UHF Century Club",
|
||||
"description": "VHF/UHF Century Club",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||
"valid_from": "1970-01-01",
|
||||
"valid_to": "9999-01-30",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "grid4",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"dynamic": true,
|
||||
"dxcc_filter": null,
|
||||
"valid_bands": [
|
||||
"6m",
|
||||
"4m",
|
||||
"2m",
|
||||
"70cm",
|
||||
"23cm",
|
||||
"13cm",
|
||||
"1.25m"
|
||||
],
|
||||
"emission": [
|
||||
"CW",
|
||||
"PHONE",
|
||||
"DIGITAL"
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true
|
||||
},
|
||||
"references": null
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user