Files
OpsLog/spotcolors_test.go
rouggy f50806fbd9 feat: panadapter spots that read, a CI-V link that survives, no auto-call
Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.

"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.

And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.

CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.

Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.

Also:
  - the log rotates while running, not only at startup: the CI-V trace left on
    wrote 416 MB and nothing would have stopped it before the disk did. Closing
    it now releases the crash file too — the runtime keeps its own duplicate.
  - the interface zoom announces itself, with a badge, a click back to 100% and
    a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
  - no more elastic bounce, and no swipe-to-navigate out of the app.
  - Edit QSO: your own TX power and the contacted station's extended locator
    were saved and written back with no box to set them.
  - FT decodes: continents are a multiple choice; a compound MSHV message that
    answers two stations in one line is recognised as addressed to you.
2026-08-23 01:16:26 +02:00

168 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"strings"
"testing"
)
// A colour the Flex refuses is a spot that never appears, and the command error
// goes to a log nobody is reading. Anything that is not #AARRGGBB must be
// dropped here rather than sent.
func TestHexARGB(t *testing.T) {
for _, ok := range []string{"#FFFF3B30", "#00000000", "#ffabcdef", "#40FF6B22"} {
if !hexARGB(ok) {
t.Errorf("%q should be accepted", ok)
}
}
for _, bad := range []string{
"", "#FFF", "#FF3B30", "FFFF3B30", "#FFFF3B3", "#FFFF3B300",
"#GGFF3B30", "#FFFF3B3G", "red", "rgb(255,0,0)",
} {
if hexARGB(bad) {
t.Errorf("%q should be refused", bad)
}
}
}
// The stored palette must survive a round trip, and must not carry a status the
// resolver does not know — an unknown key would be a colour nothing can ever use
// and a field nobody can remove from the panel.
func TestNormSpotColors(t *testing.T) {
in := SpotColors{Enabled: true, Colors: map[string]SpotColor{
"new": {Text: "#FFFF3B30", Bg: "#40FF3B30"},
"new-band": {Text: "not a colour", Bg: "#40FFCC00"}, // half valid
"worked": {Text: "", Bg: ""}, // nothing at all
"invented": {Text: "#FF000000"}, // not a status
}}
out := normSpotColors(in)
if got := out.Colors["new"].Text; got != "#FFFF3B30" {
t.Errorf("valid pair lost: %q", got)
}
if c := out.Colors["new-band"]; c.Text != "" || c.Bg != "#40FFCC00" {
t.Errorf("half-valid pair = %+v, want the background kept and the text dropped", c)
}
if _, ok := out.Colors["worked"]; ok {
t.Errorf("an entry with no colour at all must not be stored")
}
if _, ok := out.Colors["invented"]; ok {
t.Errorf("an unknown status must not be stored")
}
}
func TestSpotComment(t *testing.T) {
// What the panadapter shows: the cluster's words, who spotted it, the entity,
// then what it is worth — DXHunter's order, so an operator reads one
// convention and not two.
got := spotComment("CQ up 2", "F4BPO", "Franz Josef Land", "new-slot")
want := "CQ up 2 [F4BPO] [Franz Josef Land] [New Slot]"
if plain(got) != want {
t.Fatalf("got %q, want %q", plain(got), want)
}
// EVERY space is non-breaking, the ones inside the cluster comment included:
// SmartSDR splits its command line on spaces, so a real one truncates the
// comment at the first word.
if strings.Contains(got, " ") {
t.Fatalf("an ordinary space survived: %q", got)
}
if !strings.Contains(got, " ") {
t.Fatal("no non-breaking space in the comment")
}
// Missing parts leave no empty brackets and no double separators.
if p := plain(spotComment("", "", "", "new")); p != "[New DXCC]" {
t.Fatalf("got %q", p)
}
if p := plain(spotComment("CQ", "", "Spain", "")); p != "CQ [Spain]" {
t.Fatalf("got %q", p)
}
// A status with nothing to announce contributes no tag.
if p := plain(spotComment("loud", "DL1ABC", "Germany", "none")); p != "loud [DL1ABC] [Germany]" {
t.Fatalf("got %q", p)
}
// Every colourable status except the unresolved one is labelled, or the
// palette would say something the text does not.
for _, st := range spotColorOrder {
if st == "none" {
continue
}
if spotStatusTag(st) == "" {
t.Fatalf("status %q has a colour but no tag", st)
}
}
}
// plain puts the ordinary spaces back, for readable assertions.
func plain(s string) string { return strings.ReplaceAll(s, " ", " ") }
// The per-status switch must survive a save with no colours on that row: it is
// an instruction in its own right, and dropping it would quietly turn the status
// back on. And a palette written before the switch existed must keep sending
// everything — "no field" is not "the operator turned this off".
func TestSpotColorHideSurvivesNormalisation(t *testing.T) {
in := SpotColors{Enabled: true, Colors: map[string]SpotColor{
"worked": {Hide: true}, // no colours, just the switch
"new": {Text: "#FFFF3B30", Hide: true}, // both
"none": {Text: "not a colour"}, // neither survives
"new-band": {Text: "#FFFFCC00"}, // colour only
}}
out := normSpotColors(in)
if c, ok := out.Colors["worked"]; !ok || !c.Hide {
t.Error("a status hidden with no colour was dropped")
}
if c := out.Colors["new"]; !c.Hide || c.Text != "#FFFF3B30" {
t.Errorf("hide and colour did not both survive: %+v", c)
}
if _, ok := out.Colors["none"]; ok {
t.Error("an entry with nothing valid in it was kept")
}
if c := out.Colors["new-band"]; c.Hide {
t.Error("a colour-only entry came back hidden")
}
// An older palette says nothing about hiding, and must send everything.
old := normSpotColors(SpotColors{Enabled: true, Colors: map[string]SpotColor{"new": {Text: "#FFFF3B30"}}})
if old.Colors["new"].Hide {
t.Error("a palette from before the switch existed came back hidden")
}
}
// SmartSDR cuts a long comment mid-word — one came back from the radio as
// "…[European Russia", losing its closing bracket and its status. What the
// operator would keep must therefore survive: the spotter, the entity and the
// status, with the cluster's own words trimmed to fit.
func TestSpotCommentFitsTheRadio(t *testing.T) {
long := "FT8 5 dB LN14 CQ DX FROM A VERY TALKATIVE SKIMMER NODE INDEED"
got := plain(spotComment(long, "OH6BG-#", "European Russia", "new-band"))
if len(got) > panCommentMax {
t.Fatalf("comment is %d chars, over the %d the radio keeps: %q", len(got), panCommentMax, got)
}
for _, must := range []string{"[OH6BG-#]", "[European Russia]", "[New Band]"} {
if !strings.Contains(got, must) {
t.Errorf("%s was trimmed away — the cluster text should go first: %q", must, got)
}
}
// A short comment is left exactly as it is.
short := plain(spotComment("CQ", "K1ABC", "Spain", "new"))
if short != "CQ [K1ABC] [Spain] [New DXCC]" {
t.Fatalf("got %q", short)
}
// No room at all for the cluster's words: they go, the brackets stay.
tight := plain(spotComment("CQ DX UP 2 LISTENING", "VERYLONGSPOTTER-#", "Democratic Republic of the Congo", "new"))
if strings.Contains(tight, "LISTENING") {
t.Errorf("kept the cluster text at the expense of the tags: %q", tight)
}
}
// RBN lines are column-aligned, so a comment arrives as "FT8 5 dB LN14
// CQ". Preserved as non-breaking spaces, those runs rendered on the panadapter
// as a gap wide enough to push the rest of the comment off the screen.
func TestSpotCommentCollapsesClusterPadding(t *testing.T) {
got := plain(spotComment("FT8 -13 dB CQ", "WC2L-#", "United States", "new"))
want := "FT8 -13 dB CQ [WC2L-#] [United States] [New DXCC]"
if got != want {
t.Fatalf("got %q\nwant %q", got, want)
}
// Tabs and newlines are padding too.
if p := plain(spotComment("CQ\t\tDX\n", "K1ABC", "", "")); p != "CQ DX [K1ABC]" {
t.Fatalf("got %q", p)
}
}