up
This commit is contained in:
+153
-37
@@ -101,6 +101,48 @@ func Defaults() []Def {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate upgrades award definitions saved before the richer model existed.
|
||||
// Such defs have Type=="" and the zero value for the new fields (notably
|
||||
// Valid==false, which would otherwise hide every legacy award). For each legacy
|
||||
// def it enables the award, fills the matching/confirmation fields from the
|
||||
// matching built-in default (preserving the user's field/filters/confirm), and
|
||||
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
|
||||
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
|
||||
func Migrate(defs []Def) ([]Def, bool) {
|
||||
defaults := map[string]Def{}
|
||||
for _, d := range Defaults() {
|
||||
defaults[strings.ToUpper(d.Code)] = d
|
||||
}
|
||||
const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
|
||||
changed := false
|
||||
out := make([]Def, len(defs))
|
||||
for i, d := range defs {
|
||||
if d.Type != "" {
|
||||
out[i] = d // already on the new model
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
d.Valid = true // legacy defs predate the Valid flag → enable them
|
||||
if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
|
||||
d.Type = def.Type
|
||||
d.ExactMatch = def.ExactMatch
|
||||
d.Dynamic = def.Dynamic
|
||||
d.Protected = def.Protected
|
||||
if len(d.Validate) == 0 {
|
||||
d.Validate = def.Validate
|
||||
}
|
||||
// Fix DDFM's capture group ("06" → "D06") so refs match the list.
|
||||
if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
|
||||
d.Pattern = def.Pattern
|
||||
}
|
||||
} else {
|
||||
d.Type = TypeQSOFields // sensible default for custom legacy awards
|
||||
}
|
||||
out[i] = d
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// Fields lists the scannable QSO fields for the award editor.
|
||||
func Fields() []string {
|
||||
return []string{
|
||||
@@ -129,6 +171,7 @@ type Ref struct {
|
||||
Validated bool `json:"validated"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
ValidatedBands []string `json:"validated_bands"`
|
||||
}
|
||||
|
||||
// Result is an award's computed progress.
|
||||
@@ -151,6 +194,7 @@ type NameResolver func(field, ref string) string
|
||||
type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
validatedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
anyValidated bool
|
||||
}
|
||||
@@ -158,8 +202,9 @@ type refAgg struct {
|
||||
// refList is the per-award reference data Compute needs (a thin view of
|
||||
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
||||
type refList struct {
|
||||
byCode map[string]RefMeta // uppercased code → metadata
|
||||
codes []string // codes in input order (for stable unworked listing)
|
||||
byCode map[string]RefMeta // uppercased code → metadata
|
||||
codes []string // codes in input order (for stable unworked listing)
|
||||
withPattern []string // codes whose reference declares a regex (usually none)
|
||||
}
|
||||
|
||||
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
||||
@@ -187,6 +232,7 @@ func NewRefList(metas []RefMeta) refList {
|
||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||
if re, err := regexp.Compile(p); err == nil {
|
||||
m.re = re
|
||||
rl.withPattern = append(rl.withPattern, code)
|
||||
}
|
||||
}
|
||||
m.Code = code
|
||||
@@ -245,7 +291,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}}
|
||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
||||
agg[i][ref] = a
|
||||
}
|
||||
if band != "" {
|
||||
@@ -259,6 +305,9 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
if band != "" {
|
||||
a.validatedBands[band] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,7 +330,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
for b := range a.bands {
|
||||
@@ -303,7 +352,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
if !m.Valid {
|
||||
continue
|
||||
}
|
||||
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}}
|
||||
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}, ValidatedBands: []string{}}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
@@ -317,7 +366,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||
return r.Refs[a].Confirmed
|
||||
}
|
||||
return r.Refs[a].Ref < r.Refs[b].Ref
|
||||
return natLess(r.Refs[a].Ref, r.Refs[b].Ref)
|
||||
})
|
||||
for _, b := range sortedBands(bandWorked) {
|
||||
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
|
||||
@@ -327,6 +376,32 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
return out
|
||||
}
|
||||
|
||||
// MatchQSO returns the reference codes a single QSO contributes to for one
|
||||
// award (respecting scope + predefined enforcement). metas is the award's
|
||||
// reference list (empty/nil for dynamic awards). Used for cell drill-down.
|
||||
func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
||||
if !inScope(&d, q) {
|
||||
return nil
|
||||
}
|
||||
var re *regexp.Regexp
|
||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||
if c, err := regexp.Compile(p); err == nil {
|
||||
re = c
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rl := NewRefList(metas)
|
||||
return candidates(&d, re, q, rl, len(metas) > 0)
|
||||
}
|
||||
|
||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||
|
||||
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||
|
||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||
// name resolver as a fallback).
|
||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||
@@ -355,12 +430,16 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
||||
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||
found = regexTokens(re, raw)
|
||||
case predefined && !d.ExactMatch:
|
||||
// "Search reference inside the field": keep any reference code that
|
||||
// appears as a token (or whose per-reference pattern matches).
|
||||
up := strings.ToUpper(raw)
|
||||
for _, code := range rl.codes {
|
||||
m := rl.byCode[code]
|
||||
if (m.re != nil && m.re.MatchString(raw)) || containsToken(up, code) {
|
||||
// "Search reference inside the field": look up each token of the field in
|
||||
// the list — O(tokens), not O(all references) — plus test the few
|
||||
// references that declare a regex.
|
||||
for _, tok := range tokenize(raw) {
|
||||
if _, ok := rl.byCode[tok]; ok {
|
||||
found = append(found, tok)
|
||||
}
|
||||
}
|
||||
for _, code := range rl.withPattern {
|
||||
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||
found = append(found, code)
|
||||
}
|
||||
}
|
||||
@@ -372,8 +451,11 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
||||
if !predefined {
|
||||
return dedupe(found)
|
||||
}
|
||||
// Enforce the predefined list: keep only listed, valid references whose
|
||||
// per-reference DXCC scope (if any) includes the QSO's entity.
|
||||
// Enforce the predefined list: keep only listed, valid references. The
|
||||
// award-level DXCCFilter already scopes which QSOs are considered (see
|
||||
// inScope), so we do NOT additionally require the QSO's entity to match the
|
||||
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
||||
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
||||
var out []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range found {
|
||||
@@ -382,9 +464,6 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
||||
if !ok || !m.Valid {
|
||||
continue
|
||||
}
|
||||
if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
@@ -425,27 +504,28 @@ func dedupe(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// containsToken reports whether code appears in up (already uppercased) as a
|
||||
// whole token (delimited by non-alphanumerics), so "D6" doesn't match "D60".
|
||||
func containsToken(up, code string) bool {
|
||||
for from := 0; ; {
|
||||
idx := strings.Index(up[from:], code)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
i := from + idx
|
||||
j := i + len(code)
|
||||
leftOK := i == 0 || !isAlnum(up[i-1])
|
||||
rightOK := j == len(up) || !isAlnum(up[j])
|
||||
if leftOK && rightOK {
|
||||
return true
|
||||
}
|
||||
from = i + 1
|
||||
// tokenize splits a field into uppercased tokens on any non-alphanumeric run,
|
||||
// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553").
|
||||
// The whole trimmed value is also returned so single-token fields match.
|
||||
func tokenize(raw string) []string {
|
||||
up := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if up == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isAlnum(b byte) bool {
|
||||
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
|
||||
out := []string{up}
|
||||
cur := strings.Builder{}
|
||||
for _, r := range up {
|
||||
if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' {
|
||||
cur.WriteRune(r)
|
||||
} else if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripAffix removes a leading and/or trailing literal string before matching.
|
||||
@@ -462,6 +542,42 @@ func stripAffix(s, lead, trail string) string {
|
||||
|
||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||
|
||||
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||
|
||||
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
|
||||
// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10".
|
||||
func natLess(a, b string) bool {
|
||||
ia, ib := 0, 0
|
||||
for ia < len(a) && ib < len(b) {
|
||||
ca, cb := a[ia], b[ib]
|
||||
if isDigit(ca) && isDigit(cb) {
|
||||
ja, jb := ia, ib
|
||||
for ja < len(a) && isDigit(a[ja]) {
|
||||
ja++
|
||||
}
|
||||
for jb < len(b) && isDigit(b[jb]) {
|
||||
jb++
|
||||
}
|
||||
na := strings.TrimLeft(a[ia:ja], "0")
|
||||
nb := strings.TrimLeft(b[ib:jb], "0")
|
||||
if len(na) != len(nb) {
|
||||
return len(na) < len(nb) // fewer digits = smaller number
|
||||
}
|
||||
if na != nb {
|
||||
return na < nb
|
||||
}
|
||||
ia, ib = ja, jb
|
||||
} else {
|
||||
if ca != cb {
|
||||
return ca < cb
|
||||
}
|
||||
ia++
|
||||
ib++
|
||||
}
|
||||
}
|
||||
return len(a)-ia < len(b)-ib
|
||||
}
|
||||
|
||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||
// bands, modes, emission category, validity dates).
|
||||
func inScope(d *Def, q *qso.QSO) bool {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
@@ -66,6 +67,17 @@ func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refCodes(r Result) []string {
|
||||
out := make([]string, 0, len(r.Refs))
|
||||
for _, rf := range r.Refs {
|
||||
@@ -74,6 +86,57 @@ func refCodes(r Result) []string {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user