feat(watchlist): exact match by default, * for a family

The prefix matching ported from DXHunter swallowed too much: a three-letter
special-event entry N8W lit up NEEDED for N8WCR, a different station entirely.
The implicit becomes explicit — a bare entry matches exactly that call, a
trailing star makes it a family: VK9* catches every VK9…, RI0SP* the
expedition's portable forms. A star anywhere else (or alone) is refused at Add
rather than silently matching nothing. Same rule in the backend Match and the
tab's own matcher, pinned by test.
This commit is contained in:
2026-08-29 01:25:34 +02:00
parent 72253f99fa
commit c9e98d5fdd
5 changed files with 60 additions and 19 deletions
+25 -5
View File
@@ -99,12 +99,20 @@ func (s *Store) Entries() []Entry {
return out
}
// Add creates an entry; contest marks it as re-workable every UTC day.
// Add creates an entry; contest marks it as re-workable every UTC day. A
// trailing * makes the entry a prefix (VK9* catches every VK9…); anywhere else
// the star is refused rather than silently matching nothing.
func (s *Store) Add(callsign string, contest bool) error {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return fmt.Errorf("callsign required")
}
if i := strings.Index(call, "*"); i >= 0 && i != len(call)-1 {
return fmt.Errorf("* is only allowed at the end (VK9*)")
}
if call == "*" {
return fmt.Errorf("a bare * would match every spot")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.entries[call]; ok {
@@ -153,9 +161,12 @@ func (s *Store) patch(callsign string, fn func(*Entry)) error {
// Match returns the entry a spotted callsign belongs to, or "".
//
// Prefix match, exactly as DXHunter does it: an entry RI0SP must catch
// RI0SP/MM and RI0SP/P — expeditions sign portable more often than not, and an
// exact-only match left lastSeen stale while fresh spots scrolled past.
// EXACT unless the entry says otherwise: N8W matches only N8W, and it takes
// N8W* to catch N8WCR. The first version prefix-matched everything, DXHunter
// style, and a three-letter special-event call swallowed every longer call
// sharing its start — N8W lit up for N8WCR, which is a different station
// entirely. The operator writes the star when they MEAN a family (VK9*, an
// expedition's portable forms via RI0SP*); a bare entry means that call.
func (s *Store) Match(callsign string) (string, bool) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
@@ -164,13 +175,22 @@ func (s *Store) Match(callsign string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for pattern := range s.entries {
if call == pattern || strings.HasPrefix(call, pattern) {
if PatternMatches(pattern, call) {
return pattern, true
}
}
return "", false
}
// PatternMatches reports whether one watchlist pattern covers a callsign:
// exact equality, or — with a trailing * — a prefix.
func PatternMatches(pattern, call string) bool {
if p, ok := strings.CutSuffix(pattern, "*"); ok {
return p != "" && strings.HasPrefix(call, p)
}
return call == pattern
}
// MarkSeen records a spot against the matching entry and reports whether the
// entry wants an alert.
func (s *Store) MarkSeen(callsign string) (entry string, notify bool, ok bool) {
+21 -7
View File
@@ -7,22 +7,36 @@ import (
"testing"
)
func TestPrefixMatch(t *testing.T) {
func TestGlobMatch(t *testing.T) {
s := New(filepath.Join(t.TempDir(), "watchlist.json"))
if err := s.Add("RI0SP", false); err != nil {
if err := s.Add("RI0SP*", false); err != nil {
t.Fatal(err)
}
// The reason prefix matching exists: expeditions sign portable.
if err := s.Add("N8W", false); err != nil {
t.Fatal(err)
}
// A starred entry is a family: the expedition's portable forms.
for _, call := range []string{"RI0SP", "RI0SP/MM", "RI0SP/P"} {
if _, ok := s.Match(call); !ok {
t.Errorf("Match(%q) = false, want true", call)
}
}
if _, ok := s.Match("RI0S"); ok {
t.Error("a SHORTER call must not match the entry")
// A bare entry is THAT call — the reported bug was N8W lighting up for
// N8WCR, a different station entirely.
if _, ok := s.Match("N8W"); !ok {
t.Error("exact entry must match its own call")
}
if _, ok := s.Match("F4BPO"); ok {
t.Error("an unrelated call matched")
if _, ok := s.Match("N8WCR"); ok {
t.Error("exact entry must NOT match a longer call")
}
if _, ok := s.Match("RI0S"); ok {
t.Error("a SHORTER call must not match a starred entry")
}
if err := s.Add("VK*9", false); err == nil {
t.Error("a mid-string star must be refused")
}
if err := s.Add("*", false); err == nil {
t.Error("a bare star must be refused")
}
}