feat: added command sent in cluster panel

This commit is contained in:
2026-07-13 13:14:34 +02:00
parent eb9e2db41a
commit 68982e9a85
5 changed files with 280 additions and 12 deletions
+15
View File
@@ -886,6 +886,13 @@ func (a *App) startup(ctx context.Context) {
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s) wruntime.EventsEmit(a.ctx, "cluster:spot", s)
} }
// A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and
// stops there. It is a replay of the past: firing 100 alerts at once, or
// painting 100 stale stations on the panadapter as if they were on the
// air right now, would be actively misleading.
if s.Historical {
return
}
// Fire any matching alert rules (sound / visual / e-mail). // Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s) a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled. The // Mirror the spot onto the FlexRadio panadapter when enabled. The
@@ -904,6 +911,14 @@ func (a *App) startup(ctx context.Context) {
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status()) wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
} }
}, },
// Raw traffic → the cluster console. Spots are parsed out of this stream,
// but SH/DX, WHO, the MOTD and error replies are NOT spots — without this
// they were dropped on the floor and the command box looked inert.
func(l cluster.Line) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:line", l)
}
},
) )
a.refreshOperatorGrid() a.refreshOperatorGrid()
if cs, _ := a.clusterAutoConnect(); cs { if cs, _ := a.clusterAutoConnect(); cs {
+66 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -912,6 +912,31 @@ export default function App() {
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(''); const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
const [clusterGroup, setClusterGroup] = useState(true); const [clusterGroup, setClusterGroup] = useState(true);
const [clusterCmd, setClusterCmd] = useState(''); const [clusterCmd, setClusterCmd] = useState('');
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
// so a command appeared to do nothing at all.
type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string };
const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const off = EventsOn('cluster:line', (l: any) => {
setClusterLines((prev) => {
const next = [...prev, l as ClusterLine];
return next.length > CONSOLE_CAP ? next.slice(next.length - CONSOLE_CAP) : next;
});
});
return () => { off?.(); };
}, []);
// Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
// to read a SH/DX reply would yank you back down on the next spot.
useEffect(() => {
const el = clusterConsoleRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
if (atBottom) el.scrollTop = el.scrollHeight;
}, [clusterLines]);
// Multi-band filter: empty set = all bands. The user toggles chips. // Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set()); const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
// Lock-to-entry: when on, the band filter follows the entry's current // Lock-to-entry: when on, the band filter follows the entry's current
@@ -4336,8 +4361,48 @@ export default function App() {
); );
})()} })()}
{/* Console — the RAW cluster stream. Spots are parsed out of it into
the grid above, but SH/DX, WHO, the MOTD and error replies are not
spots: without this they were dropped and the command box looked
inert (you typed SH/DX/100 and nothing ever happened). */}
{clusterConsoleOpen && (
<div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
<div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
<Terminal className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('cluster.console')}
</span>
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
<div className="flex-1" />
<button className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
<button className="text-muted-foreground hover:text-foreground"
onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
<X className="size-3.5" />
</button>
</div>
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
{clusterLines.length === 0 ? (
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
) : clusterLines.map((l, i) => (
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
{l.text}
</div>
))}
</div>
</div>
)}
{/* Command input — sends to the master server. */} {/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0"> <div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
{!clusterConsoleOpen && (
<button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
<Terminal className="size-3.5" />
</button>
)}
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span> <span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input <Input
className="font-mono text-xs h-8" className="font-mono text-xs h-8"
+4
View File
@@ -68,6 +68,8 @@ const en: Dict = {
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.', 'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total', 'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —', 'stats.noGaps': 'No break of 30 min or more.', 'stats.noContest': '— No contest —',
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
'msg.expand': 'Click to read the full message', 'msg.expand': 'Click to read the full message',
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically', 'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
'offline.tip': '{n} QSO(s) waiting — the database is unreachable', 'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
@@ -309,6 +311,8 @@ const fr: Dict = {
'stats.activeHours': 'Temps en lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + hors antenne = la fenêtre, par construction.', 'stats.activeHours': 'Temps en lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + hors antenne = la fenêtre, par construction.',
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total', 'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —', 'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.noContest': '— Aucun contest —',
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
'cluster.consoleEmpty': 'Le trafic brut du cluster saffiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
'msg.expand': 'Cliquer pour lire le message en entier', 'msg.expand': 'Cliquer pour lire le message en entier',
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique', 'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
'offline.tip': '{n} QSO en attente — la base est injoignable', 'offline.tip': '{n} QSO en attente — la base est injoignable',
+139 -9
View File
@@ -61,6 +61,11 @@ type Spot struct {
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"` Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours
// ago is not on the air now.
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app) POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name POTAName string `json:"pota_name,omitempty"` // park name
} }
@@ -92,6 +97,21 @@ type ServerStatus struct {
} }
// session is one telnet connection bound to a single server config. // session is one telnet connection bound to a single server config.
// Line is one raw line of cluster traffic: everything the server says, plus the
// commands we send (echoed, so the console reads as a conversation).
//
// Spots are PARSED OUT of this stream — but everything else used to be silently
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
// the command box look broken. The raw stream is the fix: the console shows what
// the server actually said, spot or not.
type Line struct {
ServerID int64 `json:"server_id"`
ServerName string `json:"server_name"`
Text string `json:"text"`
Sent bool `json:"sent"` // true = a command WE sent
At string `json:"at"` // HH:MM:SS, local
}
// Internal — callers use Manager. The onStatus callback is fire-and- // Internal — callers use Manager. The onStatus callback is fire-and-
// forget: it tells the manager something changed; the frontend fetches // forget: it tells the manager something changed; the frontend fetches
// the new aggregate via Status() rather than receiving per-server diffs. // the new aggregate via Status() rather than receiving per-server diffs.
@@ -99,6 +119,7 @@ type session struct {
cfg ServerConfig cfg ServerConfig
login string login string
onSpot func(Spot) onSpot func(Spot)
onLine func(Line)
onStatus func() onStatus func()
mu sync.RWMutex mu sync.RWMutex
@@ -117,6 +138,7 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
sessions map[int64]*session sessions map[int64]*session
onSpot func(Spot) onSpot func(Spot)
onLine func(Line)
onStatus func() onStatus func()
} }
@@ -124,10 +146,11 @@ type Manager struct {
// spot (with the source server filled in). emitStatusChanged is called // spot (with the source server filled in). emitStatusChanged is called
// whenever ANY server's status changes — the frontend then re-fetches // whenever ANY server's status changes — the frontend then re-fetches
// the aggregate Status() via a Wails binding. // the aggregate Status() via a Wails binding.
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager { func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
return &Manager{ return &Manager{
sessions: make(map[int64]*session), sessions: make(map[int64]*session),
onSpot: emitSpot, onSpot: emitSpot,
onLine: emitLine,
onStatus: emitStatusChanged, onStatus: emitStatusChanged,
} }
} }
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
cfg: cfg, cfg: cfg,
login: login, login: login,
onSpot: m.onSpot, onSpot: m.onSpot,
onLine: m.onLine,
onStatus: m.emitStatus, onStatus: m.emitStatus,
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
doneCh: make(chan struct{}), doneCh: make(chan struct{}),
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
} }
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n")) _, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
if err == nil {
// Echo it into the console, so the operator sees WHAT was sent and can pair
// it with the reply that follows. A console that only shows one side of the
// conversation is barely better than none.
s.emitLine(cmd, true)
}
return err return err
} }
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
}() }()
} }
// Init commands: fire 1s after login goes through. Each command on // Init commands, once per connection (so they replay after a reconnect).
// its own line; blank lines and "//" comments are skipped. //
// Timing: the cluster needs a moment after login before it will take commands,
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
// happily swallows a burst of back-to-back lines and silently ignores half of
// them. A fast-but-dropped command is worse than a slow one.
//
// They go through s.send() — NOT a raw conn.Write, which is what they used to
// do. That matters for two reasons: the raw write skipped the write deadline
// (a wedged server could hang this goroutine forever), and it skipped the
// console echo, so there was no way to SEE whether your init commands had
// actually been applied. Now you watch them go out and the reply come back.
initFired := false initFired := false
fireInitCommands := func() { fireInitCommands := func() {
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" { if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
} }
initFired = true initFired = true
go func() { go func() {
time.Sleep(1 * time.Second) time.Sleep(initCommandLeadIn)
for _, line := range strings.Split(s.cfg.InitCommands, "\n") { for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
line = strings.TrimRight(line, "\r") line = strings.TrimSpace(strings.TrimRight(line, "\r"))
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") { if line == "" || strings.HasPrefix(line, "//") {
continue continue
} }
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
return return
default: default:
} }
_, _ = conn.Write([]byte(line + "\r\n")) if err := s.send(line); err != nil {
time.Sleep(500 * time.Millisecond) s.emitLine("init command failed: "+line+" — "+err.Error(), false)
return
}
time.Sleep(initCommandGap)
} }
}() }()
} }
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
fireInitCommands() fireInitCommands()
} }
if spot, ok := parseSpot(line); ok { // EVERY line goes to the console — spot or not. This is the whole point:
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
// (which is what happened before) made the command box look inert.
s.emitLine(line, false)
// Live broadcast first; then the SH/DX table form. Without the second, a
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
// "SH/DX/100" looked like it did nothing at all.
spot, ok := parseSpot(line)
if !ok {
spot, ok = parseShowDX(line)
}
if ok {
spot.SourceID = s.cfg.ID spot.SourceID = s.cfg.ID
spot.SourceName = s.cfg.Name spot.SourceName = s.cfg.Name
s.mu.Lock() s.mu.Lock()
// Historical spots are a replay of the past, not new traffic — counting
// them would inflate the server's "spots received" figure.
if !spot.Historical {
s.spotsCnt++ s.spotsCnt++
s.status.SpotsCount = s.spotsCnt s.status.SpotsCount = s.spotsCnt
}
s.mu.Unlock() s.mu.Unlock()
if s.onSpot != nil { if s.onSpot != nil {
s.onSpot(spot) s.onSpot(spot)
@@ -458,6 +516,21 @@ func (s *session) runOnce() (time.Time, error) {
} }
} }
// emitLine hands one line of traffic to the console. Blank lines are dropped —
// clusters send plenty and they'd only pad the scrollback.
func (s *session) emitLine(text string, sent bool) {
if s.onLine == nil || strings.TrimSpace(text) == "" {
return
}
s.onLine(Line{
ServerID: s.cfg.ID,
ServerName: s.cfg.Name,
Text: strings.TrimRight(text, "\r\n"),
Sent: sent,
At: time.Now().Format("15:04:05"),
})
}
// ---------- parsing ---------- // ---------- parsing ----------
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]". // spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
@@ -465,6 +538,63 @@ var spotRE = regexp.MustCompile(
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`, `^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
) )
// Pacing for the per-server init commands.
//
// A cluster is not ready to take commands the instant the login line goes out —
// it is still printing its banner — so we wait before the first one. And they are
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
// back-to-back lines and silently apply only some of them: the connection stays
// up, no error is returned, your filters just aren't set. A command that is
// dropped is far worse than one that is slow.
const (
initCommandLeadIn = 1500 * time.Millisecond
initCommandGap = 700 * time.Millisecond
)
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
// broadcast format, and therefore matched nothing at all:
//
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
// freq dxcall date time comment <spotter>
//
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
// regex, and get dropped. The decimal point in the frequency is required — it is
// what keeps ordinary prose out of the spot grid.
var showDXRE = regexp.MustCompile(
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
)
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
// Historical: these are PAST spots, so they belong in the grid but must not fire
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
// station spotted three hours ago is not on the air now.
func parseShowDX(line string) (Spot, bool) {
if strings.Contains(strings.ToUpper(line), "DX DE") {
return Spot{}, false // that's the broadcast form; spotRE owns it
}
m := showDXRE.FindStringSubmatch(line)
if m == nil {
return Spot{}, false
}
khz, err := strconv.ParseFloat(m[1], 64)
if err != nil || khz <= 0 {
return Spot{}, false
}
hz := int64(khz * 1000)
return Spot{
Spotter: strings.ToUpper(m[6]),
DXCall: strings.ToUpper(m[2]),
FreqKHz: khz,
FreqHz: hz,
Band: bandFromHz(hz),
Comment: strings.TrimSpace(m[5]),
TimeUTC: m[4] + "Z",
ReceivedAt: time.Now(),
Raw: line,
Historical: true,
}, true
}
func parseSpot(line string) (Spot, bool) { func parseSpot(line string) (Spot, bool) {
m := spotRE.FindStringSubmatch(line) m := spotRE.FindStringSubmatch(line)
if m == nil { if m == nil {
+54
View File
@@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) {
} }
} }
} }
// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely
// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply
// arrived, matched nothing and was dropped: the command looked like it did
// nothing at all. These lines must now land in the grid, flagged Historical.
func TestParseShowDX(t *testing.T) {
cases := []struct {
line string
call string
khz float64
spotter string
}{
{" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "VK9/DL2XYZ", 21025.0, "JA1ABC"},
// No spotter brackets — still a spot, just without a DE.
{" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""},
}
for _, c := range cases {
got, ok := parseShowDX(c.line)
if !ok {
t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line)
continue
}
if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter {
t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q",
c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter)
}
if !got.Historical {
t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line)
}
if got.Band == "" {
t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz)
}
}
}
// The SH/DX parser must not swallow ordinary cluster prose — a console full of
// chatter turned into fake spots would be worse than no parser at all.
func TestParseShowDXRejectsNoise(t *testing.T) {
noise := []string{
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
"Hello and welcome to the DXSpider cluster",
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
"",
"There are 42 users online",
}
for _, l := range noise {
if s, ok := parseShowDX(l); ok {
t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s)
}
}
}