mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
testings are going goods
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
package roverd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFirmataParserHandlesFragmentedSysex(t *testing.T) {
|
||||
parser := FirmataParser{}
|
||||
|
||||
first, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription, 1})
|
||||
if err != nil {
|
||||
t.Fatalf("first fragment: %v", err)
|
||||
}
|
||||
if len(first) != 0 {
|
||||
t.Fatalf("first fragment unexpectedly produced %d messages", len(first))
|
||||
}
|
||||
|
||||
second, err := parser.Feed([]byte{0, 2, 0, firmataEndSysex})
|
||||
if err != nil {
|
||||
t.Fatalf("second fragment: %v", err)
|
||||
}
|
||||
want := []FirmataMessage{{
|
||||
Command: firmataPeripheralFeature,
|
||||
Data: []byte{firmataPeripheralDescription, 1, 0, 2, 0},
|
||||
Sysex: true,
|
||||
}}
|
||||
if !reflect.DeepEqual(second, want) {
|
||||
t.Fatalf("messages = %#v, want %#v", second, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataParserReturnsSeveralMessagesFromOneRead(t *testing.T) {
|
||||
parser := FirmataParser{}
|
||||
messages, err := parser.Feed([]byte{
|
||||
firmataReportVersion, 2, 5,
|
||||
firmataStartSysex, firmataCapabilityReply, 0x01, 0x01, 0x7F, firmataEndSysex,
|
||||
firmataSetDigitalPin, 18, 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("feed: %v", err)
|
||||
}
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("got %d messages, want 3", len(messages))
|
||||
}
|
||||
if messages[0].Command != firmataReportVersion || messages[1].Command != firmataCapabilityReply || messages[2].Command != firmataSetDigitalPin {
|
||||
t.Fatalf("commands were not preserved in wire order: %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataParserRejectsEightBitSysexDataAndRecovers(t *testing.T) {
|
||||
parser := FirmataParser{}
|
||||
if _, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, 0x80}); err == nil {
|
||||
t.Fatal("expected invalid SysEx data to fail")
|
||||
}
|
||||
|
||||
messages, err := parser.Feed([]byte{firmataReportVersion, 2, 5})
|
||||
if err != nil {
|
||||
t.Fatalf("feed after invalid SysEx: %v", err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].Command != firmataReportVersion {
|
||||
t.Fatalf("parser did not recover: %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataSevenBitRoundTripIncludesUTF8(t *testing.T) {
|
||||
raw := []byte(`{"name":"Café lights","value":255}`)
|
||||
encoded := EncodeFirmata7Bit(raw)
|
||||
for index, value := range encoded {
|
||||
if value&0x80 != 0 {
|
||||
t.Fatalf("encoded byte %d is not seven-bit clean: 0x%02x", index, value)
|
||||
}
|
||||
}
|
||||
decoded, err := DecodeFirmata7Bit(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decoded, raw) {
|
||||
t.Fatalf("decoded %q, want %q", decoded, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeFirmataSevenBitRejectsMalformedPairs(t *testing.T) {
|
||||
for name, encoded := range map[string][]byte{
|
||||
"odd length": {1},
|
||||
"high byte": {1, 2},
|
||||
"eight bit": {0x80, 0},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := DecodeFirmata7Bit(encoded); err == nil {
|
||||
t.Fatal("expected malformed pair to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeripheralDescriptionPreservesControlOrder(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"name":"Test peripheral",
|
||||
"controls":[
|
||||
{"id":"servo","type":"slider","name":"Servo","min":0,"max":180,"output":{"type":"servo","pin":14}},
|
||||
{"id":"lights","type":"slider","name":"Lights","min":0,"max":255,"output":{"type":"pwm","pin":18}},
|
||||
{"id":"action","type":"button","name":"Action","mode":"momentary","output":{"type":"custom"}}
|
||||
]
|
||||
}`)
|
||||
var description PeripheralDescription
|
||||
if err := json.Unmarshal(raw, &description); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if err := description.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
want := []string{"servo", "lights", "action"}
|
||||
for index, id := range want {
|
||||
if description.Controls[index].ID != id {
|
||||
t.Fatalf("control %d = %q, want %q", index, description.Controls[index].ID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeripheralDescriptionRejectsInvalidDeclarations(t *testing.T) {
|
||||
minimum, maximum, pin := 10, 1, 200
|
||||
for name, description := range map[string]PeripheralDescription{
|
||||
"duplicate id": {
|
||||
Name: "device",
|
||||
Controls: []PeripheralControl{
|
||||
{ID: "same", Name: "First", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
|
||||
{ID: "same", Name: "Second", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
|
||||
},
|
||||
},
|
||||
"reversed range": {
|
||||
Name: "device",
|
||||
Controls: []PeripheralControl{{
|
||||
ID: "level", Name: "Level", Type: "slider", Minimum: &minimum, Maximum: &maximum, Output: PeripheralOutput{Type: "custom"},
|
||||
}},
|
||||
},
|
||||
"pin outside Firmata": {
|
||||
Name: "device",
|
||||
Controls: []PeripheralControl{{
|
||||
ID: "switch", Name: "Switch", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "digital", Pin: &pin},
|
||||
}},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := description.Validate(); err == nil {
|
||||
t.Fatal("expected invalid description to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFirmataCapabilities(t *testing.T) {
|
||||
pins, err := parseFirmataCapabilities([]byte{
|
||||
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
|
||||
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse capabilities: %v", err)
|
||||
}
|
||||
if len(pins) != 2 || len(pins[0]) != 2 || pins[1][1].Mode != FirmataPinModeServo {
|
||||
t.Fatalf("unexpected capabilities: %#v", pins)
|
||||
}
|
||||
|
||||
if _, err := parseFirmataCapabilities([]byte{FirmataPinModeOutput}); err == nil {
|
||||
t.Fatal("expected incomplete capability pair to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataClientWritesStandardCommands(t *testing.T) {
|
||||
connection := &recordingConnection{}
|
||||
client := NewFirmataClient(connection)
|
||||
|
||||
if err := client.SetPinMode(14, FirmataPinModeServo); err != nil {
|
||||
t.Fatalf("set pin mode: %v", err)
|
||||
}
|
||||
if err := client.ConfigureServo(14, 900, 2100); err != nil {
|
||||
t.Fatalf("configure servo: %v", err)
|
||||
}
|
||||
if err := client.ExtendedAnalog(14, 180); err != nil {
|
||||
t.Fatalf("extended analog: %v", err)
|
||||
}
|
||||
if err := client.SetDigitalPin(19, true); err != nil {
|
||||
t.Fatalf("digital write: %v", err)
|
||||
}
|
||||
|
||||
want := []byte{
|
||||
firmataSetPinMode, 14, FirmataPinModeServo,
|
||||
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
|
||||
firmataStartSysex, firmataExtendedAnalog, 14, 52, 1, firmataEndSysex,
|
||||
firmataSetDigitalPin, 19, 1,
|
||||
}
|
||||
if got := connection.Bytes(); !bytes.Equal(got, want) {
|
||||
t.Fatalf("wire bytes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataClientQueriesAndDecodesDescription(t *testing.T) {
|
||||
descriptionJSON := []byte(`{"name":"Bench device","controls":[{"id":"go","type":"button","name":"Go","mode":"momentary","output":{"type":"custom"}}]}`)
|
||||
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
|
||||
description := append([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}, EncodeFirmata7Bit(descriptionJSON)...)
|
||||
description = append(description, firmataEndSysex)
|
||||
|
||||
connection := newScriptedConnection(
|
||||
append(append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...), firmataEndSysex),
|
||||
description,
|
||||
)
|
||||
client := NewFirmataClient(connection)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client.Start(ctx)
|
||||
|
||||
firmware, err := client.QueryFirmware(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("query firmware: %v", err)
|
||||
}
|
||||
if firmware.Name != "RoverPeripheralFirmata" || firmware.Major != 1 || firmware.Minor != 0 {
|
||||
t.Fatalf("unexpected firmware: %#v", firmware)
|
||||
}
|
||||
|
||||
got, err := client.Describe(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("describe: %v", err)
|
||||
}
|
||||
if got.Name != "Bench device" || len(got.Controls) != 1 || got.Controls[0].ID != "go" {
|
||||
t.Fatalf("unexpected description: %#v", got)
|
||||
}
|
||||
|
||||
writes := connection.Bytes()
|
||||
wantWrites := []byte{
|
||||
firmataStartSysex, firmataReportFirmware, firmataEndSysex,
|
||||
firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescribe, firmataEndSysex,
|
||||
}
|
||||
if !bytes.Equal(writes, wantWrites) {
|
||||
t.Fatalf("queries = %v, want %v", writes, wantWrites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataClientKeepsReadingAfterSerialTimeoutEOF(t *testing.T) {
|
||||
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
|
||||
response := append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...)
|
||||
response = append(response, firmataEndSysex)
|
||||
|
||||
// tarm/serial returns io.EOF when its ReadTimeout expires without bytes.
|
||||
// Reproducing that behavior before the response prevents this regression
|
||||
// from being hidden by an in-memory reader that blocks indefinitely instead.
|
||||
connection := newScriptedConnection(response)
|
||||
connection.timeoutsBeforeRead = 1
|
||||
client := NewFirmataClient(connection)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client.Start(ctx)
|
||||
|
||||
firmware, err := client.QueryFirmware(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("query firmware after timeout: %v", err)
|
||||
}
|
||||
if firmware.Name != "RoverPeripheralFirmata" {
|
||||
t.Fatalf("firmware name = %q", firmware.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataClientEncodesCustomControl(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
controlID string
|
||||
value any
|
||||
wantJSON string
|
||||
}{
|
||||
"button": {controlID: "specialAction", value: true, wantJSON: `{"control":"specialAction","value":true}`},
|
||||
"text": {controlID: "displayText", value: "Café ready", wantJSON: `{"control":"displayText","value":"Café ready"}`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
connection := &recordingConnection{}
|
||||
client := NewFirmataClient(connection)
|
||||
if err := client.SendPeripheralControl(testCase.controlID, testCase.value); err != nil {
|
||||
t.Fatalf("send control: %v", err)
|
||||
}
|
||||
|
||||
wire := connection.Bytes()
|
||||
if len(wire) < 5 || wire[0] != firmataStartSysex || wire[1] != firmataPeripheralFeature || wire[2] != firmataPeripheralControl || wire[len(wire)-1] != firmataEndSysex {
|
||||
t.Fatalf("invalid control frame: %v", wire)
|
||||
}
|
||||
raw, err := DecodeFirmata7Bit(wire[3 : len(wire)-1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode control: %v", err)
|
||||
}
|
||||
if string(raw) != testCase.wantJSON {
|
||||
t.Fatalf("control JSON = %s, want %s", raw, testCase.wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataClientQueriesCapabilities(t *testing.T) {
|
||||
response := []byte{
|
||||
firmataStartSysex, firmataCapabilityReply,
|
||||
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
|
||||
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
|
||||
firmataEndSysex,
|
||||
}
|
||||
connection := newScriptedConnection(response)
|
||||
client := NewFirmataClient(connection)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client.Start(ctx)
|
||||
|
||||
pins, err := client.QueryCapabilities(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("query capabilities: %v", err)
|
||||
}
|
||||
if len(pins) != 2 || pins[0][1].Mode != FirmataPinModePWM || pins[1][1].Mode != FirmataPinModeServo {
|
||||
t.Fatalf("unexpected capabilities: %#v", pins)
|
||||
}
|
||||
if want := []byte{firmataStartSysex, firmataCapabilityQuery, firmataEndSysex}; !bytes.Equal(connection.Bytes(), want) {
|
||||
t.Fatalf("query bytes = %v, want %v", connection.Bytes(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmataClientRejectsControlTooLargeForFirmwareParser(t *testing.T) {
|
||||
connection := &recordingConnection{}
|
||||
client := NewFirmataClient(connection)
|
||||
if err := client.SendPeripheralControl("displayText", string(bytes.Repeat([]byte{'x'}, 200))); err == nil {
|
||||
t.Fatal("expected oversized control to fail")
|
||||
}
|
||||
if len(connection.Bytes()) != 0 {
|
||||
t.Fatalf("oversized control wrote bytes: %v", connection.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// recordingConnection is deliberately minimal: write-focused tests should not
|
||||
// need goroutines or a real serial device merely to inspect exact Firmata bytes.
|
||||
type recordingConnection struct {
|
||||
mu sync.Mutex
|
||||
writes bytes.Buffer
|
||||
closed bool
|
||||
writeErr error
|
||||
}
|
||||
|
||||
func (connection *recordingConnection) Read(_ []byte) (int, error) { return 0, io.EOF }
|
||||
|
||||
func (connection *recordingConnection) Write(data []byte) (int, error) {
|
||||
connection.mu.Lock()
|
||||
defer connection.mu.Unlock()
|
||||
if connection.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
if connection.writeErr != nil {
|
||||
return 0, connection.writeErr
|
||||
}
|
||||
return connection.writes.Write(data)
|
||||
}
|
||||
|
||||
func (connection *recordingConnection) Close() error {
|
||||
connection.mu.Lock()
|
||||
defer connection.mu.Unlock()
|
||||
connection.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (connection *recordingConnection) Bytes() []byte {
|
||||
connection.mu.Lock()
|
||||
defer connection.mu.Unlock()
|
||||
return append([]byte(nil), connection.writes.Bytes()...)
|
||||
}
|
||||
|
||||
func (connection *recordingConnection) Closed() bool {
|
||||
connection.mu.Lock()
|
||||
defer connection.mu.Unlock()
|
||||
return connection.closed
|
||||
}
|
||||
|
||||
func (connection *recordingConnection) SetWriteError(err error) {
|
||||
connection.mu.Lock()
|
||||
defer connection.mu.Unlock()
|
||||
connection.writeErr = err
|
||||
}
|
||||
|
||||
// scriptedConnection releases one response after each client write. This
|
||||
// mirrors request/response serial behavior and prevents a fast reader goroutine
|
||||
// from publishing all scripted answers before the matching query is sent.
|
||||
type scriptedConnection struct {
|
||||
recordingConnection
|
||||
responses chan []byte
|
||||
reads chan []byte
|
||||
timeoutsBeforeRead int
|
||||
pendingRead []byte
|
||||
}
|
||||
|
||||
func newScriptedConnection(responses ...[]byte) *scriptedConnection {
|
||||
connection := &scriptedConnection{
|
||||
responses: make(chan []byte, len(responses)),
|
||||
reads: make(chan []byte, len(responses)),
|
||||
}
|
||||
for _, response := range responses {
|
||||
connection.responses <- append([]byte(nil), response...)
|
||||
}
|
||||
return connection
|
||||
}
|
||||
|
||||
func (connection *scriptedConnection) Read(target []byte) (int, error) {
|
||||
if connection.timeoutsBeforeRead > 0 {
|
||||
connection.timeoutsBeforeRead--
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(connection.pendingRead) == 0 {
|
||||
response, ok := <-connection.reads
|
||||
if !ok {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
connection.pendingRead = response
|
||||
}
|
||||
written := copy(target, connection.pendingRead)
|
||||
connection.pendingRead = connection.pendingRead[written:]
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (connection *scriptedConnection) Write(data []byte) (int, error) {
|
||||
written, err := connection.recordingConnection.Write(data)
|
||||
if err == nil {
|
||||
select {
|
||||
case response := <-connection.responses:
|
||||
connection.reads <- response
|
||||
default:
|
||||
}
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (connection *scriptedConnection) Close() error {
|
||||
_ = connection.recordingConnection.Close()
|
||||
close(connection.reads)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user