a73x

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);
}