fix(worked-before): a prefixed call is one operator, not a whole country

Reported from a screenshot: working ZA/OE8NDR, the worked-before list showed
ZA/IZ2DPX and ZA/IW2JOP beside him and the header counted all four as contacts
"with this call".

callMatch folded portable forms together by taking the part before the slash.
That is right when the slash carries a suffix — RK3DWA/3, RK3DWA/P are one
person — and exactly wrong when it carries a prefix: ZA/OE8NDR became the
station "ZA", and the predicate `callsign LIKE 'ZA/%'` then selected every other
visitor to Albania.

The operator's own call is now picked out properly: drop the known qualifiers
(P, M, MM, AM, QRP, a bare call-area digit) and of what remains take the longest
part — a country prefix is short by nature, ZA, F, KH6, VP2E, and a callsign is
not. The predicate matches the prefixed forms too, so ZA/OE8NDR and a plain
OE8NDR still find each other, which is the whole point of the fold.

A base under three characters falls back to an exact match: a malformed entry
must not produce a LIKE that selects half the logbook.
This commit is contained in:
2026-08-16 18:08:28 +02:00
parent b5a88ee5a2
commit 14ac73028e
4 changed files with 146 additions and 31 deletions
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "", "date": "",
"en": [ "en": [
"Opening the Awards panel no longer pulls the whole logbook several times at once — a large log briefly took gigabytes of memory.", "Opening the Awards panel no longer pulls the whole logbook several times at once — a large log briefly took gigabytes of memory.",
"Awards: the Missing refs button now only appears where it means something — a worldwide award like POTA could only ever answer “nothing found”." "Awards: the Missing refs button now only appears where it means something — a worldwide award like POTA could only ever answer “nothing found”.",
"Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator."
], ],
"fr": [ "fr": [
"Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.", "Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.",
"Awards : le bouton Réf. manquantes napparaît plus que là où il a un sens — un diplôme mondial comme POTA ne pouvait répondre que « aucun manque »." "Awards : le bouton Réf. manquantes napparaît plus que là où il a un sens — un diplôme mondial comme POTA ne pouvait répondre que « aucun manque ».",
"Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur."
] ]
}, },
{ {
+77 -15
View File
@@ -2,6 +2,32 @@ package qso
import "testing" import "testing"
// baseCall picks the operator's OWN callsign out of a portable form. The slash
// carries a qualifier on either side and which side depends on what it is, so
// this is the whole difficulty.
func TestBaseCall(t *testing.T) {
for in, want := range map[string]string{
"RK3DWA": "RK3DWA",
"RK3DWA/3": "RK3DWA", // call-area digit
"RK3DWA/P": "RK3DWA", // portable
"RK3DWA/QRP": "RK3DWA",
"RK3DWA/MM": "RK3DWA",
"ZA/OE8NDR": "OE8NDR", // an Austrian in Albania — the case that prompted this
"ZA/IZ2DPX": "IZ2DPX",
"F/DL1ABC": "DL1ABC",
"F/DL1ABC/P": "DL1ABC",
"KH6/K6ABC": "K6ABC",
"VP2E/W1ABC": "W1ABC",
"3DA0/ZS1ABC": "ZS1ABC",
"9A/S51AB": "S51AB",
"/RK3DWA": "RK3DWA",
} {
if got := baseCall(in); got != want {
t.Errorf("baseCall(%q) = %q, want %q", in, got, want)
}
}
}
// The predicate behind "Worked before". Exact when folding is off; with it on, // The predicate behind "Worked before". Exact when folding is off; with it on,
// a station's portable forms are one operator — and the fold has to work from // a station's portable forms are one operator — and the fold has to work from
// either end, because you may type the base call or the portable one. // either end, because you may type the base call or the portable one.
@@ -10,25 +36,61 @@ func TestCallMatch(t *testing.T) {
t.Errorf("exact: got %q %v", pred, args) t.Errorf("exact: got %q %v", pred, args)
} }
// Typing the base call: match it and everything suffixed off it. // Typing the base call: match it, everything suffixed off it, and every
pred, args := callMatch("RK3DWA", true) // prefixed form of it.
if pred != "(callsign = ? OR callsign LIKE ?)" { _, args := callMatch("RK3DWA", true)
t.Errorf("variants predicate = %q", pred) want := []any{"RK3DWA", "RK3DWA/%", "%/RK3DWA", "%/RK3DWA/%"}
} assertArgs(t, "base call", args, want)
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
t.Errorf("variants args = %v, want [RK3DWA RK3DWA/%%]", args)
}
// Typing a portable form must reach the plain call too — the suffix is // Typing a portable form must reach the plain call too — the qualifier is
// stripped from the INPUT, not just matched in the column. // stripped from the INPUT, not just matched in the column.
_, args = callMatch("RK3DWA/3", true) _, args = callMatch("RK3DWA/3", true)
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" { assertArgs(t, "portable input", args, want)
t.Errorf("portable input args = %v, want [RK3DWA RK3DWA/%%]", args) }
// A prefixed call is the same operator, and NOT every other visitor to that
// country.
//
// Matching on the part before the slash made ZA/OE8NDR the station "ZA", so the
// worked-before list for one Austrian operating from Albania showed every other
// ZA/ guest — two Italians — and counted them as four contacts "with this
// call". Reported from a screenshot of exactly that.
func TestCallMatchPrefixedIsTheOperatorNotTheCountry(t *testing.T) {
_, args := callMatch("ZA/OE8NDR", true)
assertArgs(t, "ZA/OE8NDR", args, []any{"OE8NDR", "OE8NDR/%", "%/OE8NDR", "%/OE8NDR/%"})
for _, a := range args {
if s, _ := a.(string); s == "ZA" || s == "ZA/%" {
t.Fatalf("the country prefix is still being matched as a station: %v", args)
}
} }
// A leading slash is not a suffix marker — dropping to "" there would match // And the two are not each other: nothing in one operator's predicate can
// the entire logbook. // select the other's call.
if _, args := callMatch("/RK3DWA", true); args[0] != "/RK3DWA" { _, other := callMatch("ZA/IZ2DPX", true)
t.Errorf("leading slash: args[0] = %v, want the call unchanged", args[0]) assertArgs(t, "ZA/IZ2DPX", other, []any{"IZ2DPX", "IZ2DPX/%", "%/IZ2DPX", "%/IZ2DPX/%"})
}
// A base too short to be a callsign falls back to an exact match. "F/DL1ABC"
// resolves fine, but a malformed entry must never produce a LIKE that selects
// half the logbook.
func TestCallMatchRefusesAShortBase(t *testing.T) {
for _, call := range []string{"F/", "9A", "/P", "K/M"} {
pred, args := callMatch(call, true)
if pred != "callsign = ?" || len(args) != 1 {
t.Errorf("%q produced %q %v — want an exact match", call, pred, args)
}
}
}
func assertArgs(t *testing.T, what string, got, want []any) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: args %v, want %v", what, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s: args %v, want %v", what, got, want)
}
} }
} }
+57 -6
View File
@@ -1723,26 +1723,77 @@ type BandMode struct {
// rendering a recent-contacts mini-list. // rendering a recent-contacts mini-list.
const maxWorkedEntries = 50 const maxWorkedEntries = 50
// operatorSuffixes are the appendages that qualify a callsign without changing
// whose it is. A bare digit (RK3DWA/3) counts too, handled separately.
var operatorSuffixes = map[string]bool{
"P": true, "M": true, "MM": true, "AM": true, "QRP": true,
"A": true, "B": true, "J": true, "LH": true, "R": true, "T": true,
}
// baseCall extracts the operator's OWN callsign from a portable form.
//
// The slash carries the qualifier on either side, and which side depends on
// what it is: RK3DWA/3 and RK3DWA/P append to the call, while ZA/OE8NDR and
// F/DL1ABC put a country prefix in front of it. Taking the part before the
// slash — which is what this used to do — reads ZA/OE8NDR as the station "ZA".
//
// So: drop the known qualifiers, and of what is left take the longest part. A
// prefix is short by nature (ZA, F, KH6, VP2E) and a callsign is not.
func baseCall(call string) string {
if !strings.Contains(call, "/") {
return call
}
best := ""
for _, p := range strings.Split(call, "/") {
if p == "" || operatorSuffixes[p] {
continue
}
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
continue // the call-area digit
}
if len(p) > len(best) {
best = p
}
}
if best == "" {
return call
}
return best
}
// callMatch builds the WHERE fragment that selects one station's QSOs. // callMatch builds the WHERE fragment that selects one station's QSOs.
// //
// Exact by default. With variants on, an operator's portable forms count as the // Exact by default. With variants on, an operator's portable forms count as the
// same station: RK3DWA, RK3DWA/3, RK3DWA/P and RK3DWA/QRP are one person, and // same station: RK3DWA, RK3DWA/3, RK3DWA/P and RK3DWA/QRP are one person, and
// someone asking "have I worked RK3DWA?" means the person, not the string. // someone asking "have I worked RK3DWA?" means the person, not the string.
// Typing 21 QSOs' worth of history only when you happen to add "/3" is the // Typing 21 QSOs' worth of history only when you happen to add "/3" is the
// behaviour this replaces. The suffix is stripped from what was TYPED too, so // behaviour this replaces.
// it matches both ways round — RK3DWA/3 also finds the plain RK3DWA contacts. //
// PREFIXED FORMS MATCH TOO, and getting that wrong is what prompted this.
// Matching on the part before the slash turned ZA/OE8NDR into the station "ZA",
// so the worked-before list for one Austrian operating from Albania showed
// every OTHER visitor to Albania — two Italians and himself — and counted them
// as four contacts "with this call".
// //
// Deliberately NOT a bare "starts with": LIKE 'RK3DWA%' would also drag in // Deliberately NOT a bare "starts with": LIKE 'RK3DWA%' would also drag in
// RK3DWAB, which is a different station. The '/' is what makes it the same one. // RK3DWAB, which is a different station. The '/' is what makes it the same one.
// And the variant match is only used on a base of three characters or more —
// below that a prefix could match half the log.
func callMatch(call string, variants bool) (string, []any) { func callMatch(call string, variants bool) (string, []any) {
if !variants { if !variants {
return "callsign = ?", []any{call} return "callsign = ?", []any{call}
} }
base := call base := baseCall(call)
if i := strings.IndexByte(base, '/'); i > 0 { if len(base) < 3 {
base = base[:i] return "callsign = ?", []any{call}
} }
return "(callsign = ? OR callsign LIKE ?)", []any{base, base + "/%"} return "(callsign = ? OR callsign LIKE ? OR callsign LIKE ? OR callsign LIKE ?)",
[]any{
base, // OE8NDR
base + "/%", // OE8NDR/P
"%/" + base, // ZA/OE8NDR
"%/" + base + "/%", // ZA/OE8NDR/P
}
} }
// WorkedBefore returns aggregated history at both callsign and DXCC level. // WorkedBefore returns aggregated history at both callsign and DXCC level.
+8 -8
View File
@@ -70,12 +70,12 @@ func TestStatsNoNilSlices(t *testing.T) {
} }
// Contest metrics over a window. The two traps: // Contest metrics over a window. The two traps:
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour — // 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
// a run straddling 13:4514:45 is invisible to clock-hour bucketing, and the // a run straddling 13:4514:45 is invisible to clock-hour bucketing, and the
// rolling figure is the one contesters quote. // rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included) // 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an // AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score. // 8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) { func TestContestPeriodMetrics(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC) base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) } at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
@@ -90,8 +90,8 @@ func TestContestPeriodMetrics(t *testing.T) {
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 … times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
} }
from := base // 12:00 from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
var s Stats var s Stats
s.periodMetrics(times, from, to, time.Time{}, time.Time{}) s.periodMetrics(times, from, to, time.Time{}, time.Time{})