1package ui
2
3import (
4 "context"
5 "errors"
6 "math"
7 "net"
8 "path/filepath"
9 "strconv"
10 "testing"
11 "time"
12
13 "github.com/bnema/wlturbo"
14 "github.com/bnema/wlturbo/wl"
15
16 "github.com/bnema/nefergui/internal/css"
17 "github.com/bnema/nefergui/internal/keyboard"
18 "github.com/bnema/nefergui/internal/layout"
19 "github.com/bnema/nefergui/internal/platform/wayland"
20 "github.com/bnema/nefergui/internal/text"
21)
22
23func TestLayerConfigSurfaceOptions(t *testing.T) {
24 c := LayerConfig{Output: "DP-1", Namespace: "ns", Level: LayerOverlay, Anchors: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom,
25 Keyboard: KeyboardExclusive, ExclusiveZone: -1, Margin: [4]int32{1, 2, 3, 4}, InputRects: []Rect{{1, 2, 3, 4}}}
26 got, err := c.surfaceOptions()
27 if err != nil {
28 t.Fatal(err)
29 }
30 l := got.Layer
31 if l == nil || l.Output != "DP-1" || l.Namespace != "ns" || l.Layer != wayland.LayerOverlay || l.Anchor != 15 ||
32 l.Keyboard != wayland.KeyboardExclusive || l.ExclusiveZone != -1 || l.Margin != [4]int32{1, 2, 3, 4} {
33 t.Fatalf("layer options: %+v", l)
34 }
35 if len(got.InputRects) != 1 || got.InputRects[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
36 t.Fatalf("input rects: %+v", got.InputRects)
37 }
38 for level, want := range map[LayerLevel]uint32{LayerBackground: 0, LayerBottom: 1, LayerTop: 2, LayerOverlay: 3} {
39 o, err := LayerConfig{Level: level}.surfaceOptions()
40 if err != nil || o.Layer.Layer != want {
41 t.Fatalf("level %d: %+v %v", level, o.Layer, err)
42 }
43 }
44 // nil InputRects keeps the full surface; empty non-nil means click-through.
45 if o, _ := (LayerConfig{Level: LayerTop}).surfaceOptions(); o.InputRects != nil {
46 t.Fatal("nil rects became non-nil")
47 }
48 if o, _ := (LayerConfig{Level: LayerTop, InputRects: []Rect{}}).surfaceOptions(); o.InputRects == nil {
49 t.Fatal("empty rects became nil")
50 }
51}
52
53func TestLayerConfigRejectsInvalid(t *testing.T) {
54 for name, c := range map[string]LayerConfig{
55 "zero level": {},
56 "bad level": {Level: 9},
57 "bad anchor": {Level: LayerTop, Anchors: 1 << 5},
58 "bad keyboard": {Level: LayerTop, Keyboard: 7},
59 "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
60 "nul output": {Level: LayerTop, Output: "a\x00"},
61 } {
62 if _, err := c.surfaceOptions(); err == nil {
63 t.Errorf("%s: expected error", name)
64 }
65 }
66}
67
68func TestLayerCopiesInputRects(t *testing.T) {
69 rects := []Rect{{0, 0, 1, 1}}
70 var cfg windowConfig
71 Layer(LayerConfig{Level: LayerTop, InputRects: rects}).window(&cfg)
72 rects[0].Width = 99
73 if cfg.layer.InputRects[0].Width != 1 {
74 t.Fatal("option aliases caller slice")
75 }
76}
77
78func TestInputEventMapping(t *testing.T) {
79 ev, ok := toInputEvent(wayland.Input{Kind: "press", X: 1.5, Y: 2.5, Button: 0x110})
80 if !ok || ev.Kind != InputPointerPress || !ev.Pressed || ev.X != 1.5 || ev.Y != 2.5 || ev.Button != 0x110 {
81 t.Fatalf("press: %+v", ev)
82 }
83 ev, _ = toInputEvent(wayland.Input{Kind: "release", Button: 0x110})
84 if ev.Kind != InputPointerRelease || ev.Pressed {
85 t.Fatalf("release: %+v", ev)
86 }
87 ev, ok = toInputEvent(wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Text: "x", Pressed: true, Repeat: true, Shift: true, Ctrl: true}})
88 if !ok || ev.Kind != InputKey || ev.KeyName != "Escape" || ev.Text != "x" || !ev.Pressed || !ev.Repeat || ev.Modifiers != ModShift|ModCtrl {
89 t.Fatalf("key: %+v", ev)
90 }
91 ev, _ = toInputEvent(wayland.Input{Kind: "axis", DX: 1, DY: -2})
92 if ev.Kind != InputPointerAxis || ev.DX != 1 || ev.DY != -2 {
93 t.Fatalf("axis: %+v", ev)
94 }
95 for kind, want := range map[string]InputKind{"motion": InputPointerMotion, "leave": InputPointerLeave, "focus-in": InputFocusIn, "focus-out": InputFocusOut, wayland.InputReset: InputReset} {
96 if ev, ok = toInputEvent(wayland.Input{Kind: kind}); !ok || ev.Kind != want {
97 t.Fatalf("%s: %+v", kind, ev)
98 }
99 }
100 if _, ok = toInputEvent(wayland.Input{Kind: "unknown"}); ok {
101 t.Fatal("unknown kind delivered")
102 }
103}
104
105func TestInputHookDelivery(t *testing.T) {
106 var got []InputEvent
107 h := &inputHooks{onInput: func(ev InputEvent) bool { got = append(got, ev); return ev.Kind == InputPointerPress }}
108 if h.input(wayland.Input{Kind: "motion", X: 3, Y: 4}) {
109 t.Fatal("motion reported a change")
110 }
111 if !h.input(wayland.Input{Kind: "press", Button: 1}) {
112 t.Fatal("press change not reported")
113 }
114 if len(got) != 2 || got[0].Kind != InputPointerMotion {
115 t.Fatalf("got %+v", got)
116 }
117 // Absent callbacks and steady-state delivery do not allocate.
118 idle := &inputHooks{}
119 in := wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Pressed: true}}
120 if n := testing.AllocsPerRun(100, func() { idle.input(in) }); n != 0 {
121 t.Fatalf("idle input allocs %v", n)
122 }
123 count := 0
124 live := &inputHooks{onInput: func(InputEvent) bool { count++; return false }}
125 if n := testing.AllocsPerRun(100, func() { live.input(in) }); n != 0 {
126 t.Fatalf("hooked input allocs %v", n)
127 }
128 if n := testing.AllocsPerRun(100, func() { idle.resize(10, 10, 1) }); n != 0 {
129 t.Fatalf("idle resize allocs %v", n)
130 }
131}
132
133func TestResizeReportedOnlyOnChange(t *testing.T) {
134 type size struct {
135 w, h int
136 s float64
137 }
138 var got []size
139 h := &inputHooks{onResize: func(w, hh int, s float64) { got = append(got, size{w, hh, s}) }}
140 var changed []bool
141 for _, s := range []size{{100, 50, 1}, {100, 50, 1}, {100, 60, 1}, {100, 60, 1}, {100, 60, 2}, {100, 60, 2}} {
142 changed = append(changed, h.resize(s.w, s.h, s.s))
143 }
144 want := []size{{100, 50, 1}, {100, 60, 1}, {100, 60, 2}}
145 if len(got) != len(want) {
146 t.Fatalf("resize reports %v", got)
147 }
148 for i := range want {
149 if got[i] != want[i] {
150 t.Fatalf("report %d: %v", i, got[i])
151 }
152 }
153 if !changed[0] || changed[1] || !changed[2] || changed[3] || !changed[4] || changed[5] {
154 t.Fatalf("changed flags %v", changed)
155 }
156}
157
158func TestExternalWakeForcesBuild(t *testing.T) {
159 r := newRuntime()
160 builds := 0
161 view := func(f *Frame) { builds++; f.Root() }
162 r.Build(view)
163 if r.Build(view) {
164 t.Fatal("idle runtime rebuilt")
165 }
166 ch := make(chan struct{})
167 ctx, cancel := context.WithCancel(context.Background())
168 defer cancel()
169 done := make(chan struct{})
170 go func() { r.forwardWake(ctx, ch); close(done) }()
171 ch <- struct{}{}
172 waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
173 defer waitCancel()
174 if err := r.Wait(waitCtx); err != nil {
175 t.Fatal("wake not forwarded:", err)
176 }
177 if !r.Build(view) || builds != 2 {
178 t.Fatalf("wake did not rebuild: builds=%d", builds)
179 }
180 close(ch)
181 select {
182 case <-done:
183 case <-time.After(time.Second):
184 t.Fatal("forwarder did not stop on close")
185 }
186}
187
188func TestNodeRectAndFrameSize(t *testing.T) {
189 r := newRuntime()
190 r.width, r.height = 400, 300
191 var w, h float64
192 r.Build(func(f *Frame) {
193 w, h = f.Size()
194 stack := f.Root().Stack()
195 stack.Box(Key("a")).Rect(10, 20, 30, 40)
196 stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
197 })
198 if w != 400 || h != 300 {
199 t.Fatalf("size %v x %v", w, h)
200 }
201 kids := r.output.Tree.Children[0].Children
202 if len(kids) != 2 {
203 t.Fatalf("children %d", len(kids))
204 }
205 if kids[0].Border != (layout.Rect{X: 10, Y: 20, W: 30, H: 40}) || kids[1].Border != (layout.Rect{X: 50.5, Y: 60.25, W: 70, H: 80}) {
206 t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
207 }
208 // Hit testing follows the rect geometry.
209 if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
210 t.Fatalf("hit %q want %q", id, kids[0].ID)
211 }
212}
213
214// Raw input observation must not replace normal control routing.
215func TestRawHookDoesNotBlockControls(t *testing.T) {
216 r := newRuntime()
217 catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
218 if err != nil {
219 t.Fatal(err)
220 }
221 r.setTextEngine(text.NewEngine(catalog))
222 r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px} button {height:20px}`))
223 activated := 0
224 view := shell(r, &activated)
225 r.Build(view)
226 button := r.output.Tree.Children[1]
227 seen := 0
228 h := &inputHooks{onInput: func(ev InputEvent) bool {
229 if ev.Kind == InputPointerPress || ev.Kind == InputPointerRelease {
230 seen++
231 }
232 return false
233 }}
234 for _, in := range []wayland.Input{
235 {Kind: "motion", X: 1, Y: button.Border.Y + 1},
236 {Kind: "press", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
237 {Kind: "release", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
238 } {
239 h.input(in)
240 r.routeInput(in)
241 }
242 r.Build(view)
243 if seen != 2 || activated != 1 {
244 t.Fatalf("seen %d activated %d", seen, activated)
245 }
246}
247
248func TestSurfaceHookOptionIsStored(t *testing.T) {
249 var cfg windowConfig
250 OnSurface(nil).window(&cfg)
251 if cfg.onSurface != nil {
252 t.Fatal("nil hook stored")
253 }
254 called := false
255 OnSurface(func(context.Context, WaylandSurface) error { called = true; return nil }).window(&cfg)
256 if cfg.onSurface == nil || cfg.onSurface(context.Background(), WaylandSurface{}) != nil || !called {
257 t.Fatal("hook not stored")
258 }
259}
260
261// testDisplay is a real wlturbo display over a unix socket with no compositor.
262func testDisplay(t *testing.T) *wl.Display {
263 t.Helper()
264 l, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
265 if err != nil {
266 t.Fatal(err)
267 }
268 defer l.Close()
269 accepted := make(chan net.Conn, 1)
270 go func() {
271 c, _ := l.Accept()
272 accepted <- c
273 }()
274 c, err := net.Dial("unix", l.Addr().String())
275 if err != nil {
276 t.Fatal(err)
277 }
278 server := <-accepted
279 t.Cleanup(func() { server.Close() })
280 d, err := wlturbo.ConnectFromConn(c)
281 if err != nil {
282 t.Fatal(err)
283 }
284 t.Cleanup(func() { d.Close() })
285 return d
286}
287
288func TestStartupHookErrorsAndCancellation(t *testing.T) {
289 boom := errors.New("boom")
290 // A plain error passes through and does not need a display.
291 if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return boom }, WaylandSurface{}); !errors.Is(err, boom) {
292 t.Fatalf("error: %v", err)
293 }
294 if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return nil }, WaylandSurface{}); err != nil {
295 t.Fatalf("nil: %v", err)
296 }
297 // A hook that observes cancellation reports ctx.Err joined with its own error.
298 display := testDisplay(t)
299 ctx, cancel := context.WithCancel(context.Background())
300 err := startupHook(ctx, func(ctx context.Context, _ WaylandSurface) error {
301 cancel()
302 <-ctx.Done()
303 return boom
304 }, WaylandSurface{Display: display})
305 if !errors.Is(err, context.Canceled) || !errors.Is(err, boom) || !display.Closed() {
306 t.Fatalf("cancel: %v", err)
307 }
308 // Cancelling without a hook error still reports the cancellation.
309 ctx, cancel = context.WithCancel(context.Background())
310 err = startupHook(ctx, func(context.Context, WaylandSurface) error { cancel(); return nil }, WaylandSurface{Display: testDisplay(t)})
311 if !errors.Is(err, context.Canceled) {
312 t.Fatalf("cancel without error: %v", err)
313 }
314}
315
316func TestFrameSetInputRects(t *testing.T) {
317 r := newRuntime()
318 var setErr error
319 rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
320 r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects(rects) })
321 if setErr != nil {
322 t.Fatal(setErr)
323 }
324 rects[0].Width = 99 // caller reuse must not affect the staged copy
325 var got [][]wayland.Rect
326 set := func(rs []wayland.Rect) error { got = append(got, append([]wayland.Rect{}, rs...)); return nil }
327 if err := r.applyInputRects(set); err != nil {
328 t.Fatal(err)
329 }
330 if len(got) != 1 || len(got[0]) != 2 || got[0][0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
331 t.Fatalf("applied %+v", got)
332 }
333 // Nothing staged: the platform is not called again.
334 if err := r.applyInputRects(set); err != nil || len(got) != 1 {
335 t.Fatalf("restaged: %v %+v", err, got)
336 }
337 // nil restores the full surface; empty non-nil is click-through.
338 var nilSeen, emptySeen bool
339 r.Redraw()
340 r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects(nil) })
341 _ = r.applyInputRects(func(rs []wayland.Rect) error { nilSeen = rs == nil; return nil })
342 r.Redraw()
343 r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{}) })
344 _ = r.applyInputRects(func(rs []wayland.Rect) error { emptySeen = rs != nil && len(rs) == 0; return nil })
345 if !nilSeen || !emptySeen {
346 t.Fatalf("nil %v empty %v", nilSeen, emptySeen)
347 }
348 // Invalid rects are rejected in the frame call and stage nothing.
349 r.Redraw()
350 for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
351 r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
352 if setErr == nil || r.inputStaged {
353 t.Fatalf("accepted %+v", bad)
354 }
355 r.Redraw()
356 }
357 // Steady-state staging and apply do not allocate once buffers exist.
358 r.Build(func(f *Frame) {
359 f.Root()
360 if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
361 t.Fatalf("SetInputRects allocs %v", n)
362 }
363 })
364 noop := func([]wayland.Rect) error { return nil }
365 r.inputStaged = true
366 if n := testing.AllocsPerRun(50, func() { r.inputStaged = true; _ = r.applyInputRects(noop) }); n != 0 {
367 t.Fatalf("applyInputRects allocs %v", n)
368 }
369}
370
371// A failed stage keeps the region pending so the next frame retries it.
372func TestStagedInputRectsRetainedOnFailure(t *testing.T) {
373 r := newRuntime()
374 r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}}) })
375 boom := errors.New("stage failed")
376 if err := r.applyInputRects(func([]wayland.Rect) error { return boom }); !errors.Is(err, boom) || !r.inputStaged {
377 t.Fatalf("failure: %v staged=%v", err, r.inputStaged)
378 }
379 var got []wayland.Rect
380 if err := r.applyInputRects(func(rs []wayland.Rect) error { got = append(got, rs...); return nil }); err != nil || r.inputStaged {
381 t.Fatalf("retry: %v staged=%v", err, r.inputStaged)
382 }
383 if len(got) != 1 || got[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
384 t.Fatalf("retried region %+v", got)
385 }
386 // A later frame that does not call SetInputRects stages nothing new.
387 r.Redraw()
388 r.Build(func(f *Frame) { f.Root() })
389 called := false
390 _ = r.applyInputRects(func([]wayland.Rect) error { called = true; return nil })
391 if called {
392 t.Fatal("unchanged frame restaged the region")
393 }
394}
395
396func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
397 r := newRuntime()
398 r.width, r.height = 200, 100
399 r.Build(func(f *Frame) {
400 col := f.Root().Column()
401 col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
402 col.Box(Key("b"), Inline("height:10px"))
403 })
404 kids := r.output.Tree.Children[0].Children
405 if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
406 t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
407 }
408}
409
410// The absolute geometry path adds no allocations over the same tree without it.
411func TestNodeRectAddsNoAllocations(t *testing.T) {
412 build := func(withRect bool) func() {
413 r := newRuntime()
414 r.width, r.height = 640, 480
415 view := func(f *Frame) {
416 st := f.Root().Stack()
417 for i := 0; i < 32; i++ {
418 b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
419 if withRect {
420 b.Rect(float64(i), float64(i), 20, 20)
421 }
422 }
423 }
424 r.Build(view)
425 return func() { r.Redraw(); r.Build(view) }
426 }
427 plain := testing.AllocsPerRun(20, build(false))
428 rect := testing.AllocsPerRun(20, build(true))
429 if rect > plain {
430 t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
431 }
432}