Skip to content

Commit 02f20d0

Browse files
committed
test: add serial unit tests
Signed-off-by: llogen <christoph.lange@blindspot.software>
1 parent 3193951 commit 02f20d0

1 file changed

Lines changed: 369 additions & 0 deletions

File tree

pkg/module/serial/serial_test.go

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package serial
6+
7+
import (
8+
"strings"
9+
"testing"
10+
"time"
11+
)
12+
13+
// TestFilterOutputCSI verifies that filterOutputCSI correctly passes through
14+
// plain text and SGR sequences, drops all other CSI sequences, and stores
15+
// incomplete sequences in the remainder for the next read.
16+
//
17+
//nolint:funlen
18+
func TestFilterOutputCSI(t *testing.T) {
19+
tests := []struct {
20+
name string
21+
input []byte
22+
wantOutput []byte
23+
wantRemainder []byte
24+
}{
25+
{
26+
name: "empty input",
27+
input: []byte{},
28+
wantOutput: []byte{},
29+
wantRemainder: nil,
30+
},
31+
{
32+
name: "plain text without ESC",
33+
input: []byte("hello world"),
34+
wantOutput: []byte("hello world"),
35+
wantRemainder: nil,
36+
},
37+
{
38+
name: "SGR reset ESC[m preserved",
39+
input: []byte("\x1b[m"),
40+
wantOutput: []byte("\x1b[m"),
41+
wantRemainder: nil,
42+
},
43+
{
44+
name: "SGR foreground colour ESC[31m preserved",
45+
input: []byte("\x1b[31m"),
46+
wantOutput: []byte("\x1b[31m"),
47+
wantRemainder: nil,
48+
},
49+
{
50+
name: "SGR multi-parameter ESC[0;31m preserved",
51+
input: []byte("\x1b[0;31m"),
52+
wantOutput: []byte("\x1b[0;31m"),
53+
wantRemainder: nil,
54+
},
55+
{
56+
name: "DSR query ESC[6n dropped",
57+
input: []byte("\x1b[6n"),
58+
wantOutput: []byte{},
59+
wantRemainder: nil,
60+
},
61+
{
62+
name: "cursor position report ESC[10;20R dropped",
63+
input: []byte("\x1b[10;20R"),
64+
wantOutput: []byte{},
65+
wantRemainder: nil,
66+
},
67+
{
68+
name: "cursor-up ESC[2A dropped",
69+
input: []byte("\x1b[2A"),
70+
wantOutput: []byte{},
71+
wantRemainder: nil,
72+
},
73+
{
74+
name: "erase-display ESC[2J dropped",
75+
input: []byte("\x1b[2J"),
76+
wantOutput: []byte{},
77+
wantRemainder: nil,
78+
},
79+
{
80+
name: "text surrounding SGR preserved",
81+
input: []byte("hello \x1b[31mworld"),
82+
wantOutput: []byte("hello \x1b[31mworld"),
83+
wantRemainder: nil,
84+
},
85+
{
86+
name: "non-SGR CSI in middle of text dropped",
87+
input: []byte("hello \x1b[6n world"),
88+
wantOutput: []byte("hello world"),
89+
wantRemainder: nil,
90+
},
91+
{
92+
name: "mixed SGR and non-SGR: only SGR kept",
93+
input: []byte("\x1b[31mcolor\x1b[6nquery\x1b[0mreset"),
94+
wantOutput: []byte("\x1b[31mcolorquery\x1b[0mreset"),
95+
wantRemainder: nil,
96+
},
97+
{
98+
name: "multiple consecutive SGR sequences preserved",
99+
input: []byte("\x1b[31mred\x1b[0mreset"),
100+
wantOutput: []byte("\x1b[31mred\x1b[0mreset"),
101+
wantRemainder: nil,
102+
},
103+
{
104+
name: "lone ESC at end of buffer stored in remainder",
105+
input: []byte("text\x1b"),
106+
wantOutput: []byte("text"),
107+
wantRemainder: []byte{escByte},
108+
},
109+
{
110+
name: "incomplete CSI — ESC[ only — stored in remainder",
111+
input: []byte("\x1b["),
112+
wantOutput: []byte{},
113+
wantRemainder: []byte("\x1b["),
114+
},
115+
{
116+
name: "incomplete CSI with params stored in remainder",
117+
input: []byte("text\x1b[31"),
118+
wantOutput: []byte("text"),
119+
wantRemainder: []byte("\x1b[31"),
120+
},
121+
{
122+
name: "ESC not followed by bracket emitted as-is",
123+
input: []byte("\x1bO"), // SS3 — not a CSI sequence
124+
wantOutput: []byte("\x1bO"),
125+
wantRemainder: nil,
126+
},
127+
{
128+
name: "ESC not followed by bracket in middle of text",
129+
input: []byte("ab\x1bOcd"),
130+
wantOutput: []byte("ab\x1bOcd"),
131+
wantRemainder: nil,
132+
},
133+
}
134+
135+
for _, tt := range tests {
136+
t.Run(tt.name, func(t *testing.T) {
137+
var remainder []byte
138+
139+
got := filterOutputCSI(tt.input, &remainder)
140+
141+
if string(got) != string(tt.wantOutput) {
142+
t.Errorf("output = %q, want %q", got, tt.wantOutput)
143+
}
144+
145+
if string(remainder) != string(tt.wantRemainder) {
146+
t.Errorf("remainder = %q, want %q", remainder, tt.wantRemainder)
147+
}
148+
})
149+
}
150+
}
151+
152+
// TestFilterOutputCSISequenceSplitAcrossReads simulates an SGR sequence
153+
// whose bytes arrive in two separate buffer reads, verifying that the
154+
// remainder mechanism reconstitutes it correctly.
155+
func TestFilterOutputCSISequenceSplitAcrossReads(t *testing.T) {
156+
var remainder []byte
157+
158+
// Read 1: "ESC[31" — missing the final byte 'm'.
159+
out1 := filterOutputCSI([]byte("\x1b[31"), &remainder)
160+
161+
if len(out1) != 0 {
162+
t.Errorf("read 1: expected no output for incomplete sequence, got %q", out1)
163+
}
164+
165+
if string(remainder) != "\x1b[31" {
166+
t.Errorf("read 1: remainder = %q, want %q", remainder, "\x1b[31")
167+
}
168+
169+
// Read 2: prepend remainder to the new chunk (mirroring the main loop).
170+
chunk := append(remainder, 'm')
171+
out2 := filterOutputCSI(chunk, &remainder)
172+
173+
if string(out2) != "\x1b[31m" {
174+
t.Errorf("read 2: output = %q, want %q", out2, "\x1b[31m")
175+
}
176+
177+
if len(remainder) != 0 {
178+
t.Errorf("read 2: expected empty remainder, got %q", remainder)
179+
}
180+
}
181+
182+
// TestFilterOutputCSILoneESCSplitAcrossReads simulates a lone ESC at the end
183+
// of one read whose '[' and final byte arrive in the next read.
184+
func TestFilterOutputCSILoneESCSplitAcrossReads(t *testing.T) {
185+
var remainder []byte
186+
187+
// Read 1: text followed by a lone ESC at the buffer boundary.
188+
out1 := filterOutputCSI([]byte("text\x1b"), &remainder)
189+
190+
if string(out1) != "text" {
191+
t.Errorf("read 1: output = %q, want %q", out1, "text")
192+
}
193+
194+
if string(remainder) != "\x1b" {
195+
t.Errorf("read 1: remainder = %q, want %q", remainder, "\x1b")
196+
}
197+
198+
// Read 2: '[' and 'A' arrive — together with the remainder this forms the
199+
// cursor-up sequence ESC[A which must be dropped.
200+
chunk := append(remainder, []byte("[A")...)
201+
out2 := filterOutputCSI(chunk, &remainder)
202+
203+
if len(out2) != 0 {
204+
t.Errorf("read 2: cursor-up sequence should be dropped, got %q", out2)
205+
}
206+
207+
if len(remainder) != 0 {
208+
t.Errorf("read 2: expected empty remainder, got %q", remainder)
209+
}
210+
}
211+
212+
// TestEvalArgs covers argument parsing for the serial module.
213+
func TestEvalArgs(t *testing.T) {
214+
tests := []struct {
215+
name string
216+
args []string
217+
wantTimeout time.Duration
218+
wantPattern string // empty means expect should be nil
219+
wantErr bool
220+
}{
221+
{
222+
name: "no args",
223+
args: nil,
224+
},
225+
{
226+
name: "timeout flag only",
227+
args: []string{"-t", "5s"},
228+
wantTimeout: 5 * time.Second,
229+
},
230+
{
231+
name: "pattern only",
232+
args: []string{"login:"},
233+
wantPattern: "login:",
234+
},
235+
{
236+
name: "timeout and pattern",
237+
args: []string{"-t", "2m", "hello world"},
238+
wantTimeout: 2 * time.Minute,
239+
wantPattern: "hello world",
240+
},
241+
{
242+
name: "regex pattern with flags",
243+
args: []string{`(?i)Login\s*:`},
244+
wantPattern: `(?i)Login\s*:`,
245+
},
246+
{
247+
name: "invalid regex",
248+
args: []string{"[invalid"},
249+
wantErr: true,
250+
},
251+
{
252+
name: "unknown flag",
253+
args: []string{"-x"},
254+
wantErr: true,
255+
},
256+
{
257+
name: "invalid timeout value",
258+
args: []string{"-t", "not-a-duration"},
259+
wantErr: true,
260+
},
261+
}
262+
263+
for _, tt := range tests {
264+
t.Run(tt.name, func(t *testing.T) {
265+
s := &Serial{}
266+
267+
err := s.evalArgs(tt.args)
268+
269+
if (err != nil) != tt.wantErr {
270+
t.Fatalf("evalArgs() error = %v, wantErr %v", err, tt.wantErr)
271+
}
272+
273+
if tt.wantErr {
274+
return
275+
}
276+
277+
if s.timeout != tt.wantTimeout {
278+
t.Errorf("timeout = %v, want %v", s.timeout, tt.wantTimeout)
279+
}
280+
281+
if tt.wantPattern == "" {
282+
if s.expect != nil {
283+
t.Errorf("expect = %v, want nil", s.expect)
284+
}
285+
} else {
286+
if s.expect == nil {
287+
t.Fatalf("expect is nil, want pattern %q", tt.wantPattern)
288+
}
289+
290+
if s.expect.String() != tt.wantPattern {
291+
t.Errorf("expect pattern = %q, want %q", s.expect.String(), tt.wantPattern)
292+
}
293+
}
294+
})
295+
}
296+
}
297+
298+
// TestSerialInit covers Init validation and default-baud-rate assignment.
299+
func TestSerialInit(t *testing.T) {
300+
tests := []struct {
301+
name string
302+
serial Serial
303+
wantBaud int
304+
wantErr bool
305+
}{
306+
{
307+
name: "missing port returns error",
308+
serial: Serial{},
309+
wantErr: true,
310+
},
311+
{
312+
name: "zero baud is replaced with DefaultBaudRate",
313+
serial: Serial{Port: "/dev/ttyS0"},
314+
wantBaud: DefaultBaudRate,
315+
},
316+
{
317+
name: "explicit baud rate is preserved",
318+
serial: Serial{Port: "/dev/ttyS0", Baud: 9600},
319+
wantBaud: 9600,
320+
},
321+
}
322+
323+
for _, tt := range tests {
324+
t.Run(tt.name, func(t *testing.T) {
325+
err := tt.serial.Init()
326+
327+
if (err != nil) != tt.wantErr {
328+
t.Fatalf("Init() error = %v, wantErr %v", err, tt.wantErr)
329+
}
330+
331+
if !tt.wantErr && tt.serial.Baud != tt.wantBaud {
332+
t.Errorf("Baud = %d, want %d", tt.serial.Baud, tt.wantBaud)
333+
}
334+
})
335+
}
336+
}
337+
338+
// TestSerialHelp verifies that the help text includes the configured port,
339+
// baud rate, and key usage information.
340+
func TestSerialHelp(t *testing.T) {
341+
tests := []struct {
342+
name string
343+
serial Serial
344+
wantIn []string
345+
}{
346+
{
347+
name: "contains configured port and baud",
348+
serial: Serial{Port: "/dev/ttyS0", Baud: 9600},
349+
wantIn: []string{"/dev/ttyS0", "9600"},
350+
},
351+
{
352+
name: "contains timeout flag and regex mention",
353+
serial: Serial{Port: "/dev/ttyUSB0", Baud: DefaultBaudRate},
354+
wantIn: []string{"-t", "expect", "regex"},
355+
},
356+
}
357+
358+
for _, tt := range tests {
359+
t.Run(tt.name, func(t *testing.T) {
360+
help := strings.ToLower(tt.serial.Help())
361+
362+
for _, want := range tt.wantIn {
363+
if !strings.Contains(help, strings.ToLower(want)) {
364+
t.Errorf("Help() missing %q", want)
365+
}
366+
}
367+
})
368+
}
369+
}

0 commit comments

Comments
 (0)