a73x

a36f7122

scenario: add ScenarioState + tick state machine

a73x   2026-04-19 09:22

Tick advances the directive cursor on a monotonic timeline,
invoking caller-supplied callbacks for bytes/capture/flip.
sleep-until-flip rendezvouses with the blink timer via a
caller-observed flag; times out at 2x blink period.

Assert-cell predicates still stubbed — Task 3 wires the
predicate evaluator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/src/scenario.zig b/src/scenario.zig
index 2dd3f50..0fd4e7c 100644
--- a/src/scenario.zig
+++ b/src/scenario.zig
@@ -752,3 +752,332 @@ test "parse: bytes-hex rejects empty token list" {
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "at least one token") != null);
}

// ---------------------------------------------------------------
// Tick state machine
// ---------------------------------------------------------------

/// Caller-supplied side-effects. The state machine itself is pure.
pub const TickIO = struct {
    ctx: *anyopaque,
    /// Write bytes into the terminal's VT parser (caller decides whether
    /// that goes through pty master or direct term.write).
    write_bytes: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!void,
    /// Perform an offscreen render and return an owned png.Image.
    /// The Scenario will take ownership; caller must not free.
    capture: *const fn (ctx: *anyopaque, label: []const u8) anyerror!png.Image,
    /// Return true if the blink timer flipped since the previous tick.
    /// Used by sleep-until-flip. If blink isn't armed at all, calls
    /// to this return false forever and sleep-until-flip will time out.
    blink_just_flipped: *const fn (ctx: *anyopaque) bool,
};

pub const TickError = error{
    ScenarioTimeout,
    SleepUntilFlipTimeout,
    AssertFailed,
    PredicateOnMissingLabel,
} || std.mem.Allocator.Error || anyerror; // callbacks can surface anyerror

pub const TickOutcome = enum {
    working, // directives remain; call tick again later
    done,    // no more directives
};

pub const ScenarioState = struct {
    scenario: *const Scenario,
    cursor: usize, // index into scenario.directives
    origin_ns: i128, // monotonic wall-clock at start
    deadline_ns: i128, // origin + timeout + slack; exceeding this → TickError.ScenarioTimeout
    scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
    sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
    captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
    alloc: std.mem.Allocator,

    pub fn init(
        alloc: std.mem.Allocator,
        scenario: *const Scenario,
        origin_ns: i128,
    ) ScenarioState {
        return .{
            .scenario = scenario,
            .cursor = 0,
            .origin_ns = origin_ns,
            .deadline_ns = origin_ns + @as(i128, scenario.timeout_ms) * std.time.ns_per_ms,
            .scheduled_offset_ns = 0,
            .sleep_until_flip_started_ns = null,
            .captures = .{},
            .alloc = alloc,
        };
    }

    pub fn deinit(self: *ScenarioState) void {
        var it = self.captures.iterator();
        while (it.next()) |entry| {
            self.alloc.free(entry.key_ptr.*);
            self.alloc.free(entry.value_ptr.pixels);
        }
        self.captures.deinit(self.alloc);
    }

    pub fn isDone(self: *const ScenarioState) bool {
        return self.cursor >= self.scenario.directives.len;
    }

    pub fn tick(self: *ScenarioState, now_ns: i128, io: TickIO) TickError!TickOutcome {
        // Check scenario-level timeout first.
        if (now_ns > self.deadline_ns) return error.ScenarioTimeout;

        const elapsed_ns = now_ns - self.origin_ns;

        // Loop over directives that are ready to execute.
        while (self.cursor < self.scenario.directives.len) {
            // Check if the current directive's scheduled time has arrived.
            if (elapsed_ns < self.scheduled_offset_ns) break;

            const directive = self.scenario.directives[self.cursor];

            switch (directive) {
                .sleep => |ms| {
                    self.scheduled_offset_ns += @as(i128, ms) * std.time.ns_per_ms;
                    self.cursor += 1;
                },
                .sleep_until_flip => {
                    if (self.sleep_until_flip_started_ns == null) {
                        // First encounter: stamp the start time and hold.
                        self.sleep_until_flip_started_ns = now_ns;
                        return .working;
                    }
                    // Subsequent encounter: check flip or timeout.
                    if (io.blink_just_flipped(io.ctx)) {
                        self.sleep_until_flip_started_ns = null;
                        self.cursor += 1;
                        // Continue the loop to execute the next directive.
                    } else {
                        const started = self.sleep_until_flip_started_ns.?;
                        const blink_timeout_ns: i128 = 500 * std.time.ns_per_ms * 2;
                        if (now_ns - started > blink_timeout_ns) {
                            return error.SleepUntilFlipTimeout;
                        }
                        return .working;
                    }
                },
                .bytes => |slice| {
                    try io.write_bytes(io.ctx, slice);
                    self.cursor += 1;
                },
                .capture => |label| {
                    const img = try io.capture(io.ctx, label);
                    // Dupe the label into ScenarioState.alloc so captures outlive the arena.
                    const label_copy = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(label_copy);
                    try self.captures.put(self.alloc, label_copy, img);
                    self.cursor += 1;
                },
                .assert_cell => unreachable, // Task 3 wires the predicate evaluator.
                .assert_cell_at => unreachable, // Task 3 wires the predicate evaluator.
            }
        }

        return if (self.isDone()) .done else .working;
    }
};

// ---------------------------------------------------------------
// Tick tests
// ---------------------------------------------------------------

const TestIO = struct {
    writes: std.ArrayListUnmanaged(u8) = .{},
    captures_called: std.ArrayListUnmanaged([]const u8) = .{},
    flip_stub: bool = false,
    alloc: std.mem.Allocator,

    pub fn writeBytes(ctx: *anyopaque, bytes: []const u8) anyerror!void {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        try self.writes.appendSlice(self.alloc, bytes);
    }

    pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        const label_copy = try self.alloc.dupe(u8, label);
        try self.captures_called.append(self.alloc, label_copy);
        // Return a 1x1 white pixel that the Scenario takes ownership of.
        const pixels = try self.alloc.alloc(u8, 4);
        pixels[0] = 255;
        pixels[1] = 255;
        pixels[2] = 255;
        pixels[3] = 255;
        return .{ .width = 1, .height = 1, .pixels = pixels };
    }

    pub fn flipCb(ctx: *anyopaque) bool {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        const v = self.flip_stub;
        self.flip_stub = false;
        return v;
    }

    pub fn io(self: *TestIO) TickIO {
        return .{
            .ctx = self,
            .write_bytes = writeBytes,
            .capture = captureCb,
            .blink_just_flipped = flipCb,
        };
    }

    pub fn deinit(self: *TestIO) void {
        for (self.captures_called.items) |lbl| self.alloc.free(lbl);
        self.captures_called.deinit(self.alloc);
        self.writes.deinit(self.alloc);
    }
};

test "tick: empty scenario is immediately done" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    try std.testing.expect(state.isDone());
}

test "tick: bytes directive fires write_bytes immediately" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
        \\bytes "abc"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    const r = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r);
    try std.testing.expectEqualSlices(u8, "abc", tio.writes.items);
    try std.testing.expect(state.isDone());
}

test "tick: sleep holds advancement until time passes" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep 500ms
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // At t=0, sleep is consumed (moves scheduled offset forward); bytes is held.
    const r1 = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.working, r1);
    try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);

    // At t=250ms, still under scheduled offset — no advance.
    const r2 = try state.tick(250 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.working, r2);
    try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);

    // At t=500ms, bytes fires.
    const r3 = try state.tick(500 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r3);
    try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
}

test "tick: capture calls io.capture and stores the result under the label" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap1
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    _ = try state.tick(0, tio.io());
    try std.testing.expect(state.isDone());
    try std.testing.expectEqual(@as(usize, 1), tio.captures_called.items.len);
    try std.testing.expectEqualSlices(u8, "snap1", tio.captures_called.items[0]);
    try std.testing.expect(state.captures.contains("snap1"));
}

test "tick: scenario timeout fires" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 100ms
        \\sleep 500ms
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // 200ms is past the 100ms scenario timeout.
    const r = state.tick(200 * std.time.ns_per_ms, tio.io());
    try std.testing.expectError(error.ScenarioTimeout, r);
}

test "tick: sleep-until-flip holds until flip callback returns true" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep-until-flip
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // No flip yet — tick holds at the sleep-until-flip directive.
    tio.flip_stub = false;
    const r1 = try state.tick(100 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.working, r1);
    try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);

    // Flip observed — sleep-until-flip advances; bytes fires.
    tio.flip_stub = true;
    const r2 = try state.tick(200 * std.time.ns_per_ms, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r2);
    try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
}

test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 10000ms
        \\sleep-until-flip
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // After 2 * 500ms = 1s with no flip observed, fail.
    // First tick enters the directive, stamps the start time.
    _ = try state.tick(0, tio.io());
    // Second tick at t=1.1s — over the 2x blink_period budget.
    const r = state.tick(1_100 * std.time.ns_per_ms, tio.io());
    try std.testing.expectError(error.SleepUntilFlipTimeout, r);
}