fix(stats): confirmed outranks worked in the band/mode matrix

Reported by VK4DX with the case that names itself: YB confirmed on 20m
digital, painted blue, because one unconfirmed YB station had also been
worked on that slot.

The ladder ran call_c > call_w > dxcc_c > dxcc_w, so a callsign worked and
not confirmed beat an entity CONFIRMED on the same band and mode. The grid
answers 'what do I still need here', and a confirmed entity needs nothing
whoever was worked afterwards — so the order is now call_c > dxcc_c >
call_w > dxcc_w.

Two things made it easy to get wrong, and both are fixed rather than
merely corrected. The code was a run of assignments where the later test
silently overwrote the earlier, so 'call worked' erased 'entity
confirmed'; it now takes the maximum. And the rule lived inside a scan
loop where nothing could reach it, so it is lifted into bandStatusCode
with a table test — including this exact case, and one that fails if
anybody reorders the constants.
This commit is contained in:
2026-08-26 10:53:36 +02:00
parent 08905a1680
commit dc8f9c5099
3 changed files with 120 additions and 21 deletions
+58
View File
@@ -0,0 +1,58 @@
package qso
import "testing"
// The colour of a matrix cell is a claim about what the operator still needs,
// and it was wrong for several releases: a callsign worked and not confirmed
// outranked an entity CONFIRMED on the same band and mode, so a slot that was
// finished showed as unfinished.
//
// Reported by VK4DX with the case that names itself: YB confirmed on 20m
// digital, shown blue, because one unconfirmed YB station had also been worked
// there.
func TestBandStatusConfirmedOutranksWorked(t *testing.T) {
cases := []struct {
name string
callWorked, callConfirmed, entityConfirm bool
want string
}{
{"entity worked only", false, false, false, "dxcc_w"},
{"this call worked, nothing confirmed", true, false, false, "call_w"},
// The regression, in one line.
{"entity confirmed, this call worked but not confirmed", true, false, true, "dxcc_c"},
{"entity confirmed, this call not worked here", false, false, true, "dxcc_c"},
{"this call confirmed", true, true, true, "call_c"},
// A confirmed contact with this call implies it was worked, but the flags
// arrive from separate SQL aggregates and nothing guarantees the pair.
{"call confirmed without the worked flag", false, true, true, "call_c"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := bandStatusNames[bandStatusCode(c.callWorked, c.callConfirmed, c.entityConfirm)]
if got != c.want {
t.Errorf("bandStatusCode(worked=%v, callConf=%v, entityConf=%v) = %s, want %s",
c.callWorked, c.callConfirmed, c.entityConfirm, got, c.want)
}
})
}
}
// The ladder itself, stated once: every confirmed code must beat every worked
// code. A future edit that reorders the constants fails here rather than in a
// screenshot from an operator.
func TestBandStatusLadderPutsConfirmedAbove(t *testing.T) {
for _, worked := range []int{stDxccW, stCallW} {
for _, confirmed := range []int{stDxccC, stCallC} {
if confirmed <= worked {
t.Errorf("%s (%d) does not outrank %s (%d)",
bandStatusNames[confirmed], confirmed, bandStatusNames[worked], worked)
}
}
}
if stCallC <= stDxccC {
t.Error("call_c must outrank dxcc_c: the callsign is the more specific claim")
}
if stCallW <= stDxccW {
t.Error("call_w must outrank dxcc_w for the same reason")
}
}
+58 -19
View File
@@ -1895,7 +1895,8 @@ type WorkedBefore struct {
// Status grid driving the band×class matrix in the UI. One entry per
// (band, class) where ANY QSO exists in this DXCC. Only the highest
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
// status for that cell is kept (call_c > dxcc_c > call_w > dxcc_w —
// confirmed outranks worked).
BandStatus []BandStatus `json:"band_status"`
}
@@ -1906,6 +1907,48 @@ type BandStatus struct {
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
}
// Band-status codes, lowest first. The ORDER is the rule: a cell shows the
// highest that applies.
const (
stDxccW = iota // the entity was worked on this slot
stCallW // …and this callsign was one of them
stDxccC // the entity is CONFIRMED here
stCallC // …and by this callsign
)
// bandStatusNames maps those codes to what the UI colours by.
var bandStatusNames = [...]string{"dxcc_w", "call_w", "dxcc_c", "call_c"}
// bandStatusCode picks the status of one cell of the band × mode matrix.
//
// CONFIRMED OUTRANKS WORKED, and that is the whole of it. The ladder used to
// run call_c > call_w > dxcc_c > dxcc_w, so a callsign worked and not confirmed
// beat an entity confirmed on the same slot: an operator with YB confirmed on
// 20m digital saw that cell as "worked, not confirmed" because he had also
// worked one unconfirmed YB station there. The grid answers "what do I still
// need", and a confirmed entity needs nothing, whoever was worked afterwards.
//
// Taken as a MAXIMUM rather than as a run of assignments, which is how the
// later test came to overwrite the earlier one in the first place.
func bandStatusCode(callWorked, callConfirmed, entityConfirmed bool) int {
code := stDxccW // there is a row at all ⇒ the entity was worked here
raise := func(c int) {
if c > code {
code = c
}
}
if callWorked {
raise(stCallW)
}
if entityConfirmed {
raise(stDxccC)
}
if callConfirmed {
raise(stCallC)
}
return code
}
// modeClass collapses ADIF modes into the three buckets DXers care about.
// Anything not voice and not CW is treated as digital.
func modeClass(mode string) string {
@@ -2167,7 +2210,18 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
// ---- Per-(band, class) status grid ----
// One pass over every distinct (band, mode) in the DXCC, aggregating
// "did this call work it?" and "was anything confirmed?" via MAX.
// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
// Status precedence: CONFIRMED OUTRANKS WORKED — call_c > dxcc_c > call_w >
// dxcc_w.
//
// It used to run call_c > call_w > dxcc_c > dxcc_w, which made a call worked
// and not confirmed outrank an entity confirmed on the same slot. Reported
// from a real log: YB confirmed on 20m digital showed BLUE, because that
// operator had also worked one unconfirmed YB station there. The cell said
// "not confirmed" about a slot that is confirmed.
//
// The grid answers "what do I still need on this band and mode", and for
// that question confirmation is the axis that matters: a confirmed entity
// needs nothing, whoever else was worked afterwards.
// Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI.
@@ -2188,12 +2242,6 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
return wb, fmt.Errorf("band status: %w", err)
}
type cellKey struct{ band, class string }
const (
stDxccW = 0
stDxccC = 1
stCallW = 2
stCallC = 3
)
best := map[cellKey]int{}
for statusRows.Next() {
var band, mode string
@@ -2202,23 +2250,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
statusRows.Close()
return wb, fmt.Errorf("scan band status: %w", err)
}
code := stDxccW // row exists ⇒ entity worked at minimum
if dxccConfirmed == 1 {
code = stDxccC
}
if callW == 1 {
code = stCallW
}
if callC == 1 {
code = stCallC
}
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
k := cellKey{band: band, class: modeClass(mode)}
if cur, ok := best[k]; !ok || code > cur {
best[k] = code
}
}
statusRows.Close()
codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"}
codeStr := bandStatusNames
for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code],