diff --git a/app.go b/app.go index d194db9..7584e63 100644 --- a/app.go +++ b/app.go @@ -2472,6 +2472,54 @@ func (a *App) mirrorAwardsToFolder(defs []award.Def) { } } +// AwardExplain is one QSO run through one award's matching rules, step by step. +// It answers the question the awards grid cannot: not "did it match" but WHY. +type AwardExplain struct { + QSO qso.QSO `json:"qso"` + Explanation award.Explanation `json:"explanation"` +} + +// ExplainAward replays an award's rules against the QSOs of one callsign and +// reports every step. Written because three award bugs in a week all presented +// identically — an empty column and no way to see which rule looked where — and +// each took far longer to find than it should have. +// +// Returns the matching QSOs most recent first (a call worked several times can +// match on one QSO and not another, which is itself the answer sometimes). +func (a *App) ExplainAward(code, callsign string) ([]AwardExplain, error) { + if a.qso == nil { + return nil, fmt.Errorf("db not initialized") + } + call := strings.ToUpper(strings.TrimSpace(callsign)) + if call == "" { + return nil, fmt.Errorf("enter a callsign to test against") + } + var def *award.Def + defs := a.awardDefs() + for i := range defs { + if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) { + def = &defs[i] + break + } + } + if def == nil { + return nil, fmt.Errorf("unknown award %q", code) + } + qsos, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Limit: 20}) + if err != nil { + return nil, err + } + if len(qsos) == 0 { + return nil, fmt.Errorf("no QSO with %s in the log", call) + } + metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] + out := make([]AwardExplain, 0, len(qsos)) + for i := range qsos { + out = append(out, AwardExplain{QSO: qsos[i], Explanation: award.Explain(*def, metas, &qsos[i])}) + } + return out, nil +} + // AwardUpdate is a shipped fix an operator has NOT received, because they edited // the award and we refuse to overwrite their work. Rather than drop the fix // silently, we offer it: applying is their call, and it costs them their changes. diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 5ebe47d..7496f2d 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -19,7 +19,7 @@ import { ListCountries, DXCCForCountry, DXCCName, PopulateBuiltinReferences, HasBuiltinReferences, ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder, - GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, + GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward, } from '../../wailsjs/go/main/App'; // Above this many references the editor stops loading the whole list and @@ -212,6 +212,56 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { const cur = defs[sel]; const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null; + + // ── Award tester: run the award's rules against a real QSO and show every step. + type Rejected = { candidate: string; reason: string }; + type Step = { + rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string; + field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[]; + skipped?: boolean; error?: string; + }; + type Explanation = { + code: string; in_scope: boolean; scope_error?: string; predefined: boolean; + ref_count: number; steps: Step[]; manual?: string[]; result: string[]; + }; + type TestRow = { qso: any; explanation: Explanation }; + const [testCall, setTestCall] = useState(''); + const [testRows, setTestRows] = useState(null); + const [testErr, setTestErr] = useState(''); + const [testing, setTesting] = useState(false); + const runTest = async () => { + if (!cur) return; + setTesting(true); setTestErr(''); setTestRows(null); + try { + const r = await ExplainAward(cur.code, testCall); + setTestRows((Array.isArray(r) ? r : []) as TestRow[]); + } catch (e: any) { + setTestErr(String(e?.message ?? e)); + } finally { + setTesting(false); + } + }; + // The tester reads the SAVED award, not the unsaved edits in this dialog — so say + // so, rather than let the operator test a rule they only think they applied. + useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]); + + // Several QSOs with the same station usually trace IDENTICALLY, and twenty copies + // of the same trace is noise. But they don't always: scope is judged per QSO + // (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and + // two contacts can even hold different QTHs. That divergence is often the actual + // answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by + // trace instead of dropping it: one card per distinct outcome, with its count. + const testGroups = useMemo(() => { + if (!testRows) return null; + const groups: { key: string; rows: TestRow[] }[] = []; + for (const row of testRows) { + const key = JSON.stringify(row.explanation); + const g = groups.find((x) => x.key === key); + if (g) g.rows.push(row); + else groups.push({ key, rows: [row] }); + } + return groups; + }, [testRows]); const patch = (p: Partial) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d))); const toggleIn = (key: keyof AwardDef, v: string) => { const arr = ((cur?.[key] as string[]) ?? []); @@ -397,6 +447,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { {t('awed.tabType')} {t('awed.tabConfirmation')} {t('awed.tabReferences')} + {t('awed.tabTest')}
@@ -547,6 +598,101 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { + {/* ── Test ── + Runs the SAVED award against a real QSO and shows every rule: + what it scanned, what it produced, and why each candidate was + rejected. An award that matches nothing used to be a black box. */} + +
+
+ + setTestCall(e.target.value.toUpperCase())} + onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} /> +
+ + {t('awed.testSavedOnly')} +
+ + {testErr &&
{testErr}
} + + {testGroups?.map((g, ri) => { + const row = g.rows[0]; + const ex = row.explanation; + const matched = (ex.result ?? []).length > 0; + const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`; + return ( +
+
+ {row.qso?.callsign} + {qsoLabel(row.qso)} + {g.rows.length > 1 && ( + qsoLabel(r.qso)).join('\n')}> + {t('awed.testSameAs', { n: String(g.rows.length - 1) })} + + )} + + {matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')} + +
+ + {!ex.in_scope ? ( +
+ {t('awed.testOutOfScope')}{' '} + {ex.scope_error} +
+ ) : ( +
+ {(ex.steps ?? []).map((s, si) => ( +
+
+ {s.rule} + + {s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''} + + {s.skipped && — {t('awed.testSkipped')}} + {s.error && — {s.error}} +
+ {!s.skipped && !s.error && ( +
+
+ {t('awed.testFieldValue')}: + {s.field_value + ? {s.field_value} + : {t('awed.testEmptyField')}} +
+ {(s.kept ?? []).map((k) => ( +
✓ {k}
+ ))} + {(s.rejected ?? []).map((r, i2) => ( +
+ ✗ {r.candidate} — {r.reason} +
+ ))} + {!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && ( +
{t('awed.testNoCandidate')}
+ )} +
+ )} +
+ ))} + {(ex.manual ?? []).length > 0 && ( +
+ {t('awed.testManual')}{' '} + {(ex.manual ?? []).join(', ')} +
+ )} +
+ )} +
+ ); + })} +
+ {/* ── References ── */} ; export function DuplicateProfile(arg1:number,arg2:string):Promise; +export function ExplainAward(arg1:string,arg2:string):Promise>; + export function ExportADIF(arg1:string,arg2:boolean):Promise; export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 37cacf7..d43431a 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -262,6 +262,10 @@ export function DuplicateProfile(arg1, arg2) { return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); } +export function ExplainAward(arg1, arg2) { + return window['go']['main']['App']['ExplainAward'](arg1, arg2); +} + export function ExportADIF(arg1, arg2) { return window['go']['main']['App']['ExportADIF'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b2a0940..856fe2a 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -338,6 +338,114 @@ export namespace award { return a; } } + export class Rejected { + candidate: string; + reason: string; + + static createFrom(source: any = {}) { + return new Rejected(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.candidate = source["candidate"]; + this.reason = source["reason"]; + } + } + export class Step { + rule: string; + field: string; + match_by?: string; + exact?: boolean; + pattern?: string; + field_value?: string; + candidates?: string[]; + kept?: string[]; + rejected?: Rejected[]; + skipped?: boolean; + error?: string; + + static createFrom(source: any = {}) { + return new Step(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.rule = source["rule"]; + this.field = source["field"]; + this.match_by = source["match_by"]; + this.exact = source["exact"]; + this.pattern = source["pattern"]; + this.field_value = source["field_value"]; + this.candidates = source["candidates"]; + this.kept = source["kept"]; + this.rejected = this.convertValues(source["rejected"], Rejected); + this.skipped = source["skipped"]; + this.error = source["error"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Explanation { + code: string; + in_scope: boolean; + scope_error?: string; + predefined: boolean; + ref_count: number; + steps: Step[]; + manual?: string[]; + result: string[]; + + static createFrom(source: any = {}) { + return new Explanation(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.code = source["code"]; + this.in_scope = source["in_scope"]; + this.scope_error = source["scope_error"]; + this.predefined = source["predefined"]; + this.ref_count = source["ref_count"]; + this.steps = this.convertValues(source["steps"], Step); + this.manual = source["manual"]; + this.result = source["result"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class Ref { ref: string; @@ -369,6 +477,7 @@ export namespace award { this.validated_bands = source["validated_bands"]; } } + export class Result { code: string; name: string; @@ -1271,6 +1380,38 @@ export namespace main { this.enabled = source["enabled"]; } } + export class AwardExplain { + qso: qso.QSO; + explanation: award.Explanation; + + static createFrom(source: any = {}) { + return new AwardExplain(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.qso = this.convertValues(source["qso"], qso.QSO); + this.explanation = this.convertValues(source["explanation"], award.Explanation); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class AwardImportPreviewEntry { code: string; name: string; diff --git a/internal/award/award.go b/internal/award/award.go index fe665cc..c03865e 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -16,6 +16,7 @@ import ( "embed" "encoding/json" "errors" + "fmt" "regexp" "sort" "strconv" @@ -715,9 +716,113 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr return found } +// ── Explain: why did (or didn't) this QSO count for this award? ────────────── +// +// An award that silently matches nothing is the hardest kind of bug to see: the +// UI shows an empty column and the operator has no way to tell whether the QSO is +// out of scope, the field is empty, the rule looked in the wrong place, or the +// reference simply isn't on the list. Explain replays the matcher on ONE QSO and +// reports every step it took. + +// Rejected is a candidate a rule produced that did not survive. +type Rejected struct { + Candidate string `json:"candidate"` + Reason string `json:"reason"` +} + +// Step is one matching rule as it actually ran. +type Step struct { + Rule string `json:"rule"` // "primary", "OR 1", … + Field string `json:"field"` // the QSO field it scanned + MatchBy string `json:"match_by,omitempty"` // code | description | pattern + Exact bool `json:"exact,omitempty"` // whole field IS the reference + Pattern string `json:"pattern,omitempty"` // the rule's regex, if any + FieldValue string `json:"field_value,omitempty"` // what the field actually held + Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation + Kept []string `json:"kept,omitempty"` // what survived the reference list + Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason + Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched + Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule +} + +// Explanation is the full account of one QSO against one award. +type Explanation struct { + Code string `json:"code"` + InScope bool `json:"in_scope"` + ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope + Predefined bool `json:"predefined"` // matches are validated against a reference list + RefCount int `json:"ref_count"` // size of that list + Steps []Step `json:"steps"` + Manual []string `json:"manual,omitempty"` // references the operator assigned by hand + Result []string `json:"result"` // what the QSO finally counts for +} + +// Explain runs the matcher on a single QSO and reports what it did. It goes +// through the SAME code path as Compute — not a re-implementation — so what it +// shows is what actually happens. +func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation { + ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}} + rl := NewRefList(metas) + ex.Predefined = len(metas) > 0 && !d.Dynamic + ex.RefCount = len(metas) + + var why string + if !inScopeWhy(&d, q, &why) { + ex.ScopeError = why + return ex // out of scope: no rule ever runs, and saying so IS the answer + } + ex.InScope = true + + var re *regexp.Regexp + if p := strings.TrimSpace(d.Pattern); p != "" { + c, err := compileAwardRE(p) + if err != nil { + ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern, + Error: "bad regex: " + err.Error()}) + return ex + } + re = c + } + candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex) + return ex +} + func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { + return candidatesTrace(d, re, q, rl, hasList, nil) +} + +// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain +// passes it) each rule records what it scanned, what it produced and what was +// rejected. The tracing MUST live inside the real matcher rather than in a +// parallel "explain" implementation — a trace that can drift from the code it +// describes is worse than no trace, because it is believed. +func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string { predefined := hasList && !d.Dynamic + // run executes one rule and, when tracing, records it. + run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string { + raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined) + kept := keepRefs(predefined, rl, raw) + if ex != nil { + s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern, + FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)), + Candidates: raw, Kept: kept} + keptSet := map[string]struct{}{} + for _, k := range kept { + keptSet[k] = struct{}{} + } + for _, c := range raw { + n := normalizeRef(c) + if _, ok := keptSet[n]; ok { + continue + } + s.Rejected = append(s.Rejected, rejection(predefined, rl, n)) + } + ex.Steps = append(ex.Steps, s) + } + return kept + } + // Primary search first; the OR rules are ordered FALLBACKS — try the next // only while nothing has matched yet, and stop at the first that yields a // reference (short-circuit). So a province already found by NAME isn't also @@ -730,18 +835,30 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // Testing the raw candidate would call that a hit, skip every fallback, and // only then drop it as unlisted — leaving the QSO unmatched even though the // next rule ("find the code inside the QTH") would have found BG. - found := keepRefs(predefined, rl, searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)) - for i := 0; len(found) == 0 && i < len(d.OrRules); i++ { + found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "") + for i := range d.OrRules { r := &d.OrRules[i] + label := fmt.Sprintf("OR %d", i+1) + if len(found) > 0 { + if ex != nil { + ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch, + Pattern: r.Pattern, Skipped: true}) + } + continue // an earlier rule already matched — fallbacks short-circuit + } var rre *regexp.Regexp if p := strings.TrimSpace(r.Pattern); p != "" { c, err := compileAwardRE(p) if err != nil { + if ex != nil { + ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern, + Error: "bad regex: " + err.Error()}) + } continue // skip a rule with a bad regex rather than failing the award } rre = c } - found = keepRefs(predefined, rl, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)) + found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix) } // Merge operator-assigned references (manual override, ManualRefsKey). Lets @@ -751,8 +868,37 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // hand. Applied HERE (not just in MatchQSO) so Compute — which powers the // awards panel and the per-QSO refs editor — honours overrides too. For a // predefined award the ref is still validated against the list below. - found = append(found, keepRefs(predefined, rl, manualRefs(q, d.Code))...) - return dedupe(found) + manual := keepRefs(predefined, rl, manualRefs(q, d.Code)) + if ex != nil { + ex.Manual = manual + } + found = append(found, manual...) + out := dedupe(found) + if ex != nil { + ex.Result = out + } + return out +} + +// rejection explains why a candidate the operator can SEE in the trace did not +// become a reference. "Nothing matched" is the least useful thing a matcher can +// say; every one of this week's award bugs was a rejection with a plain reason +// that nothing was printing. +func rejection(predefined bool, rl refList, code string) Rejected { + switch { + case code == "": + return Rejected{Candidate: code, Reason: "empty"} + case !predefined: + return Rejected{Candidate: code, Reason: "duplicate"} + } + m, ok := rl.byCode[code] + if !ok { + return Rejected{Candidate: code, Reason: "not in the award's reference list"} + } + if !m.Valid { + return Rejected{Candidate: code, Reason: "listed but disabled"} + } + return Rejected{Candidate: code, Reason: "duplicate"} } // keepRefs reduces a rule's raw candidates to the references that actually count. @@ -911,24 +1057,37 @@ func natLess(a, b string) bool { // 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) { +func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) } + +// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the +// QSO is out of scope AND a caller asked for the reason (Explain does; Compute, +// which runs this for every QSO × every award, passes nil and pays nothing). +// Keeping both behind one function is the point: a scope check that disagrees with +// the scope check it explains would be worse than no explanation at all. +func inScopeWhy(d *Def, q *qso.QSO, why *string) bool { + fail := func(format string, args ...any) bool { + if why != nil { + *why = fmt.Sprintf(format, args...) + } return false } + if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) { + return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter) + } if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) { - return false + return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands) } if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) { - return false + return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes) } if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) { - return false + return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission) } if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom { - return false + return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom) } if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo { - return false + return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo) } return true } diff --git a/internal/award/award_test.go b/internal/award/award_test.go index ff3fb1a..748a6c7 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -384,6 +384,65 @@ func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) { t.Errorf("BG not worked; refs = %v", refCodes(r)) } +// Explain must account for the WAIP case exactly: the primary rule produces a +// candidate that is NOT a province, says so, and the QTH fallback then finds BG. +func TestExplainAccountsForEveryRule(t *testing.T) { + def := Def{ + Code: "WAIP", Valid: true, Type: TypeReference, + Field: "address", MatchBy: "code", ExactMatch: true, + OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, + Confirm: []string{"lotw"}, + } + metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}} + q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"} + + ex := Explain(def, metas, q) + if !ex.InScope || len(ex.Steps) != 2 { + t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex) + } + // The primary rule LOOKS like it found something. Saying only "no match" here is + // what cost hours: the trace has to show the candidate and why it lost. + p := ex.Steps[0] + if len(p.Candidates) == 0 { + t.Error("primary produced no candidate; the whole point is that it produces a bogus one") + } + if len(p.Kept) != 0 || len(p.Rejected) == 0 { + t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected) + } + if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") { + t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason) + } + if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" { + t.Errorf("OR 1 = %+v, want it to run and find BG", or1) + } + if len(ex.Result) != 1 || ex.Result[0] != "BG" { + t.Errorf("result = %v, want [BG]", ex.Result) + } + + // A trace that disagrees with the matcher is worse than none: it is believed. + if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") { + t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got) + } +} + +func TestExplainOutOfScopeSaysWhy(t *testing.T) { + def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true, + ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}} + q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA", + QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)} + + ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q) + if ex.InScope { + t.Fatal("a 2 m QSO is not in scope for a 6 m-only award") + } + if !strings.Contains(ex.ScopeError, "2m") { + t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError) + } + if len(ex.Steps) != 0 { + t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps)) + } +} + func refCodes(r Result) []string { out := make([]string, 0, len(r.Refs)) for _, rf := range r.Refs {