From 273437ff042e2e6eb2f843cdf4e4bf679f23525d Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 14 Feb 2026 08:20:59 -0800 Subject: [PATCH] Add mock QLab server and tests, fix OSC typetag parsing off-by-one --- lib/qlab/mock.go | 232 ++++++++++++++++++++++++++++++ lib/qlab/osc.go | 2 +- lib/qlab/qlab_test.go | 318 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 lib/qlab/mock.go create mode 100644 lib/qlab/qlab_test.go diff --git a/lib/qlab/mock.go b/lib/qlab/mock.go new file mode 100644 index 0000000..3256929 --- /dev/null +++ b/lib/qlab/mock.go @@ -0,0 +1,232 @@ +package qlab + +import ( + "encoding/json" + "net" + "strings" + "sync" +) + +type MockServer struct { + listener net.Listener + mu sync.Mutex + conns []net.Conn + + Version string + Workspaces []Workspace + CueLists map[string][]Cue +} + +func NewMockServer() (*MockServer, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + m := &MockServer{ + listener: ln, + Version: "5.0.0", + Workspaces: []Workspace{}, + CueLists: make(map[string][]Cue), + } + go m.serve() + return m, nil +} + +func (m *MockServer) Port() int { + return m.listener.Addr().(*net.TCPAddr).Port +} + +func (m *MockServer) Close() error { + err := m.listener.Close() + m.mu.Lock() + for _, conn := range m.conns { + conn.Close() + } + m.mu.Unlock() + return err +} + +func (m *MockServer) SendUpdate(addr string) { + m.mu.Lock() + defer m.mu.Unlock() + msg := buildOSC(addr) + encoded := slipEncode(msg) + for _, conn := range m.conns { + conn.Write(encoded) + } +} + +func (m *MockServer) serve() { + for { + conn, err := m.listener.Accept() + if err != nil { + return + } + m.mu.Lock() + m.conns = append(m.conns, conn) + m.mu.Unlock() + go m.handleConn(conn) + } +} + +func (m *MockServer) handleConn(conn net.Conn) { + buf := make([]byte, 0, 65536) + tmp := make([]byte, 4096) + for { + n, err := conn.Read(tmp) + if err != nil { + return + } + buf = append(buf, tmp[:n]...) + for { + frame, rest, ok := extractSLIPFrame(buf) + if !ok { + break + } + buf = rest + addr, args, err := parseOSC(frame) + if err != nil { + continue + } + m.handleRequest(conn, addr, args) + } + } +} + +func (m *MockServer) sendReply(conn net.Conn, addr string, wsID string, status string, data any) { + jsonData, _ := json.Marshal(data) + r := Reply{ + WorkspaceID: wsID, + Address: addr, + Status: status, + Data: json.RawMessage(jsonData), + } + replyJSON, _ := json.Marshal(r) + msg := buildOSC("/reply"+addr, string(replyJSON)) + conn.Write(slipEncode(msg)) +} + +func (m *MockServer) handleRequest(conn net.Conn, addr string, args []any) { + m.mu.Lock() + defer m.mu.Unlock() + + switch { + case addr == "/version": + m.sendReply(conn, addr, "", "ok", m.Version) + return + case addr == "/workspaces": + m.sendReply(conn, addr, "", "ok", m.Workspaces) + return + } + + parts := strings.SplitN(addr, "/", 5) + if len(parts) < 4 || parts[1] != "workspace" { + return + } + wsID := parts[2] + rest := strings.Join(parts[3:], "/") + + switch { + case rest == "connect": + m.sendReply(conn, addr, wsID, "ok", "ok") + case rest == "cueLists": + cues := m.CueLists[wsID] + if cues == nil { + cues = []Cue{} + } + m.sendReply(conn, addr, wsID, "ok", cues) + case rest == "selectedCues": + m.sendReply(conn, addr, wsID, "ok", []Cue{}) + case rest == "runningCues": + m.sendReply(conn, addr, wsID, "ok", []Cue{}) + case strings.HasPrefix(rest, "cue_id/"): + sub := strings.SplitN(rest, "/", 3) + if len(sub) < 3 { + return + } + cue := m.findCueByID(wsID, sub[1]) + if cue == nil { + m.sendReply(conn, addr, wsID, "not found", nil) + return + } + if len(args) > 0 { + m.setCueProperty(cue, sub[2], args[0]) + } else { + m.sendReply(conn, addr, wsID, "ok", m.getCueProperty(cue, sub[2])) + } + case strings.HasPrefix(rest, "cue/"): + sub := strings.SplitN(rest, "/", 3) + if len(sub) < 3 { + return + } + cue := m.findCueByNumber(wsID, sub[1]) + if cue == nil { + m.sendReply(conn, addr, wsID, "not found", nil) + return + } + if len(args) > 0 { + m.setCueProperty(cue, sub[2], args[0]) + } else { + m.sendReply(conn, addr, wsID, "ok", m.getCueProperty(cue, sub[2])) + } + default: + m.sendReply(conn, addr, wsID, "ok", nil) + } +} + +func (m *MockServer) findCueByID(wsID, cueID string) *Cue { + return findCueInList(m.CueLists[wsID], cueID, func(c *Cue) string { return c.UniqueID }) +} + +func (m *MockServer) findCueByNumber(wsID, num string) *Cue { + return findCueInList(m.CueLists[wsID], num, func(c *Cue) string { return c.Number }) +} + +func findCueInList(cues []Cue, val string, key func(*Cue) string) *Cue { + for i := range cues { + if key(&cues[i]) == val { + return &cues[i] + } + if found := findCueInList(cues[i].Cues, val, key); found != nil { + return found + } + } + return nil +} + +func (m *MockServer) getCueProperty(cue *Cue, prop string) any { + switch prop { + case "uniqueID": + return cue.UniqueID + case "number": + return cue.Number + case "name": + return cue.Name + case "type": + return cue.Type + case "colorName": + return cue.ColorName + case "flagged": + return cue.Flagged + case "armed": + return cue.Armed + case "listName": + return cue.ListName + default: + return nil + } +} + +func (m *MockServer) setCueProperty(cue *Cue, prop string, val any) { + str, _ := val.(string) + switch prop { + case "name": + cue.Name = str + case "number": + cue.Number = str + case "colorName": + cue.ColorName = str + case "listName": + cue.ListName = str + } +} diff --git a/lib/qlab/osc.go b/lib/qlab/osc.go index 9d52d0f..6a7fd73 100644 --- a/lib/qlab/osc.go +++ b/lib/qlab/osc.go @@ -91,7 +91,7 @@ func parseOSC(data []byte) (addr string, args []any, err error) { ttEnd++ } typetag := string(data[pos+1 : ttEnd]) - pos = ttEnd + 1 + oscPad(ttEnd-pos) + pos = ttEnd + 1 + oscPad(ttEnd-pos+1) for _, t := range typetag { switch t { diff --git a/lib/qlab/qlab_test.go b/lib/qlab/qlab_test.go new file mode 100644 index 0000000..7770797 --- /dev/null +++ b/lib/qlab/qlab_test.go @@ -0,0 +1,318 @@ +package qlab + +import ( + "encoding/json" + "testing" + "time" +) + +func setupTest(t *testing.T) (*MockServer, *Client) { + t.Helper() + mock, err := NewMockServer() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { mock.Close() }) + + client, err := Dial("127.0.0.1", mock.Port()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { client.Close() }) + + return mock, client +} + +func TestVersion(t *testing.T) { + mock, client := setupTest(t) + mock.Version = "5.2.3" + + v, err := client.Version() + if err != nil { + t.Fatal(err) + } + if v != "5.2.3" { + t.Errorf("got %q, want %q", v, "5.2.3") + } +} + +func TestWorkspaces(t *testing.T) { + mock, client := setupTest(t) + mock.Workspaces = []Workspace{ + {DisplayName: "Show 1", UniqueID: "ws-1"}, + {DisplayName: "Show 2", UniqueID: "ws-2", HasPasscode: true}, + } + + ws, err := client.Workspaces() + if err != nil { + t.Fatal(err) + } + if len(ws) != 2 { + t.Fatalf("got %d workspaces, want 2", len(ws)) + } + if ws[0].DisplayName != "Show 1" { + t.Errorf("got %q, want %q", ws[0].DisplayName, "Show 1") + } + if ws[0].UniqueID != "ws-1" { + t.Errorf("got %q, want %q", ws[0].UniqueID, "ws-1") + } + if !ws[1].HasPasscode { + t.Error("expected HasPasscode to be true") + } +} + +func TestConnect(t *testing.T) { + _, client := setupTest(t) + + if err := client.Connect("ws-1", ""); err != nil { + t.Fatal(err) + } +} + +func TestConnectWithPasscode(t *testing.T) { + _, client := setupTest(t) + + if err := client.Connect("ws-1", "secret"); err != nil { + t.Fatal(err) + } +} + +func TestCueLists(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{ + { + UniqueID: "list-1", + Name: "Main Cue List", + Type: "Cue List", + Cues: []Cue{ + {UniqueID: "cue-1", Number: "1", Name: "Lights Up", Type: "Light"}, + {UniqueID: "cue-2", Number: "2", Name: "Sound", Type: "Audio"}, + }, + }, + } + + lists, err := client.CueLists("ws-1") + if err != nil { + t.Fatal(err) + } + if len(lists) != 1 { + t.Fatalf("got %d lists, want 1", len(lists)) + } + if lists[0].Name != "Main Cue List" { + t.Errorf("got %q, want %q", lists[0].Name, "Main Cue List") + } + if len(lists[0].Cues) != 2 { + t.Fatalf("got %d cues, want 2", len(lists[0].Cues)) + } + if lists[0].Cues[0].Name != "Lights Up" { + t.Errorf("got %q, want %q", lists[0].Cues[0].Name, "Lights Up") + } +} + +func TestCueGet(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{ + { + UniqueID: "list-1", + Type: "Cue List", + Cues: []Cue{ + {UniqueID: "cue-1", Number: "1", Name: "Lights Up"}, + }, + }, + } + + reply, err := client.CueGet("ws-1", "cue-1", "name") + if err != nil { + t.Fatal(err) + } + var name string + if err := json.Unmarshal(reply.Data, &name); err != nil { + t.Fatal(err) + } + if name != "Lights Up" { + t.Errorf("got %q, want %q", name, "Lights Up") + } +} + +func TestCueGetByNumber(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{ + { + UniqueID: "list-1", + Type: "Cue List", + Cues: []Cue{ + {UniqueID: "cue-1", Number: "1", Name: "Lights Up"}, + {UniqueID: "cue-2", Number: "2", Name: "Sound"}, + }, + }, + } + + reply, err := client.CueGetByNumber("ws-1", "2", "name") + if err != nil { + t.Fatal(err) + } + var name string + if err := json.Unmarshal(reply.Data, &name); err != nil { + t.Fatal(err) + } + if name != "Sound" { + t.Errorf("got %q, want %q", name, "Sound") + } +} + +func TestCueGetNotFound(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{} + + _, err := client.CueGet("ws-1", "nonexistent", "name") + if err == nil { + t.Fatal("expected error for nonexistent cue") + } +} + +func TestCueSet(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{ + { + UniqueID: "list-1", + Type: "Cue List", + Cues: []Cue{ + {UniqueID: "cue-1", Number: "1", Name: "Lights Up"}, + }, + }, + } + + if err := client.CueSet("ws-1", "cue-1", "name", "Blackout"); err != nil { + t.Fatal(err) + } + + reply, err := client.CueGet("ws-1", "cue-1", "name") + if err != nil { + t.Fatal(err) + } + var name string + json.Unmarshal(reply.Data, &name) + if name != "Blackout" { + t.Errorf("got %q, want %q", name, "Blackout") + } +} + +func TestCueSetByNumber(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{ + { + UniqueID: "list-1", + Type: "Cue List", + Cues: []Cue{ + {UniqueID: "cue-1", Number: "1", Name: "Lights Up"}, + }, + }, + } + + if err := client.CueSetByNumber("ws-1", "1", "name", "Blackout"); err != nil { + t.Fatal(err) + } + + reply, err := client.CueGetByNumber("ws-1", "1", "name") + if err != nil { + t.Fatal(err) + } + var name string + json.Unmarshal(reply.Data, &name) + if name != "Blackout" { + t.Errorf("got %q, want %q", name, "Blackout") + } +} + +func TestUpdates(t *testing.T) { + mock, client := setupTest(t) + + // Ensure connection is fully established + _, err := client.Version() + if err != nil { + t.Fatal(err) + } + + mock.SendUpdate("/update/workspace/ws-1/cue_id/cue-1") + + select { + case u := <-client.Updates(): + if u.Address != "/update/workspace/ws-1/cue_id/cue-1" { + t.Errorf("got %q, want %q", u.Address, "/update/workspace/ws-1/cue_id/cue-1") + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for update") + } +} + +func TestTransport(t *testing.T) { + _, client := setupTest(t) + + for _, fn := range []func(string) error{ + client.Go, + client.Stop, + client.Pause, + client.Resume, + client.Panic, + client.Reset, + } { + if err := fn("ws-1"); err != nil { + t.Fatal(err) + } + } +} + +func TestSelectedCues(t *testing.T) { + _, client := setupTest(t) + + cues, err := client.SelectedCues("ws-1") + if err != nil { + t.Fatal(err) + } + if len(cues) != 0 { + t.Errorf("got %d cues, want 0", len(cues)) + } +} + +func TestRunningCues(t *testing.T) { + _, client := setupTest(t) + + cues, err := client.RunningCues("ws-1") + if err != nil { + t.Fatal(err) + } + if len(cues) != 0 { + t.Errorf("got %d cues, want 0", len(cues)) + } +} + +func TestNestedCueGet(t *testing.T) { + mock, client := setupTest(t) + mock.CueLists["ws-1"] = []Cue{ + { + UniqueID: "list-1", + Type: "Cue List", + Cues: []Cue{ + { + UniqueID: "group-1", + Number: "10", + Name: "Group", + Type: "Group", + Cues: []Cue{ + {UniqueID: "nested-1", Number: "10.1", Name: "Nested Cue"}, + }, + }, + }, + }, + } + + reply, err := client.CueGet("ws-1", "nested-1", "name") + if err != nil { + t.Fatal(err) + } + var name string + json.Unmarshal(reply.Data, &name) + if name != "Nested Cue" { + t.Errorf("got %q, want %q", name, "Nested Cue") + } +}