fix(serial): list a COM port once, in the order a human reads it

An operator's keyer dropdown showed COM1 and COM3 twice, in the order
COM1 COM1 COM3 COM8 COM9 COM4 COM3, with "COM3COM3" as the selected value —
two entries sharing one value both counted as selected.

Not our bug to start with, but ours to absorb. Windows enumerates serial
ports from HARDWARE\DEVICEMAP\SERIALCOMM, which maps a DEVICE PATH to a COM
name, so the same name legitimately appears twice when two devices claim it:
a driver uninstalled without cleaning up after itself, or virtual-port
software. go.bug.st/serial returns one entry per device, faithfully.

Deduplicated and naturally sorted in the one binding every port dropdown in
the app calls, so CAT, rotators, amplifiers and the keyer all get it. COM4
now sits between COM3 and COM8 rather than after COM9, which is how a port
gets overlooked on a machine with a dozen.

A dropped duplicate is logged rather than swallowed: two devices holding one
COM name is also a good reason for one of them to report that port busy, and
that is not something an operator can work out from a list.
This commit is contained in:
2026-08-14 15:25:45 +02:00
parent a9e3a159d9
commit e67f57fee7
3 changed files with 116 additions and 3 deletions
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"strings"
"testing"
)
// The exact list an operator's machine produced: COM1 and COM3 each claimed by
// two devices in the Windows port map, and no order to speak of. The dropdown
// showed every duplicate, and rendered its own value as "COM3COM3" because two
// entries with the same value both counted as selected.
func TestTidySerialPortsDeduplicatesAndOrders(t *testing.T) {
got := tidySerialPorts([]string{"COM1", "COM1", "COM3", "COM8", "COM9", "COM4", "COM3"})
want := []string{"COM1", "COM3", "COM4", "COM8", "COM9"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("got %v, want %v", got, want)
}
}
// COM10 after COM9, not between COM1 and COM2 — lexical order is how a port
// gets overlooked on a machine with a dozen of them.
func TestTidySerialPortsSortsNaturally(t *testing.T) {
got := tidySerialPorts([]string{"COM10", "COM2", "COM1", "COM20", "COM3"})
want := []string{"COM1", "COM2", "COM3", "COM10", "COM20"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("got %v, want %v", got, want)
}
}
// Not every port is a COMn: a device path or a Unix name must survive, after
// the numbered ones, in its own order.
func TestTidySerialPortsKeepsOtherNames(t *testing.T) {
got := tidySerialPorts([]string{"/dev/ttyUSB1", "COM3", "/dev/ttyUSB0", "COM1", "", " "})
want := []string{"COM1", "COM3", "/dev/ttyUSB0", "/dev/ttyUSB1"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("got %v, want %v", got, want)
}
}
// Case is not identity on Windows: "com3" and "COM3" are one port, and letting
// both through would put the same duplicate back in the list.
func TestTidySerialPortsIgnoresCase(t *testing.T) {
if got := tidySerialPorts([]string{"COM3", "com3"}); len(got) != 1 || got[0] != "COM3" {
t.Errorf("got %v, want [COM3]", got)
}
}