award
This commit is contained in:
+331
-47
@@ -21,31 +21,83 @@ import (
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Def defines one award.
|
||||
// AwardType selects how a QSO is matched to an award's references.
|
||||
//
|
||||
// "DXCC" — match the QSO's DXCC entity number (references keyed by entity)
|
||||
// "QSOFIELDS" — search a QSO field for a reference code/description/pattern
|
||||
// "REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
|
||||
// the per-reference DXCC list (e.g. RAC provinces by state)
|
||||
// "GRID" — match a Maidenhead grid square
|
||||
type AwardType = string
|
||||
|
||||
const (
|
||||
TypeDXCC AwardType = "DXCC"
|
||||
TypeQSOFields AwardType = "QSOFIELDS"
|
||||
TypeReference AwardType = "REFERENCE"
|
||||
TypeGrid AwardType = "GRID"
|
||||
)
|
||||
|
||||
// Def defines one award. Fields mirror Log4OM's Award Management model: an
|
||||
// identity + scope (when the award applies) + a matching rule (how a QSO maps
|
||||
// to a reference) + confirmation rules. Most fields are optional; the zero
|
||||
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
|
||||
// behaves as before.
|
||||
type Def struct {
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl
|
||||
Total int `json:"total"` // known denominator (0 = unknown)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
// --- Identity ---
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Description string `json:"description,omitempty"` // free text
|
||||
Valid bool `json:"valid"` // award enabled
|
||||
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||
URL string `json:"url,omitempty"` // award home page
|
||||
DownloadURL string `json:"download_url,omitempty"` // reference-list source
|
||||
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||
Alias string `json:"alias,omitempty"`
|
||||
|
||||
// --- Type & matching ---
|
||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||
|
||||
// --- Scope ---
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
|
||||
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||
|
||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
}
|
||||
|
||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||
func Defaults() []Def {
|
||||
lq := []string{"lotw", "qsl"}
|
||||
return []Def{
|
||||
{Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, Builtin: true},
|
||||
{Code: "WAS", Name: "Worked All States", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lq, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lq, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lq, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: []string{"lotw", "qsl", "eqsl"}, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lq, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: []string{"qsl"}, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +122,11 @@ type BandCount struct {
|
||||
type Ref struct {
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
}
|
||||
@@ -83,6 +138,7 @@ type Result struct {
|
||||
Field string `json:"field"`
|
||||
Worked int `json:"worked"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
Validated int `json:"validated"`
|
||||
Total int `json:"total"`
|
||||
Bands []BandCount `json:"bands"`
|
||||
Refs []Ref `json:"refs"`
|
||||
@@ -96,11 +152,63 @@ type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
anyValidated bool
|
||||
}
|
||||
|
||||
// Compute runs every definition over the QSOs in a single pass.
|
||||
func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
// Pre-compile patterns once per award (not per QSO).
|
||||
// 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)
|
||||
}
|
||||
|
||||
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
||||
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
|
||||
// label results.
|
||||
type RefMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Group string
|
||||
SubGrp string
|
||||
DXCCList []int // nil = any
|
||||
Pattern string
|
||||
re *regexp.Regexp
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||
func NewRefList(metas []RefMeta) refList {
|
||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||
for _, m := range metas {
|
||||
code := normalizeRef(m.Code)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||
if re, err := regexp.Compile(p); err == nil {
|
||||
m.re = re
|
||||
}
|
||||
}
|
||||
m.Code = code
|
||||
if _, dup := rl.byCode[code]; !dup {
|
||||
rl.codes = append(rl.codes, code)
|
||||
}
|
||||
rl.byCode[code] = m
|
||||
}
|
||||
return rl
|
||||
}
|
||||
|
||||
// Compute runs every definition over the QSOs in a single pass. refMetas maps an
|
||||
// award code to its reference metadata; awards present there with Dynamic=false
|
||||
// are "predefined" (only listed references count, and the full list — including
|
||||
// unworked references — appears in the result).
|
||||
func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf NameResolver) []Result {
|
||||
refLists := make(map[string]refList, len(refMetas))
|
||||
for code, metas := range refMetas {
|
||||
refLists[strings.ToUpper(strings.TrimSpace(code))] = NewRefList(metas)
|
||||
}
|
||||
|
||||
// Pre-compile award-level patterns once.
|
||||
res := make([]*regexp.Regexp, len(defs))
|
||||
perr := make([]string, len(defs))
|
||||
for i := range defs {
|
||||
@@ -123,18 +231,17 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
q := &qsos[qi]
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
if perr[i] != "" {
|
||||
if perr[i] != "" || !inScope(d, q) {
|
||||
continue
|
||||
}
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
continue
|
||||
}
|
||||
refs := refValues(d, res[i], q)
|
||||
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||
refs := candidates(d, res[i], q, rl, hasList)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
@@ -150,6 +257,9 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +267,8 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
out := make([]Result, len(defs))
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||
predefined := hasList && !d.Dynamic
|
||||
r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
|
||||
bandWorked := map[string]int{}
|
||||
bandConfirmed := map[string]int{}
|
||||
@@ -165,10 +277,12 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
if a.anyConfirmed {
|
||||
r.Confirmed++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
if nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, ref)
|
||||
if a.anyValidated {
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
for b := range a.bands {
|
||||
bandWorked[b]++
|
||||
@@ -177,7 +291,29 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
bandConfirmed[b]++
|
||||
}
|
||||
}
|
||||
// Predefined awards: the full list is the denominator, and unworked
|
||||
// references are listed too (greyed in the UI).
|
||||
if predefined {
|
||||
r.Total = len(rl.codes)
|
||||
for _, code := range rl.codes {
|
||||
if _, worked := agg[i][code]; worked {
|
||||
continue
|
||||
}
|
||||
m := rl.byCode[code]
|
||||
if !m.Valid {
|
||||
continue
|
||||
}
|
||||
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
r.Refs = append(r.Refs, rf)
|
||||
}
|
||||
}
|
||||
sort.Slice(r.Refs, func(a, b int) bool {
|
||||
if r.Refs[a].Worked != r.Refs[b].Worked {
|
||||
return r.Refs[a].Worked // worked first
|
||||
}
|
||||
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||
return r.Refs[a].Confirmed
|
||||
}
|
||||
@@ -191,41 +327,189 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
return out
|
||||
}
|
||||
|
||||
// refValues extracts the reference(s) a QSO contributes to an award.
|
||||
func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string {
|
||||
raw := fieldRaw(d.Field, q)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
// 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) {
|
||||
if hasList {
|
||||
if m, ok := rl.byCode[code]; ok {
|
||||
rf.Name, rf.Group, rf.SubGrp = m.Name, m.Group, m.SubGrp
|
||||
}
|
||||
}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
}
|
||||
|
||||
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
||||
// a predefined list when one applies.
|
||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if re == nil {
|
||||
return []string{normalizeRef(raw)}
|
||||
predefined := hasList && !d.Dynamic
|
||||
|
||||
var found []string
|
||||
switch {
|
||||
case re != nil:
|
||||
// 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) {
|
||||
found = append(found, code)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Whole field value is the candidate.
|
||||
found = []string{normalizeRef(raw)}
|
||||
}
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
|
||||
if !predefined {
|
||||
return dedupe(found)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
// Enforce the predefined list: keep only listed, valid references whose
|
||||
// per-reference DXCC scope (if any) includes the QSO's entity.
|
||||
var out []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range found {
|
||||
c = normalizeRef(c)
|
||||
m, ok := rl.byCode[c]
|
||||
if !ok || !m.Valid {
|
||||
continue
|
||||
}
|
||||
if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func regexTokens(re *regexp.Regexp, raw string) []string {
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
ref := m[0]
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
ref = m[1]
|
||||
}
|
||||
ref = normalizeRef(ref)
|
||||
if ref == "" {
|
||||
if ref = normalizeRef(ref); ref != "" {
|
||||
out = append(out, ref)
|
||||
}
|
||||
}
|
||||
return dedupe(out)
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
if len(in) <= 1 {
|
||||
return in
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := in[:0]
|
||||
for _, s := range in {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[ref]; dup {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
out = append(out, ref)
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func isAlnum(b byte) bool {
|
||||
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
|
||||
}
|
||||
|
||||
// stripAffix removes a leading and/or trailing literal string before matching.
|
||||
func stripAffix(s, lead, trail string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if lead != "" {
|
||||
s = strings.TrimPrefix(s, lead)
|
||||
}
|
||||
if trail != "" {
|
||||
s = strings.TrimSuffix(s, trail)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||
|
||||
// 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 {
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
return false
|
||||
}
|
||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||
return false
|
||||
}
|
||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||
return false
|
||||
}
|
||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||
return false
|
||||
}
|
||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||
return false
|
||||
}
|
||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsFold(list []string, v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// emissionOf maps an ADIF mode to its broad emission category.
|
||||
func emissionOf(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "PHONE", "C4FM":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "DIGITAL"
|
||||
}
|
||||
}
|
||||
|
||||
// fieldRaw returns the raw string value of a QSO field (computed for numeric /
|
||||
// derived fields). Unknown fields yield "".
|
||||
func fieldRaw(field string, q *qso.QSO) string {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||
{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)
|
||||
res := Compute(Defaults(), qsos, nil, nil)
|
||||
by := map[string]Result{}
|
||||
for _, r := range res {
|
||||
by[r.Code] = r
|
||||
@@ -73,3 +73,37 @@ func refCodes(r Result) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user