feat: Added a test tab in awards to test the matching
This commit is contained in:
@@ -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<TestRow[] | null>(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<AwardDef>) => 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) {
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
@@ -547,6 +598,101 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 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. */}
|
||||
<TabsContent value="test" className="mt-0 space-y-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||
{t('awed.testRun')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||
</div>
|
||||
|
||||
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||
|
||||
{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 (
|
||||
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||
{g.rows.length > 1 && (
|
||||
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!ex.in_scope ? (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{(ex.steps ?? []).map((s, si) => (
|
||||
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||
</span>
|
||||
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||
</div>
|
||||
{!s.skipped && !s.error && (
|
||||
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||
{s.field_value
|
||||
? <span className="font-mono">{s.field_value}</span>
|
||||
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||
</div>
|
||||
{(s.kept ?? []).map((k) => (
|
||||
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||
))}
|
||||
{(s.rejected ?? []).map((r, i2) => (
|
||||
<div key={i2} className="font-mono text-muted-foreground">
|
||||
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.manual ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── References ── */}
|
||||
<TabsContent value="refs" className="mt-0">
|
||||
<ReferencesPanel
|
||||
|
||||
Reference in New Issue
Block a user