Files
OpsLog/internal/steppir/steppir_test.go
T
2026-07-26 16:57:19 +02:00

228 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package steppir
import (
"bytes"
"encoding/binary"
"errors"
"sync"
"testing"
"time"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
// antenna, far worse than a compile error.
func TestBuildSetLayout(t *testing.T) {
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
pkt := buildSet(14_074_000, DirNormal, '1')
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
if len(pkt) != 11 {
t.Fatalf("packet is %d bytes, want 11", len(pkt))
}
for i := range want {
if pkt[i] != want[i] {
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
}
}
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
t.Fatalf("freq round-trip = %d, want 14074000", got)
}
}
func TestBuildSetDirectionAndCommand(t *testing.T) {
cases := []struct {
dir int
cmd byte
wantDir byte
wantCmd byte
}{
{DirNormal, '1', 0x00, '1'},
{Dir180, '1', 0x40, '1'},
{DirBi, '1', 0x80, '1'},
{DirNormal, 'S', 0x00, 'S'}, // retract / home
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
}
for _, c := range cases {
pkt := buildSet(21_000_000, c.dir, c.cmd)
if pkt[7] != c.wantDir {
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
}
if pkt[8] != c.wantCmd {
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
}
}
}
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
// version "08". Using the actual device output (rather than hand-built bytes)
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
func TestParseStatusRealFrame(t *testing.T) {
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 50313 {
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
}
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
}
if st.MotorsMoving != 0 {
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
}
}
// parseStatus decodes what the controller sends back — the inverse of buildSet's
// frequency field, plus the direction nibble and the motors-busy byte.
func TestParseStatus(t *testing.T) {
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14074 {
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
}
// Motors busy: a bit beyond 0x01 is set.
frame[6] = 0x07
st, _ = parseStatus(frame)
if st.MotorsMoving == 0 {
t.Error("active-motors 0x07 should read as moving")
}
// 0xFF is "command received", not motion.
frame[6] = 0xFF
st, _ = parseStatus(frame)
if st.MotorsMoving != 0 {
t.Error("active-motors 0xFF (command received) must not read as moving")
}
}
// fakeConn stands in for the controller link. rx holds bytes the "controller"
// has already sent (what the client will read), tx collects what the client
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
//
// Reads never block: an empty rx returns (0, nil), which is exactly how
// go.bug.st/serial reports a read timeout, so drain() sees the same
// nothing-left signal it gets from real hardware.
type fakeConn struct {
mu sync.Mutex
rx bytes.Buffer
tx bytes.Buffer
reply []byte
}
func (f *fakeConn) Read(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.rx.Len() == 0 {
return 0, nil
}
return f.rx.Read(p)
}
func (f *fakeConn) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.tx.Write(p)
if bytes.Contains(p, []byte("?A")) {
f.rx.Write(f.reply)
}
return len(p), nil
}
func (f *fakeConn) Close() error { return nil }
var (
// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
// F4BPO's friend's log long after the rig had left the band.
frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
// 14.250 MHz, 180° — what the controller actually reports right now.
frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
)
// The controller pushes status frames unsolicited, so they pile up between polls.
// Reading one frame per poll then returns state from minutes ago — which is how a
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
fc := &fakeConn{reply: frame20m180}
fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
fc.rx.Write(frame6mNormal)
c := &Client{conn: fc}
st, err := c.queryStatus()
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14250 {
t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
}
// A reply we land on mid-frame must be rejected, not decoded into a bogus
// frequency and direction the follow loop would then act on — and it must not
// look like a dead link either (errBadFrame keeps the connection).
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
c := &Client{conn: &fakeConn{reply: shifted}}
if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
t.Fatalf("err = %v, want errBadFrame", err)
}
}
// The direction the operator just commanded is shown until the controller
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
// to Normal while the elements were still swapping over to 180°.
func TestApplyPendingDirHold(t *testing.T) {
c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}
// Controller still reports the old pattern: keep showing what was commanded.
st := &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
}
if !c.pendingDirSet {
t.Fatal("hold released before the controller confirmed")
}
// Still holding well past the old 4 s window — a SteppIR takes longer than
// that to report a pattern change.
c.pendingDirAt = time.Now().Add(-10 * time.Second)
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
}
// Controller confirms: release the hold and trust its reports again.
st = &Status{Direction: Dir180}
c.applyPendingDir(st)
if c.pendingDirSet {
t.Fatal("hold should be released once the controller reports the commanded direction")
}
// A command the controller never acted on must not lie forever.
c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != DirNormal || c.pendingDirSet {
t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
}
}