diff --git a/changelog.json b/changelog.json index 1e69475..f868d8c 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,13 @@ "date": "", "en": [ "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": [ "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 n’apparaî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 n’apparaî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." ] }, { diff --git a/internal/qso/callmatch_test.go b/internal/qso/callmatch_test.go index 642bb88..d94d893 100644 --- a/internal/qso/callmatch_test.go +++ b/internal/qso/callmatch_test.go @@ -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) + } } } diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 78eba1e..3e51c88 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -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. diff --git a/internal/qso/stats_test.go b/internal/qso/stats_test.go index 31205b0..29399ec 100644 --- a/internal/qso/stats_test.go +++ b/internal/qso/stats_test.go @@ -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:45–14: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:45–14: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{})