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