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.
168 lines
6.6 KiB
Go
168 lines
6.6 KiB
Go
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)
|
||
}
|
||
}
|