Files
OpsLog/amps_linked_test.go
T
rouggy dc898ce2af fix(amps): combined amplifiers are commanded together, power level included
Two faults in the combiner coupling, both reported from the operating position.

THE POWER LEVEL WAS NEVER COUPLED. ON, OFF and OPERATE fanned out to the group;
L/M/H did not — it was simply the command nobody had linked. Two combined
amplifiers left at different power levels feed the combiner unevenly, which is
the thing the coupling exists to prevent.

THE COMMANDS DID NOT LEAVE TOGETHER. The second amplifier was commanded only
once the first had answered, and an SPE answers over its own link in its own
time. The combiner heard power appear on one input before the other and beeped
about it, on every OFF and every ON.

Each target now gets a goroutine, all parked on one channel until every one is
ready; closing it releases them together. That is the difference between "start
one, then start the other" and "both leave at once" — they have separate clients
and separate connections, so nothing downstream re-serialises them.

It matters most on the power level, which is not one command at all: an SPE has
no "set level", so the driver taps the POWER key and waits for the amp to report
the new one before tapping again — up to three taps, up to two seconds each. One
after the other, the pair would sit at different levels for six seconds.

A single amplifier still runs inline: no goroutine, no barrier, nothing new to
go wrong for the operators who have one amp. The one that was clicked stays
first, because its failure is the one worth reporting.
2026-08-17 10:42:19 +02:00

139 lines
4.4 KiB
Go

package main
import (
"errors"
"sync"
"testing"
"time"
)
// The coupling is a SET, not a global switch.
//
// A station can run a combiner pair AND a third amplifier that has nothing to
// do with it — two SPE on the combiner, a PowerGenius on another antenna. A
// global flag would send that third one into OPERATE alongside them.
func TestAmpTargetsFollowTheGroup(t *testing.T) {
a := &App{}
a.ampInsts = map[string]*ampInst{"spe1": {}, "spe2": {}, "pgxl": {}}
group := []string{"spe1", "spe2"}
// A member commands the whole group, itself first.
got := a.ampTargets("spe2", group)
if len(got) != 2 || got[0] != "spe2" || got[1] != "spe1" {
t.Errorf("member = %v, want [spe2 spe1] — the clicked one first", got)
}
// The amplifier OUTSIDE the group keeps its buttons to itself.
if got := a.ampTargets("pgxl", group); len(got) != 1 || got[0] != "pgxl" {
t.Errorf("outsider = %v, want just [pgxl]", got)
}
// No group at all: everyone is on their own.
if got := a.ampTargets("spe1", nil); len(got) != 1 || got[0] != "spe1" {
t.Errorf("no group = %v, want just [spe1]", got)
}
// A group remembering an amplifier that is gone must not carry it: it would
// fail the command for a member that no longer exists.
if got := a.ampTargets("spe1", []string{"spe1", "deleted"}); len(got) != 1 || got[0] != "spe1" {
t.Errorf("stale member = %v, want it dropped", got)
}
}
// One amplifier coupled to itself is not a group — storing it would make every
// command fan out to a single member for ever, which is just noise.
func TestLinkedAmpsNeedsTwo(t *testing.T) {
for _, tc := range []struct {
in []string
want int
}{
{[]string{"a", "b"}, 2},
{[]string{"a"}, 0},
{[]string{" ", "a"}, 0}, // blanks are not members
{nil, 0},
} {
clean := make([]string, 0, len(tc.in))
for _, id := range tc.in {
if id != "" && id != " " {
clean = append(clean, id)
}
}
if len(clean) < 2 {
clean = nil
}
if len(clean) != tc.want {
t.Errorf("SetLinkedAmps(%v) would keep %d, want %d", tc.in, len(clean), tc.want)
}
}
}
// Two combined amplifiers must be commanded AT THE SAME TIME, not one after the
// other.
//
// Sequentially, the second was commanded only once the first had answered — and
// an SPE answers over its own link, in its own time. The combiner heard power
// appear on one input before the other and beeped about it, on every OFF and
// every ON. This is what an operator hears, so it is worth a test that would
// hear it too.
func TestLinkedAmpCommandsLeaveTogether(t *testing.T) {
a := &App{}
const slow = 150 * time.Millisecond
var mu sync.Mutex
starts := map[string]time.Time{}
err := a.ampFanOut([]string{"one", "two"}, func(id string) error {
mu.Lock()
starts[id] = time.Now()
mu.Unlock()
time.Sleep(slow) // an amplifier taking its time to answer
return nil
})
if err != nil {
t.Fatalf("fan-out: %v", err)
}
if len(starts) != 2 {
t.Fatalf("%d amplifiers were commanded, want both", len(starts))
}
// Both goroutines wait on one channel and are released by closing it, so the
// gap is scheduling noise. Sequential execution would put a full command
// between them.
gap := starts["one"].Sub(starts["two"])
if gap < 0 {
gap = -gap
}
if gap > slow/3 {
t.Errorf("the two amplifiers were commanded %v apart — the combiner hears that as one input arriving late", gap)
}
}
// The amplifier the operator clicked comes first, and its failure is the one
// reported: "the amp I pressed did not respond" beats the same message about
// its silent partner.
func TestLinkedAmpErrorNamesTheOneClicked(t *testing.T) {
a := &App{}
clicked := errors.New("the one clicked")
other := errors.New("the other one")
err := a.ampFanOut([]string{"clicked", "other"}, func(id string) error {
if id == "clicked" {
return clicked
}
return other
})
if !errors.Is(err, clicked) {
t.Errorf("fan-out reported %v, want the amplifier the operator pressed", err)
}
}
// One amplifier is the ordinary case and must not change: run inline, no
// goroutine, no barrier, and the error straight back.
func TestSingleAmpRunsInline(t *testing.T) {
a := &App{}
boom := errors.New("not running")
if err := a.ampFanOut([]string{"solo"}, func(string) error { return boom }); !errors.Is(err, boom) {
t.Errorf("a single amplifier reported %v, want the error itself", err)
}
if err := a.ampFanOut(nil, func(string) error { return boom }); err != nil {
t.Errorf("an empty group reported %v, want nothing to do", err)
}
}