1package layout
2
3import (
4 "fmt"
5 "log/slog"
6 "testing"
7
8 "github.com/bnema/neferbar/internal/glyph"
9 "github.com/bnema/neferbar/internal/module"
10 "github.com/bnema/neferbar/internal/racecheck"
11)
12
13var testPalette = [16][3]uint8{{1, 1, 1}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, {0, 0, 255}}
14
15func newTestLayout(cols int) (*Layout, []*module.Module) {
16 wake := make(chan struct{}, 1)
17 log := slog.New(slog.DiscardHandler)
18 mods := []*module.Module{
19 module.New("l", module.Left, "true", wake, log),
20 module.New("c", module.Center, "true", wake, log),
21 module.New("r", module.Right, "true", wake, log),
22 }
23 return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, testPalette, mods), mods
24}
25
26func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }
27
28func rowText(l *Layout) string {
29 row := l.Compose()
30 rs := make([]rune, len(row))
31 for i, c := range row {
32 rs[i] = c.Rune
33 }
34 return string(rs)
35}
36
37func TestZones(t *testing.T) {
38 l, m := newTestLayout(20)
39 publish(m[0], "LEFT")
40 publish(m[1], "mid")
41 publish(m[2], "RIGHT")
42 if !l.Update() {
43 t.Fatal("Update reported no change")
44 }
45 got := rowText(l)
46 want := "LEFT mid RIGHT"
47 if len(want) < 20 {
48 want += " "[:20-len(want)]
49 }
50 if got[:4] != "LEFT" || got[15:] != "RIGHT" {
51 t.Fatalf("row = %q", got)
52 }
53 if i := indexOf(got, "mid"); i < 4 || i > 15 {
54 t.Fatalf("center misplaced in %q", got)
55 }
56}
57
58func indexOf(s, sub string) int {
59 for i := 0; i+len(sub) <= len(s); i++ {
60 if s[i:i+len(sub)] == sub {
61 return i
62 }
63 }
64 return -1
65}
66
67func TestOverlapCutsCenterFirst(t *testing.T) {
68 l, m := newTestLayout(12)
69 publish(m[0], "AAAAA")
70 publish(m[1], "CCCCCCCC")
71 publish(m[2], "ZZZZZ")
72 l.Update()
73 got := rowText(l)
74 if got[:5] != "AAAAA" || got[7:] != "ZZZZZ" {
75 t.Fatalf("sides must survive: %q", got)
76 }
77}
78
79func TestColorsAndClip(t *testing.T) {
80 l, m := newTestLayout(6)
81 publish(m[0], "\x1b[31mR\x1b[0mx\x1b[1;38;2;1;2;3mverylongtext")
82 l.Update()
83 row := l.Compose()
84 if row[0].Rune != 'R' || row[0].FG == l.fg {
85 t.Fatalf("red cell: %+v", row[0])
86 }
87 if row[1].FG != l.fg {
88 t.Fatalf("reset should restore default fg: %+v", row[1])
89 }
90 if row[2].Style&glyph.Bold == 0 || row[2].FG != [3]uint8{1, 2, 3} {
91 t.Fatalf("bold truecolor cell: %+v", row[2])
92 }
93}
94
95func TestNewFrameReplacesOld(t *testing.T) {
96 l, m := newTestLayout(10)
97 publish(m[0], "long text")
98 l.Update()
99 publish(m[0], "ab")
100 l.Update()
101 if got := rowText(l); got[:2] != "ab" || got[2] != ' ' {
102 t.Fatalf("old text lingers: %q", got)
103 }
104}
105
106// TestUpdateComposeAllocs: parsing a frame and composing the row must not
107// allocate once the buffers are warm.
108func TestUpdateComposeAllocs(t *testing.T) {
109 if racecheck.Enabled {
110 t.Skip("the race detector allocates")
111 }
112 l, m := newTestLayout(100)
113 frames := make([]string, 8)
114 for i := range frames {
115 frames[i] = fmt.Sprintf("\x1b[48;2;%d;10;10m \x1b[0m clock %02d \x1b[1;32mok\x1b[0m \uf017", i*30, i)
116 }
117 n := 0
118 step := func() {
119 publish(m[2], frames[n%len(frames)])
120 n++
121 l.Update()
122 l.Compose()
123 }
124 for i := 0; i < 20; i++ {
125 step()
126 }
127 if got := testing.AllocsPerRun(500, step); got > 0 {
128 t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)
129 }
130}
131
132func TestTextStyles(t *testing.T) {
133 l, m := newTestLayout(10)
134 publish(m[0], "\x1b[3mi\x1b[0m\x1b[4mu\x1b[0m\x1b[9ms\x1b[0m\x1b[1;3;4;9mA\x1b[0mn")
135 l.Update()
136 row := l.Compose()
137 want := []glyph.Style{glyph.Italic, glyph.Underline, glyph.Strike, glyph.Bold | glyph.Italic | glyph.Underline | glyph.Strike, 0}
138 for i, w := range want {
139 if row[i].Style != w {
140 t.Errorf("cell %d (%q): style %04b, want %04b", i, row[i].Rune, row[i].Style, w)
141 }
142 }
143}
144
145func TestAnsiColorsComeFromThePalette(t *testing.T) {
146 l, m := newTestLayout(6)
147 publish(m[0], "\x1b[31mr\x1b[44mb\x1b[0m\x1b[38;5;2mg")
148 l.Update()
149 row := l.Compose()
150 if row[0].FG != testPalette[1] {
151 t.Errorf("\\e[31m = %v, want palette[1] %v", row[0].FG, testPalette[1])
152 }
153 if row[1].BG != testPalette[4] {
154 t.Errorf("\\e[44m background = %v, want palette[4] %v", row[1].BG, testPalette[4])
155 }
156 if row[2].FG != testPalette[2] {
157 t.Errorf("\\e[38;5;2m = %v, want palette[2] %v", row[2].FG, testPalette[2])
158 }
159 pal := testPalette
160 pal[1] = [3]uint8{9, 9, 9}
161 l.SetColors(l.fg, l.bg, pal)
162 if got := l.Compose()[0].FG; got != pal[1] {
163 t.Errorf("after SetColors, \\e[31m = %v, want %v", got, pal[1])
164 }
165}
166
167// A frame that stops inside an escape sequence must not blank the frames after it.
168func TestBrokenEscapeDoesNotSwallowNextFrames(t *testing.T) {
169 for name, broken := range map[string]string{
170 "osc": "\x1b]0;title",
171 "csi": "\x1b[38;2;1",
172 "dcs": "\x1bP1;2",
173 "apc": "\x1b_Gf=24",
174 "esc": "\x1b",
175 "utf8": "ab\xe2\x82",
176 } {
177 l, m := newTestLayout(10)
178 publish(m[0], broken)
179 l.Update()
180 publish(m[0], "hello")
181 l.Update()
182 if got := rowText(l); got[:5] != "hello" {
183 t.Errorf("%s: row after the next frame = %q, want hello", name, got)
184 }
185 }
186}
187
188// The center zone sits on the middle of the bar, not on the middle of the
189// space left between a wide left zone and a narrow right one.
190func TestCenterIsOnTheMiddleOfTheBar(t *testing.T) {
191 l, m := newTestLayout(40)
192 publish(m[0], "LLLLLLLLLL") // 10 cells on the left
193 publish(m[1], "cccc") // 4 in the middle
194 publish(m[2], "RRRR") // 4 on the right
195 l.Update()
196 got := rowText(l)
197 if i := indexOf(got, "cccc"); i != 18 {
198 t.Fatalf("center starts at cell %d, want 18 (the bar is 40 cells wide): %q", i, got)
199 }
200}
201
202// When the middle of the bar is taken, the center moves just enough to fit.
203func TestCenterMovesOnlyAsFarAsNeeded(t *testing.T) {
204 l, m := newTestLayout(40)
205 publish(m[0], "LLLLLLLLLLLLLLLLLLLL") // 20 cells: reaches past the ideal start of 18
206 publish(m[1], "cccc")
207 publish(m[2], "RRRR")
208 l.Update()
209 got := rowText(l)
210 if i := indexOf(got, "cccc"); i != 20 {
211 t.Fatalf("center starts at cell %d, want 20 (right after the left zone): %q", i, got)
212 }
213 // And the other way round: a wide right zone pushes it left.
214 l, m = newTestLayout(40)
215 publish(m[0], "LL")
216 publish(m[1], "cccc")
217 publish(m[2], "RRRRRRRRRRRRRRRRRRRR") // 20 cells: the center must end by cell 20
218 l.Update()
219 got = rowText(l)
220 if i := indexOf(got, "cccc"); i != 16 {
221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)
222 }
223}
224
225func spanOf(spans []Span, m *module.Module) (Span, bool) {
226 for _, s := range spans {
227 if s.M == m {
228 return s, true
229 }
230 }
231 return Span{}, false
232}
233
234func TestSpansPerZone(t *testing.T) {
235 l, m := newTestLayout(20)
236 publish(m[0], "LEFT")
237 publish(m[1], "mid")
238 publish(m[2], "RIGHT")
239 l.Update()
240 row := rowText(l)
241 spans := l.Spans(nil)
242 if len(spans) != 3 {
243 t.Fatalf("spans = %+v, want 3", spans)
244 }
245 for i, want := range []string{"LEFT", "mid", "RIGHT"} {
246 s, ok := spanOf(spans, m[i])
247 if !ok || s.Width != len(want) || row[s.Start:s.Start+s.Width] != want {
248 t.Errorf("span %d = %+v (ok %v) in %q, want %q", i, s, ok, row, want)
249 }
250 }
251 // Spans come in module order.
252 if spans[0].M != m[0] || spans[1].M != m[1] || spans[2].M != m[2] {
253 t.Errorf("spans out of order: %+v", spans)
254 }
255}
256
257func TestSpansTwoModulesInOneZone(t *testing.T) {
258 wake := make(chan struct{}, 1)
259 log := slog.New(slog.DiscardHandler)
260 a := module.New("a", module.Left, "true", wake, log)
261 b := module.New("b", module.Left, "true", wake, log)
262 l := New(10, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, testPalette, []*module.Module{a, b})
263 publish(a, "AA")
264 publish(b, "BBB")
265 l.Update()
266 l.Compose()
267 sa, _ := spanOf(l.Spans(nil), a)
268 sb, _ := spanOf(l.Spans(nil), b)
269 if sa != (Span{a, 0, 2}) || sb != (Span{b, 2, 3}) {
270 t.Fatalf("spans = %+v %+v", sa, sb)
271 }
272 for col, want := range map[int]struct {
273 m *module.Module
274 off int
275 ok bool
276 }{0: {a, 0, true}, 1: {a, 1, true}, 2: {b, 0, true}, 4: {b, 2, true}, 5: {nil, 0, false}, -1: {nil, 0, false}, 99: {nil, 0, false}} {
277 gm, off, ok := l.At(col)
278 if gm != want.m || off != want.off || ok != want.ok {
279 t.Errorf("At(%d) = %v, %d, %v; want %v, %d, %v", col, gm, off, ok, want.m, want.off, want.ok)
280 }
281 }
282}
283
284func TestSpansAreClipped(t *testing.T) {
285 // The right zone wins over the left one: left is cut to the room left.
286 l, m := newTestLayout(8)
287 publish(m[0], "AAAAA")
288 publish(m[2], "ZZZZZ")
289 l.Update()
290 l.Compose()
291 spans := l.Spans(nil)
292 if s, ok := spanOf(spans, m[0]); !ok || s != (Span{m[0], 0, 3}) {
293 t.Errorf("left span = %+v, want {0,3}", s)
294 }
295 if s, ok := spanOf(spans, m[2]); !ok || s != (Span{m[2], 3, 5}) {
296 t.Errorf("right span = %+v, want {3,5}", s)
297 }
298 if _, _, ok := l.At(3); !ok {
299 t.Error("column 3 belongs to the right module")
300 }
301 if gm, off, _ := l.At(3); gm != m[2] || off != 0 {
302 t.Errorf("At(3) = %v, %d", gm, off)
303 }
304
305 // The center is cut first, and may vanish entirely.
306 l, m = newTestLayout(12)
307 publish(m[0], "AAAAA")
308 publish(m[1], "CCCCCCCC")
309 publish(m[2], "ZZZZZ")
310 l.Update()
311 l.Compose()
312 if s, ok := spanOf(l.Spans(nil), m[1]); !ok || s != (Span{m[1], 5, 2}) {
313 t.Errorf("center span = %+v (ok %v), want {5,2}", s, ok)
314 }
315 l, m = newTestLayout(10)
316 publish(m[0], "AAAAA")
317 publish(m[1], "CC")
318 publish(m[2], "ZZZZZ")
319 l.Update()
320 l.Compose()
321 if _, ok := spanOf(l.Spans(nil), m[1]); ok {
322 t.Error("a fully clipped module must have no span")
323 }
324 if _, _, ok := l.At(5); !ok {
325 // column 5 is the first of the right zone
326 t.Error("At(5) must find the right module")
327 }
328}
329
330func TestSpansEmptyModuleAndRecompose(t *testing.T) {
331 l, m := newTestLayout(10)
332 publish(m[0], "")
333 publish(m[2], "R")
334 l.Update()
335 l.Compose()
336 if _, ok := spanOf(l.Spans(nil), m[0]); ok {
337 t.Error("an empty module has no span")
338 }
339 if len(l.Spans(nil)) != 1 {
340 t.Errorf("spans = %+v", l.Spans(nil))
341 }
342 // A module that empties out loses its span at the next Compose.
343 publish(m[2], "")
344 l.Update()
345 l.Compose()
346 if got := l.Spans(nil); len(got) != 0 {
347 t.Errorf("spans after emptying = %+v", got)
348 }
349 if _, _, ok := l.At(9); ok {
350 t.Error("At must not find an emptied module")
351 }
352}
353
354func TestComposeAndSpansAllocs(t *testing.T) {
355 if racecheck.Enabled {
356 t.Skip("the race detector allocates")
357 }
358 l, m := newTestLayout(40)
359 publish(m[0], "left")
360 publish(m[1], "center")
361 publish(m[2], "right")
362 l.Update()
363 if got := testing.AllocsPerRun(100, func() { l.Compose() }); got > 0 {
364 t.Errorf("Compose allocates %.1f objects; want 0", got)
365 }
366 buf := make([]Span, 0, 8)
367 if got := testing.AllocsPerRun(100, func() { buf = l.Spans(buf[:0]) }); got > 0 {
368 t.Errorf("Spans allocates %.1f objects; want 0", got)
369 }
370 if got := testing.AllocsPerRun(100, func() { l.At(3) }); got > 0 {
371 t.Errorf("At allocates %.1f objects; want 0", got)
372 }
373}