1package wayland
2
3import (
4 "slices"
5 "testing"
6 "time"
7
8 "github.com/bnema/go-wayland-bindings/server/wayland"
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12func TestDueFramesAllocations(t *testing.T) {
13 s := &Server{frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
14 now := time.Unix(100, 0)
15 s.dueFrames(now, nil)
16 if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, nil) }); allocs != 0 {
17 t.Errorf("idle dueFrames: %.1f allocs, want 0", allocs)
18 } else {
19 t.Logf("idle dueFrames: %.1f allocs", allocs)
20 }
21}
22
23func TestFramePeriod(t *testing.T) {
24 for _, tc := range []struct {
25 milli int
26 want time.Duration
27 }{{0, time.Second / 60}, {60000, time.Second / 60}, {165058, 6058476}, {144000, 6944444}} {
28 if got := framePeriod(tc.milli); got != tc.want {
29 t.Errorf("%d: %v want %v", tc.milli, got, tc.want)
30 }
31 }
32}
33
34// Each output is paced at its own refresh; a flip fires its queue and
35// pushes its deadline back; the pacer idles with nothing queued.
36func TestDueFrames(t *testing.T) {
37 fast := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 165000}}}
38 slow := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "HDMI-A-1", RefreshMilli: 60000}}}
39 s := &Server{outputs: []*output{fast, slow}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
40 // dueFrames only counts the queued callbacks: nil ones stand for them.
41 queue := func(name string) { s.awaiting[name] = append(s.awaiting[name], nil) }
42 fired := func(fire []string) {
43 for _, name := range fire {
44 delete(s.awaiting, name)
45 }
46 }
47 now := time.Unix(100, 0)
48 if _, _, idle := s.dueFrames(now, nil); !idle {
49 t.Fatal("not idle without callbacks")
50 }
51 // A first callback goes out at once, then one per refresh.
52 queue("DP-2")
53 queue("HDMI-A-1")
54 fire, _, idle := s.dueFrames(now, nil)
55 if len(fire) != 2 || !idle {
56 t.Fatalf("first fire %v idle %v", fire, idle)
57 }
58 fired(fire)
59 queue("DP-2")
60 queue("HDMI-A-1")
61 fire, wait, _ := s.dueFrames(now.Add(time.Millisecond), nil)
62 if len(fire) != 0 || wait != framePeriod(165000)-time.Millisecond {
63 t.Fatalf("early fire %v wait %v", fire, wait)
64 }
65 at := now.Add(framePeriod(165000))
66 fire, wait, _ = s.dueFrames(at, nil)
67 if !slices.Equal(fire, []string{"DP-2"}) || wait != time.Second/60-framePeriod(165000) {
68 t.Fatalf("165 Hz fire %v wait %v", fire, wait)
69 }
70 fired(fire)
71 // A flip fires its output early and pushes its deadline to 1.5 periods.
72 queue("DP-2")
73 flip := at.Add(time.Millisecond)
74 fire, _, _ = s.dueFrames(flip, map[string]bool{"DP-2": true})
75 if !slices.Equal(fire, []string{"DP-2"}) || s.frameDue["DP-2"] != flip.Add(framePeriod(165000)*3/2) {
76 t.Fatalf("flip fire %v due %v", fire, s.frameDue["DP-2"].Sub(flip))
77 }
78 fired(fire)
79 // A queue on an unplugged output moves to the outputless one (60 Hz).
80 s.outputs = []*output{fast}
81 queue("HDMI-A-1")
82 fire, _, _ = s.dueFrames(flip, nil)
83 fired(fire)
84 if _, ok := s.frameDue["HDMI-A-1"]; ok || len(s.awaiting["HDMI-A-1"]) != 0 || !slices.Equal(fire, []string{""}) {
85 t.Fatalf("unplugged: fire %v due %v", fire, s.frameDue)
86 }
87}
88
89func TestInvisibleFramePacingAndMigration(t *testing.T) {
90 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
91 s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
92 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
93 root := &surface{xdg: &xdgSurface{window: w}}
94 child := &surface{sub: subState{parent: root}}
95 cursor := &surface{}
96 // Use distinct live callback pointers as identities; dueFrames never dereferences them.
97 a, b := new(wayland.Callback), new(wayland.Callback)
98 s.queueFrames(child, []*wayland.Callback{a})
99 s.queueFrames(cursor, []*wayland.Callback{b})
100 w.last.Visible = false
101 s.relocateCallbacks(root)
102 if len(s.awaiting[suspendedFrameQueue]) != 1 || len(s.awaiting["DP-2"]) != 0 || len(s.awaiting[""]) != 1 || s.frameOutput(child) != suspendedFrameQueue {
103 t.Fatalf("migration: %+v", s.awaiting)
104 }
105 now := time.Unix(100, 0)
106 fire, _, _ := s.dueFrames(now, nil)
107 if !slices.Contains(fire, suspendedFrameQueue) || !slices.Contains(fire, "") {
108 t.Fatalf("initial fire: %v", fire)
109 }
110 delete(s.awaiting, suspendedFrameQueue)
111 delete(s.awaiting, "")
112 s.queueFrames(child, []*wayland.Callback{a})
113 s.queueFrames(cursor, []*wayland.Callback{b})
114 fire, wait, _ := s.dueFrames(now.Add(20*time.Millisecond), nil)
115 if slices.Contains(fire, suspendedFrameQueue) || !slices.Contains(fire, "") || wait != 980*time.Millisecond {
116 t.Fatalf("20ms: fire %v wait %v", fire, wait)
117 }
118 w.last.Visible = true
119 s.relocateCallbacks(root)
120 fire, _, _ = s.dueFrames(now.Add(21*time.Millisecond), nil)
121 if !slices.Contains(fire, "DP-2") || len(s.awaiting[suspendedFrameQueue]) != 0 {
122 t.Fatalf("resume: fire %v queues %+v", fire, s.awaiting)
123 }
124}
125
126// A window only a capture session draws is physically invisible (no scanout,
127// no presentation feedback) but is not throttled: its callbacks stay on its
128// output's queue and move when Captured changes.
129func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {
130 out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
131 s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
132 w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
133 root := &surface{xdg: &xdgSurface{window: w}}
134 a := new(wayland.Callback)
135 s.queueFrames(root, []*wayland.Callback{a})
136 w.last = ports.ConfigureWindow{Output: "DP-2", Captured: true}
137 s.relocateCallbacks(root)
138 if !s.invisible(root) || s.suspended(root) || s.frameOutput(root) != "DP-2" || len(s.awaiting["DP-2"]) != 1 || len(s.awaiting[suspendedFrameQueue]) != 0 {
139 t.Fatalf("captured: invisible %v suspended %v queues %+v", s.invisible(root), s.suspended(root), s.awaiting)
140 }
141 w.last = ports.ConfigureWindow{Output: "DP-2"}
142 s.relocateCallbacks(root)
143 if !s.suspended(root) || len(s.awaiting[suspendedFrameQueue]) != 1 || len(s.awaiting["DP-2"]) != 0 {
144 t.Fatalf("released: queues %+v", s.awaiting)
145 }
146}