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
+4 -2
View File
@@ -3,10 +3,12 @@
"version": "0.26.17", "version": "0.26.17",
"date": "", "date": "",
"en": [ "en": [
"Awards, RDA district comparison: each disagreement can now be settled on its own row. Click the district you keep — the log's or the database's — and apply. The choice becomes the contact's award reference, so it is what counts, while both sources keep saying what they said." "Awards, RDA district comparison: each disagreement can now be settled on its own row. Click the district you keep — the log's or the database's — and apply. The choice becomes the contact's award reference, so it is what counts, while both sources keep saying what they said.",
"The band/mode matrix: a confirmed slot now shows as confirmed. A callsign worked and not confirmed was outranking an entity CONFIRMED on the same band and mode, so a slot that needs nothing was painted as if it still did."
], ],
"fr": [ "fr": [
"Diplômes, comparaison des districts RDA : chaque divergence se règle désormais sur sa propre ligne. Cliquer le district qu'on garde — celui du log ou celui de la base — puis appliquer. Le choix devient la référence de diplôme du contact, donc c'est lui qui compte, tandis que les deux sources continuent de dire ce qu'elles disaient." "Diplômes, comparaison des districts RDA : chaque divergence se règle désormais sur sa propre ligne. Cliquer le district qu'on garde — celui du log ou celui de la base — puis appliquer. Le choix devient la référence de diplôme du contact, donc c'est lui qui compte, tandis que les deux sources continuent de dire ce qu'elles disaient.",
"Matrice bandes/modes : une case confirmée s'affiche enfin comme confirmée. Un indicatif travaillé et non confirmé l'emportait sur une entité CONFIRMÉE sur la même bande et le même mode, si bien qu'une case qui ne demandait plus rien était peinte comme s'il manquait encore quelque chose."
] ]
}, },
{ {
+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 // 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 // (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"` BandStatus []BandStatus `json:"band_status"`
} }
@@ -1906,6 +1907,48 @@ type BandStatus struct {
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" 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. // modeClass collapses ADIF modes into the three buckets DXers care about.
// Anything not voice and not CW is treated as digital. // Anything not voice and not CW is treated as digital.
func modeClass(mode string) string { 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 ---- // ---- Per-(band, class) status grid ----
// One pass over every distinct (band, mode) in the DXCC, aggregating // One pass over every distinct (band, mode) in the DXCC, aggregating
// "did this call work it?" and "was anything confirmed?" via MAX. // "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 // 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 // that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI. // 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) return wb, fmt.Errorf("band status: %w", err)
} }
type cellKey struct{ band, class string } type cellKey struct{ band, class string }
const (
stDxccW = 0
stDxccC = 1
stCallW = 2
stCallC = 3
)
best := map[cellKey]int{} best := map[cellKey]int{}
for statusRows.Next() { for statusRows.Next() {
var band, mode string var band, mode string
@@ -2202,23 +2250,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
statusRows.Close() statusRows.Close()
return wb, fmt.Errorf("scan band status: %w", err) return wb, fmt.Errorf("scan band status: %w", err)
} }
code := stDxccW // row exists ⇒ entity worked at minimum code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
if dxccConfirmed == 1 {
code = stDxccC
}
if callW == 1 {
code = stCallW
}
if callC == 1 {
code = stCallC
}
k := cellKey{band: band, class: modeClass(mode)} k := cellKey{band: band, class: modeClass(mode)}
if cur, ok := best[k]; !ok || code > cur { if cur, ok := best[k]; !ok || code > cur {
best[k] = code best[k] = code
} }
} }
statusRows.Close() statusRows.Close()
codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"} codeStr := bandStatusNames
for k, code := range best { for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{ wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code], Band: k.band, Class: k.class, Status: codeStr[code],