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
+77 -15
View File
@@ -2,6 +2,32 @@ package qso
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,
// 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.
@@ -10,25 +36,61 @@ func TestCallMatch(t *testing.T) {
t.Errorf("exact: got %q %v", pred, args)
}
// Typing the base call: match it and everything suffixed off it.
pred, args := callMatch("RK3DWA", true)
if pred != "(callsign = ? OR callsign LIKE ?)" {
t.Errorf("variants predicate = %q", pred)
}
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
t.Errorf("variants args = %v, want [RK3DWA RK3DWA/%%]", args)
}
// Typing the base call: match it, everything suffixed off it, and every
// prefixed form of it.
_, args := callMatch("RK3DWA", true)
want := []any{"RK3DWA", "RK3DWA/%", "%/RK3DWA", "%/RK3DWA/%"}
assertArgs(t, "base call", args, want)
// 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.
_, args = callMatch("RK3DWA/3", true)
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
t.Errorf("portable input args = %v, want [RK3DWA RK3DWA/%%]", args)
assertArgs(t, "portable input", args, want)
}
// 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
// the entire logbook.
if _, args := callMatch("/RK3DWA", true); args[0] != "/RK3DWA" {
t.Errorf("leading slash: args[0] = %v, want the call unchanged", args[0])
// And the two are not each other: nothing in one operator's predicate can
// select the other's call.
_, other := callMatch("ZA/IZ2DPX", true)
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.
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.
//
// 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
// 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
// behaviour this replaces. The suffix is stripped from what was TYPED too, so
// it matches both ways round — RK3DWA/3 also finds the plain RK3DWA contacts.
// behaviour this replaces.
//
// 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
// 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) {
if !variants {
return "callsign = ?", []any{call}
}
base := call
if i := strings.IndexByte(base, '/'); i > 0 {
base = base[:i]
base := baseCall(call)
if len(base) < 3 {
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.
+8 -8
View File
@@ -70,12 +70,12 @@ func TestStatsNoNilSlices(t *testing.T) {
}
// Contest metrics over a window. The two traps:
// 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
// rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score.
// 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
// rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) {
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) }
@@ -90,8 +90,8 @@ func TestContestPeriodMetrics(t *testing.T) {
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
}
from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
var s Stats
s.periodMetrics(times, from, to, time.Time{}, time.Time{})