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:
+4
-2
@@ -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 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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 leading slash is not a suffix marker — dropping to "" there would match
|
// A prefixed call is the same operator, and NOT every other visitor to that
|
||||||
// the entire logbook.
|
// country.
|
||||||
if _, args := callMatch("/RK3DWA", true); args[0] != "/RK3DWA" {
|
//
|
||||||
t.Errorf("leading slash: args[0] = %v, want the call unchanged", args[0])
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
@@ -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 ? OR callsign LIKE ? OR callsign LIKE ?)",
|
||||||
|
[]any{
|
||||||
|
base, // OE8NDR
|
||||||
|
base + "/%", // OE8NDR/P
|
||||||
|
"%/" + base, // ZA/OE8NDR
|
||||||
|
"%/" + base + "/%", // ZA/OE8NDR/P
|
||||||
}
|
}
|
||||||
return "(callsign = ? OR callsign LIKE ?)", []any{base, base + "/%"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
||||||
|
|||||||
Reference in New Issue
Block a user