a73x

39c1f074

scenario: close TickError set + extract blink_period_ns const

a73x   2026-04-19 09:26

Removes the `|| anyerror` tail from TickError so callers can
exhaustively match. Callback failures from write_bytes / capture
now wrap to error.CallbackFailed with a log line. blink_period_ns
moves to a module-local pub const.

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

diff --git a/src/scenario.zig b/src/scenario.zig
index 0fd4e7c..42c766c 100644
--- a/src/scenario.zig
+++ b/src/scenario.zig
@@ -8,6 +8,11 @@ const std = @import("std");
const png = @import("png");
const imgdiff = @import("imgdiff");

/// Matches the cursor-blink period in the main runtime. Kept as a
/// module-local constant for now; if/when the runtime exports it
/// publicly, replace this with the imported symbol.
pub const blink_period_ns: i128 = 500 * std.time.ns_per_ms;

// ---------------------------------------------------------------
// Directive types
// ---------------------------------------------------------------
@@ -777,7 +782,8 @@ pub const TickError = error{
    SleepUntilFlipTimeout,
    AssertFailed,
    PredicateOnMissingLabel,
} || std.mem.Allocator.Error || anyerror; // callbacks can surface anyerror
    CallbackFailed,
} || std.mem.Allocator.Error;

pub const TickOutcome = enum {
    working, // directives remain; call tick again later
@@ -855,7 +861,7 @@ pub const ScenarioState = struct {
                        // 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;
                        const blink_timeout_ns: i128 = 2 * blink_period_ns;
                        if (now_ns - started > blink_timeout_ns) {
                            return error.SleepUntilFlipTimeout;
                        }
@@ -863,11 +869,17 @@ pub const ScenarioState = struct {
                    }
                },
                .bytes => |slice| {
                    try io.write_bytes(io.ctx, slice);
                    io.write_bytes(io.ctx, slice) catch |err| {
                        std.log.warn("scenario: write_bytes callback failed: {s}", .{@errorName(err)});
                        return error.CallbackFailed;
                    };
                    self.cursor += 1;
                },
                .capture => |label| {
                    const img = try io.capture(io.ctx, label);
                    const img = io.capture(io.ctx, label) catch |err| {
                        std.log.warn("scenario: capture callback failed: {s}", .{@errorName(err)});
                        return error.CallbackFailed;
                    };
                    // 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);
@@ -1078,6 +1090,45 @@ test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
    // 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());
    const r = state.tick(2 * blink_period_ns + 100 * std.time.ns_per_ms, tio.io());
    try std.testing.expectError(error.SleepUntilFlipTimeout, r);
}

const FailingIO = struct {
    pub fn writeBytes(ctx: *anyopaque, bytes: []const u8) anyerror!void {
        _ = ctx;
        _ = bytes;
        return error.DiskFull;
    }
    pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
        _ = ctx;
        _ = label;
        unreachable;
    }
    pub fn flipCb(ctx: *anyopaque) bool {
        _ = ctx;
        return false;
    }
};

test "tick: write_bytes callback failure surfaces as CallbackFailed" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var ctx: u8 = 0;
    const io: TickIO = .{
        .ctx = &ctx,
        .write_bytes = FailingIO.writeBytes,
        .capture = FailingIO.captureCb,
        .blink_just_flipped = FailingIO.flipCb,
    };

    const r = state.tick(0, io);
    try std.testing.expectError(error.CallbackFailed, r);
}