da641eec
Add MockDisplayOps + FrameLoop unit tests
a73x 2026-04-16 11:04
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.
diff --git a/src/frame_loop.zig b/src/frame_loop.zig index 640872b..66e5bdf 100644 --- a/src/frame_loop.zig +++ b/src/frame_loop.zig @@ -130,3 +130,242 @@ pub const FrameLoop = struct { self.ops.dispatchPendingFn(self.ops.ctx); } }; // --------------------------------------------------------------------------- // Test-only mock below this line. // --------------------------------------------------------------------------- const Mock = struct { next_token: usize = 1, frame_requests: usize = 0, callbacks_destroyed: usize = 0, flushed: usize = 0, dispatched: usize = 0, // A fake fd that never becomes ready — tests call waitForWork with a // 0ms timeout so poll returns immediately. pipe_fds: [2]std.posix.fd_t = .{ -1, -1 }, fn init() !Mock { const pipes = try std.posix.pipe(); return .{ .pipe_fds = pipes }; } fn deinit(self: *Mock) void { if (self.pipe_fds[0] >= 0) std.posix.close(self.pipe_fds[0]); if (self.pipe_fds[1] >= 0) std.posix.close(self.pipe_fds[1]); } fn flushThunk(ctx: *anyopaque) void { const self: *Mock = @ptrCast(@alignCast(ctx)); self.flushed += 1; } fn prepareReadThunk(_: *anyopaque) bool { return false; } fn readEventsThunk(_: *anyopaque) void {} fn dispatchPendingThunk(ctx: *anyopaque) void { const self: *Mock = @ptrCast(@alignCast(ctx)); self.dispatched += 1; } fn getFdThunk(ctx: *anyopaque) std.posix.fd_t { const self: *Mock = @ptrCast(@alignCast(ctx)); return self.pipe_fds[0]; } fn requestFrameThunk(ctx: *anyopaque, _: *FrameLoop) anyerror!CallbackToken { const self: *Mock = @ptrCast(@alignCast(ctx)); const tok: CallbackToken = @ptrFromInt(self.next_token); self.next_token += 1; self.frame_requests += 1; return tok; } fn destroyCallbackThunk(ctx: *anyopaque, _: CallbackToken) void { const self: *Mock = @ptrCast(@alignCast(ctx)); self.callbacks_destroyed += 1; } fn ops(self: *Mock) DisplayOps { return .{ .ctx = self, .flushFn = flushThunk, .prepareReadFn = prepareReadThunk, .readEventsFn = readEventsThunk, .dispatchPendingFn = dispatchPendingThunk, .getFdFn = getFdThunk, .requestFrameFn = requestFrameThunk, .destroyCallbackFn = destroyCallbackThunk, }; } }; const TestState = struct { configured: bool = true, suspended: bool = false, tracker: scale_tracker.ScaleTracker, fn init(alloc: std.mem.Allocator) !TestState { var t = scale_tracker.ScaleTracker.init(alloc); try t.addOutput(1); try t.enterOutput(1); return .{ .tracker = t }; } fn deinit(self: *TestState) void { self.tracker.deinit(); } fn view(self: *const TestState) SurfaceStateView { return .{ .configured_ptr = &self.configured, .suspended_ptr = &self.suspended, .tracker = &self.tracker, }; } }; test "initial state: armed and can render when visible" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try std.testing.expect(loop.armed); try std.testing.expect(loop.canRender()); try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token); } test "commitRender requests a frame and disarms" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); try std.testing.expect(!loop.armed); try std.testing.expect(!loop.canRender()); try std.testing.expect(loop.pending_token != null); try std.testing.expectEqual(@as(usize, 1), mock.frame_requests); } test "onFrameCallbackDone re-arms when token matches" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); const tok = loop.pending_token.?; loop.onFrameCallbackDone(tok); try std.testing.expect(loop.armed); try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token); try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed); } test "onFrameCallbackDone ignores stale token" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); const stale_tok: CallbackToken = @ptrFromInt(0xDEAD); loop.onFrameCallbackDone(stale_tok); try std.testing.expect(!loop.armed); try std.testing.expect(loop.pending_token != null); } test "onSurfaceHidden destroys pending callback and leaves armed unchanged" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); const armed_before = loop.armed; loop.onSurfaceHidden(); try std.testing.expectEqual(armed_before, loop.armed); // false, unchanged try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token); try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed); } test "onSurfaceShown force-arms" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); try std.testing.expect(!loop.armed); loop.onSurfaceShown(); try std.testing.expect(loop.armed); } test "canRender requires both armed and visible" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try std.testing.expect(loop.canRender()); // armed + visible ts.suspended = true; try std.testing.expect(!loop.canRender()); // visibility gate ts.suspended = false; try loop.commitRender(); try std.testing.expect(!loop.canRender()); // armed gate } test "forceArm recovers without a callback (OUT_OF_DATE path)" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); try std.testing.expect(!loop.armed); loop.forceArm(); try std.testing.expect(loop.armed); try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token); } test "deinit destroys any pending callback" { var mock = try Mock.init(); defer mock.deinit(); var ts = try TestState.init(std.testing.allocator); defer ts.deinit(); { var loop = FrameLoop.init(mock.ops(), ts.view()); defer loop.deinit(); try loop.commitRender(); } try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed); }