da641eec
Add MockDisplayOps + FrameLoop unit tests
a73x 2026-04-16 11:04
Commit message
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 | } | ||