chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+121
View File
@@ -1,8 +1,12 @@
package steppir
import (
"bytes"
"encoding/binary"
"errors"
"sync"
"testing"
"time"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
@@ -104,3 +108,120 @@ func TestParseStatus(t *testing.T) {
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)
}
}