a73x

da641eec

Add MockDisplayOps + FrameLoop unit tests

a73x   2026-04-16 11:04

Commit message
Add MockDisplayOps + FrameLoop unit tests

9 tests covering: initial state, commit disarms, callback done re-arms,
stale-token rejection, hidden cleanup, show force-arm, canRender
gating, forceArm recovery, deinit cleanup.

src/frame_loop.zig
Old New
@@ -130,3 +130,242 @@ pub const FrameLoop = struct {
130 self.ops.dispatchPendingFn(self.ops.ctx); 130 self.ops.dispatchPendingFn(self.ops.ctx);
131 } 131 }
132 }; 132 };
133
134 // ---------------------------------------------------------------------------
135 // Test-only mock below this line.
136 // ---------------------------------------------------------------------------
137
138 const Mock = struct {
139 next_token: usize = 1,
140 frame_requests: usize = 0,
141 callbacks_destroyed: usize = 0,
142 flushed: usize = 0,
143 dispatched: usize = 0,
144
145 // A fake fd that never becomes ready — tests call waitForWork with a
146 // 0ms timeout so poll returns immediately.
147 pipe_fds: [2]std.posix.fd_t = .{ -1, -1 },
148
149 fn init() !Mock {
150 const pipes = try std.posix.pipe();
151 return .{ .pipe_fds = pipes };
152 }
153
154 fn deinit(self: *Mock) void {
155 if (self.pipe_fds[0] >= 0) std.posix.close(self.pipe_fds[0]);
156 if (self.pipe_fds[1] >= 0) std.posix.close(self.pipe_fds[1]);
157 }
158
159 fn flushThunk(ctx: *anyopaque) void {
160 const self: *Mock = @ptrCast(@alignCast(ctx));
161 self.flushed += 1;
162 }
163 fn prepareReadThunk(_: *anyopaque) bool { return false; }
164 fn readEventsThunk(_: *anyopaque) void {}
165 fn dispatchPendingThunk(ctx: *anyopaque) void {
166 const self: *Mock = @ptrCast(@alignCast(ctx));
167 self.dispatched += 1;
168 }
169 fn getFdThunk(ctx: *anyopaque) std.posix.fd_t {
170 const self: *Mock = @ptrCast(@alignCast(ctx));
171 return self.pipe_fds[0];
172 }
173 fn requestFrameThunk(ctx: *anyopaque, _: *FrameLoop) anyerror!CallbackToken {
174 const self: *Mock = @ptrCast(@alignCast(ctx));
175 const tok: CallbackToken = @ptrFromInt(self.next_token);
176 self.next_token += 1;
177 self.frame_requests += 1;
178 return tok;
179 }
180 fn destroyCallbackThunk(ctx: *anyopaque, _: CallbackToken) void {
181 const self: *Mock = @ptrCast(@alignCast(ctx));
182 self.callbacks_destroyed += 1;
183 }
184
185 fn ops(self: *Mock) DisplayOps {
186 return .{
187 .ctx = self,
188 .flushFn = flushThunk,
189 .prepareReadFn = prepareReadThunk,
190 .readEventsFn = readEventsThunk,
191 .dispatchPendingFn = dispatchPendingThunk,
192 .getFdFn = getFdThunk,
193 .requestFrameFn = requestFrameThunk,
194 .destroyCallbackFn = destroyCallbackThunk,
195 };
196 }
197 };
198
199 const TestState = struct {
200 configured: bool = true,
201 suspended: bool = false,
202 tracker: scale_tracker.ScaleTracker,
203
204 fn init(alloc: std.mem.Allocator) !TestState {
205 var t = scale_tracker.ScaleTracker.init(alloc);
206 try t.addOutput(1);
207 try t.enterOutput(1);
208 return .{ .tracker = t };
209 }
210
211 fn deinit(self: *TestState) void {
212 self.tracker.deinit();
213 }
214
215 fn view(self: *const TestState) SurfaceStateView {
216 return .{
217 .configured_ptr = &self.configured,
218 .suspended_ptr = &self.suspended,
219 .tracker = &self.tracker,
220 };
221 }
222 };
223
224 test "initial state: armed and can render when visible" {
225 var mock = try Mock.init();
226 defer mock.deinit();
227 var ts = try TestState.init(std.testing.allocator);
228 defer ts.deinit();
229
230 var loop = FrameLoop.init(mock.ops(), ts.view());
231 defer loop.deinit();
232
233 try std.testing.expect(loop.armed);
234 try std.testing.expect(loop.canRender());
235 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
236 }
237
238 test "commitRender requests a frame and disarms" {
239 var mock = try Mock.init();
240 defer mock.deinit();
241 var ts = try TestState.init(std.testing.allocator);
242 defer ts.deinit();
243
244 var loop = FrameLoop.init(mock.ops(), ts.view());
245 defer loop.deinit();
246
247 try loop.commitRender();
248
249 try std.testing.expect(!loop.armed);
250 try std.testing.expect(!loop.canRender());
251 try std.testing.expect(loop.pending_token != null);
252 try std.testing.expectEqual(@as(usize, 1), mock.frame_requests);
253 }
254
255 test "onFrameCallbackDone re-arms when token matches" {
256 var mock = try Mock.init();
257 defer mock.deinit();
258 var ts = try TestState.init(std.testing.allocator);
259 defer ts.deinit();
260
261 var loop = FrameLoop.init(mock.ops(), ts.view());
262 defer loop.deinit();
263
264 try loop.commitRender();
265 const tok = loop.pending_token.?;
266 loop.onFrameCallbackDone(tok);
267
268 try std.testing.expect(loop.armed);
269 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
270 try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
271 }
272
273 test "onFrameCallbackDone ignores stale token" {
274 var mock = try Mock.init();
275 defer mock.deinit();
276 var ts = try TestState.init(std.testing.allocator);
277 defer ts.deinit();
278
279 var loop = FrameLoop.init(mock.ops(), ts.view());
280 defer loop.deinit();
281
282 try loop.commitRender();
283 const stale_tok: CallbackToken = @ptrFromInt(0xDEAD);
284 loop.onFrameCallbackDone(stale_tok);
285
286 try std.testing.expect(!loop.armed);
287 try std.testing.expect(loop.pending_token != null);
288 }
289
290 test "onSurfaceHidden destroys pending callback and leaves armed unchanged" {
291 var mock = try Mock.init();
292 defer mock.deinit();
293 var ts = try TestState.init(std.testing.allocator);
294 defer ts.deinit();
295
296 var loop = FrameLoop.init(mock.ops(), ts.view());
297 defer loop.deinit();
298
299 try loop.commitRender();
300 const armed_before = loop.armed;
301 loop.onSurfaceHidden();
302
303 try std.testing.expectEqual(armed_before, loop.armed); // false, unchanged
304 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
305 try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
306 }
307
308 test "onSurfaceShown force-arms" {
309 var mock = try Mock.init();
310 defer mock.deinit();
311 var ts = try TestState.init(std.testing.allocator);
312 defer ts.deinit();
313
314 var loop = FrameLoop.init(mock.ops(), ts.view());
315 defer loop.deinit();
316
317 try loop.commitRender();
318 try std.testing.expect(!loop.armed);
319 loop.onSurfaceShown();
320 try std.testing.expect(loop.armed);
321 }
322
323 test "canRender requires both armed and visible" {
324 var mock = try Mock.init();
325 defer mock.deinit();
326 var ts = try TestState.init(std.testing.allocator);
327 defer ts.deinit();
328
329 var loop = FrameLoop.init(mock.ops(), ts.view());
330 defer loop.deinit();
331
332 try std.testing.expect(loop.canRender()); // armed + visible
333
334 ts.suspended = true;
335 try std.testing.expect(!loop.canRender()); // visibility gate
336
337 ts.suspended = false;
338 try loop.commitRender();
339 try std.testing.expect(!loop.canRender()); // armed gate
340 }
341
342 test "forceArm recovers without a callback (OUT_OF_DATE path)" {
343 var mock = try Mock.init();
344 defer mock.deinit();
345 var ts = try TestState.init(std.testing.allocator);
346 defer ts.deinit();
347
348 var loop = FrameLoop.init(mock.ops(), ts.view());
349 defer loop.deinit();
350
351 try loop.commitRender();
352 try std.testing.expect(!loop.armed);
353 loop.forceArm();
354 try std.testing.expect(loop.armed);
355 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
356 }
357
358 test "deinit destroys any pending callback" {
359 var mock = try Mock.init();
360 defer mock.deinit();
361 var ts = try TestState.init(std.testing.allocator);
362 defer ts.deinit();
363
364 {
365 var loop = FrameLoop.init(mock.ops(), ts.view());
366 defer loop.deinit();
367 try loop.commitRender();
368 }
369
370 try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
371 }