a73x

src/scenario.zig

Ref:   Size: 61.1 KiB

//! Scenario runner — parser, state machine, and cell predicate evaluator.
//!
//! All APIs in this file are pure. Side-effectful work (pty writes,
//! offscreen renders) is represented as callbacks that the caller
//! supplies. See docs/superpowers/specs/2026-04-19-scenario-runner-design.md.

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
// ---------------------------------------------------------------

pub const Predicate = enum {
    cell_matches_golden,
    cursor_block_at,
    cursor_bar_at,
    cursor_underline_at,
    cell_empty,
};

pub const Directive = union(enum) {
    sleep: u64, // ms
    sleep_until_flip,
    bytes: []const u8, // owned by Scenario.arena
    capture: []const u8, // label, owned by Scenario.arena
    assert_cell: AssertCell,
    assert_cell_at: AssertCellAt,

    pub const AssertCell = struct {
        row: u16,
        col: u16,
        pred: Predicate,
    };
    pub const AssertCellAt = struct {
        label: []const u8, // owned by Scenario.arena
        row: u16,
        col: u16,
        pred: Predicate,
    };
};

pub const Scenario = struct {
    cols: u16,
    rows: u16,
    timeout_ms: u64,
    directives: []const Directive,

    // Owns directive payload slices.
    arena: std.heap.ArenaAllocator,

    pub fn deinit(self: *Scenario) void {
        self.arena.deinit();
    }
};

pub const Diagnostic = struct {
    line: usize = 0,
    message: []const u8 = "", // static string
};

pub const ParseError = error{ParseFailed} || std.mem.Allocator.Error;

// ---------------------------------------------------------------
// Parser implementation
// ---------------------------------------------------------------

fn parsePredicate(token: []const u8) ?Predicate {
    if (std.mem.eql(u8, token, "cell-matches-golden")) return .cell_matches_golden;
    if (std.mem.eql(u8, token, "cursor-block-at")) return .cursor_block_at;
    if (std.mem.eql(u8, token, "cursor-bar-at")) return .cursor_bar_at;
    if (std.mem.eql(u8, token, "cursor-underline-at")) return .cursor_underline_at;
    if (std.mem.eql(u8, token, "cell-empty")) return .cell_empty;
    return null;
}

/// Parse a duration token of the form "NNms" or "NNs". Returns ms.
fn parseDuration(token: []const u8, diag: *Diagnostic, line: usize) ParseError!u64 {
    if (std.mem.endsWith(u8, token, "ms")) {
        const n = std.fmt.parseInt(u64, token[0 .. token.len - 2], 10) catch {
            diag.line = line;
            diag.message = "invalid sleep duration";
            return error.ParseFailed;
        };
        return n;
    } else if (std.mem.endsWith(u8, token, "s")) {
        const n = std.fmt.parseInt(u64, token[0 .. token.len - 1], 10) catch {
            diag.line = line;
            diag.message = "invalid sleep duration";
            return error.ParseFailed;
        };
        return n * 1000;
    } else {
        diag.line = line;
        diag.message = "sleep duration must end in 'ms' or 's'";
        return error.ParseFailed;
    }
}

/// Parse a double-quoted string with escape sequences.
/// Escape set: \n \t \r \e (→ 0x1B) \\ \"
fn parseQuotedString(
    arena_alloc: std.mem.Allocator,
    token: []const u8,
    diag: *Diagnostic,
    line: usize,
) ParseError![]const u8 {
    if (token.len < 2 or token[0] != '"' or token[token.len - 1] != '"') {
        diag.line = line;
        diag.message = "bytes string must be double-quoted";
        return error.ParseFailed;
    }
    const inner = token[1 .. token.len - 1];
    var buf: std.ArrayList(u8) = .empty;
    var i: usize = 0;
    while (i < inner.len) {
        if (inner[i] == '\\') {
            if (i + 1 >= inner.len) {
                diag.line = line;
                diag.message = "trailing backslash in bytes string";
                return error.ParseFailed;
            }
            const ch = inner[i + 1];
            switch (ch) {
                'n' => try buf.append(arena_alloc, '\n'),
                't' => try buf.append(arena_alloc, '\t'),
                'r' => try buf.append(arena_alloc, '\r'),
                'e' => try buf.append(arena_alloc, 0x1B),
                '\\' => try buf.append(arena_alloc, '\\'),
                '"' => try buf.append(arena_alloc, '"'),
                else => {
                    diag.line = line;
                    diag.message = "unknown escape sequence in bytes string";
                    return error.ParseFailed;
                },
            }
            i += 2;
        } else {
            try buf.append(arena_alloc, inner[i]);
            i += 1;
        }
    }
    return buf.toOwnedSlice(arena_alloc);
}

/// Split a line into whitespace-separated tokens.
/// Handles the special case of a quoted string as the second token for `bytes`.
fn nextToken(s: []const u8, start: usize) ?struct { tok: []const u8, end: usize } {
    var i = start;
    // skip leading whitespace
    while (i < s.len and (s[i] == ' ' or s[i] == '\t')) i += 1;
    if (i >= s.len) return null;
    const tok_start = i;
    if (s[i] == '"') {
        // consume until closing quote (no nesting)
        i += 1;
        while (i < s.len and s[i] != '"') {
            if (s[i] == '\\') i += 1; // skip escaped char
            if (i < s.len) i += 1;
        }
        if (i < s.len) i += 1; // consume closing quote
        return .{ .tok = s[tok_start..i], .end = i };
    } else {
        while (i < s.len and s[i] != ' ' and s[i] != '\t') i += 1;
        return .{ .tok = s[tok_start..i], .end = i };
    }
}

pub fn parse(
    gpa: std.mem.Allocator,
    source: []const u8,
    diag: *Diagnostic,
) ParseError!Scenario {
    var arena = std.heap.ArenaAllocator.init(gpa);
    errdefer arena.deinit();
    const alloc = arena.allocator();

    var directives: std.ArrayList(Directive) = .empty;

    var has_size = false;
    var has_timeout = false;
    var cols: u16 = 0;
    var rows: u16 = 0;
    var timeout_ms: u64 = 0;

    var lines = std.mem.splitScalar(u8, source, '\n');
    var line_num: usize = 0;

    while (lines.next()) |raw_line| {
        line_num += 1;
        const line = std.mem.trim(u8, raw_line, " \t\r");

        // Skip blank lines and comments
        if (line.len == 0 or line[0] == '#') continue;

        // Get first token (directive keyword)
        const kw_res = nextToken(line, 0) orelse continue;
        const kw = kw_res.tok;
        var pos = kw_res.end;

        if (std.mem.eql(u8, kw, "size")) {
            if (has_timeout) {
                diag.line = line_num;
                diag.message = "size directive must appear before timeout";
                return error.ParseFailed;
            }
            if (has_size) {
                diag.line = line_num;
                diag.message = "duplicate size directive";
                return error.ParseFailed;
            }
            const cols_res = nextToken(line, pos) orelse {
                diag.line = line_num;
                diag.message = "size directive requires cols and rows";
                return error.ParseFailed;
            };
            pos = cols_res.end;
            const rows_res = nextToken(line, pos) orelse {
                diag.line = line_num;
                diag.message = "size directive requires cols and rows";
                return error.ParseFailed;
            };
            cols = std.fmt.parseInt(u16, cols_res.tok, 10) catch {
                diag.line = line_num;
                diag.message = "size cols must be a positive integer";
                return error.ParseFailed;
            };
            rows = std.fmt.parseInt(u16, rows_res.tok, 10) catch {
                diag.line = line_num;
                diag.message = "size rows must be a positive integer";
                return error.ParseFailed;
            };
            if (cols == 0 or rows == 0) {
                diag.* = .{ .line = line_num, .message = "size dimensions must be non-zero" };
                return error.ParseFailed;
            }
            has_size = true;
        } else if (std.mem.eql(u8, kw, "timeout")) {
            if (!has_size) {
                diag.line = line_num;
                diag.message = "expected size directive first";
                return error.ParseFailed;
            }
            if (has_timeout) {
                diag.line = line_num;
                diag.message = "duplicate timeout directive";
                return error.ParseFailed;
            }
            const dur_res = nextToken(line, pos) orelse {
                diag.line = line_num;
                diag.message = "timeout directive requires a duration";
                return error.ParseFailed;
            };
            timeout_ms = try parseDuration(dur_res.tok, diag, line_num);
            has_timeout = true;
        } else {
            // Body directives require size + timeout first
            if (!has_size) {
                diag.line = line_num;
                diag.message = "expected size directive first";
                return error.ParseFailed;
            }
            if (!has_timeout) {
                diag.line = line_num;
                diag.message = "expected timeout directive before body directives";
                return error.ParseFailed;
            }

            if (std.mem.eql(u8, kw, "sleep")) {
                const dur_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "sleep directive requires a duration";
                    return error.ParseFailed;
                };
                const ms = try parseDuration(dur_res.tok, diag, line_num);
                try directives.append(alloc, .{ .sleep = ms });
            } else if (std.mem.eql(u8, kw, "sleep-until-flip")) {
                try directives.append(alloc, .sleep_until_flip);
            } else if (std.mem.eql(u8, kw, "bytes")) {
                const str_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "bytes directive requires a quoted string";
                    return error.ParseFailed;
                };
                // Verify it starts with a quote; if not, the string was unquoted/unterminated
                if (str_res.tok.len == 0 or str_res.tok[0] != '"') {
                    diag.line = line_num;
                    diag.message = "bytes string must be double-quoted";
                    return error.ParseFailed;
                }
                // Check for unclosed quote: the token should end with '"' too
                if (str_res.tok.len < 2 or str_res.tok[str_res.tok.len - 1] != '"') {
                    diag.line = line_num;
                    diag.message = "unterminated string in bytes directive";
                    return error.ParseFailed;
                }
                const payload = try parseQuotedString(alloc, str_res.tok, diag, line_num);
                try directives.append(alloc, .{ .bytes = payload });
            } else if (std.mem.eql(u8, kw, "bytes-hex")) {
                var hex_buf: std.ArrayList(u8) = .empty;
                var cur_pos = pos;
                while (nextToken(line, cur_pos)) |tok_res| {
                    cur_pos = tok_res.end;
                    const tok = tok_res.tok;
                    if (tok.len != 2) {
                        diag.line = line_num;
                        diag.message = "bytes-hex tokens must be exactly 2 hex characters";
                        return error.ParseFailed;
                    }
                    const byte = std.fmt.parseInt(u8, tok, 16) catch {
                        diag.line = line_num;
                        diag.message = "bytes-hex token is not valid hexadecimal";
                        return error.ParseFailed;
                    };
                    try hex_buf.append(alloc, byte);
                }
                if (hex_buf.items.len == 0) {
                    diag.* = .{ .line = line_num, .message = "bytes-hex requires at least one token" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .bytes = try hex_buf.toOwnedSlice(alloc) });
            } else if (std.mem.eql(u8, kw, "capture")) {
                const lbl_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "capture directive requires a label";
                    return error.ParseFailed;
                };
                const lbl = lbl_res.tok;
                if (lbl.len == 0) {
                    diag.line = line_num;
                    diag.message = "capture label must not be empty";
                    return error.ParseFailed;
                }
                // Validate label chars: [a-zA-Z0-9_-]+
                for (lbl) |ch| {
                    if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') {
                        diag.line = line_num;
                        diag.message = "capture label contains invalid characters";
                        return error.ParseFailed;
                    }
                }
                const label_copy = try alloc.dupe(u8, lbl);
                try directives.append(alloc, .{ .capture = label_copy });
            } else if (std.mem.eql(u8, kw, "assert-cell")) {
                const row_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell requires row col predicate";
                    return error.ParseFailed;
                };
                pos = row_res.end;
                const col_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell requires row col predicate";
                    return error.ParseFailed;
                };
                pos = col_res.end;
                const pred_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell requires row col predicate";
                    return error.ParseFailed;
                };
                const row = std.fmt.parseInt(u16, row_res.tok, 10) catch {
                    diag.line = line_num;
                    diag.message = "assert-cell row must be an integer";
                    return error.ParseFailed;
                };
                const col = std.fmt.parseInt(u16, col_res.tok, 10) catch {
                    diag.line = line_num;
                    diag.message = "assert-cell col must be an integer";
                    return error.ParseFailed;
                };
                const pred = parsePredicate(pred_res.tok) orelse {
                    diag.line = line_num;
                    diag.message = "unknown predicate";
                    return error.ParseFailed;
                };
                if (row >= rows or col >= cols) {
                    diag.* = .{ .line = line_num, .message = "assert-cell row/col out of bounds for scenario size" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .assert_cell = .{ .row = row, .col = col, .pred = pred } });
            } else if (std.mem.eql(u8, kw, "assert-cell-at")) {
                const lbl_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell-at requires label row col predicate";
                    return error.ParseFailed;
                };
                pos = lbl_res.end;
                const row_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell-at requires label row col predicate";
                    return error.ParseFailed;
                };
                pos = row_res.end;
                const col_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell-at requires label row col predicate";
                    return error.ParseFailed;
                };
                pos = col_res.end;
                const pred_res = nextToken(line, pos) orelse {
                    diag.line = line_num;
                    diag.message = "assert-cell-at requires label row col predicate";
                    return error.ParseFailed;
                };
                const lbl = lbl_res.tok;
                for (lbl) |ch| {
                    if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') {
                        diag.line = line_num;
                        diag.message = "assert-cell-at label contains invalid characters";
                        return error.ParseFailed;
                    }
                }
                const label_copy = try alloc.dupe(u8, lbl);
                const row = std.fmt.parseInt(u16, row_res.tok, 10) catch {
                    diag.line = line_num;
                    diag.message = "assert-cell-at row must be an integer";
                    return error.ParseFailed;
                };
                const col = std.fmt.parseInt(u16, col_res.tok, 10) catch {
                    diag.line = line_num;
                    diag.message = "assert-cell-at col must be an integer";
                    return error.ParseFailed;
                };
                const pred = parsePredicate(pred_res.tok) orelse {
                    diag.line = line_num;
                    diag.message = "unknown predicate";
                    return error.ParseFailed;
                };
                if (row >= rows or col >= cols) {
                    diag.* = .{ .line = line_num, .message = "assert-cell-at row/col out of bounds for scenario size" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .assert_cell_at = .{
                    .label = label_copy,
                    .row = row,
                    .col = col,
                    .pred = pred,
                } });
            } else {
                diag.line = line_num;
                diag.message = "unknown directive";
                return error.ParseFailed;
            }
        }
    }

    if (!has_size) {
        diag.line = 1;
        diag.message = "missing size directive";
        return error.ParseFailed;
    }
    if (!has_timeout) {
        diag.line = line_num;
        diag.message = "missing timeout directive";
        return error.ParseFailed;
    }

    return Scenario{
        .cols = cols,
        .rows = rows,
        .timeout_ms = timeout_ms,
        .directives = try directives.toOwnedSlice(alloc),
        .arena = arena,
    };
}

// ---------------------------------------------------------------
// Parser tests
// ---------------------------------------------------------------

fn parseOk(source: []const u8) !Scenario {
    var diag: Diagnostic = .{};
    return parse(std.testing.allocator, source, &diag) catch |err| {
        std.debug.print("unexpected parse error at line {}: {s}\n", .{ diag.line, diag.message });
        return err;
    };
}

fn parseErr(source: []const u8) !Diagnostic {
    var diag: Diagnostic = .{};
    const result = parse(std.testing.allocator, source, &diag);
    try std.testing.expectError(error.ParseFailed, result);
    return diag;
}

test "parse: minimum valid scenario" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 1000ms
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(u16, 80), s.cols);
    try std.testing.expectEqual(@as(u16, 24), s.rows);
    try std.testing.expectEqual(@as(u64, 1000), s.timeout_ms);
    try std.testing.expectEqual(@as(usize, 0), s.directives.len);
}

test "parse: comments and blank lines ignored" {
    var s = try parseOk(
        \\# leading comment
        \\
        \\size 80 24
        \\   # indented comment
        \\timeout 500ms
        \\
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(u16, 80), s.cols);
}

test "parse: missing size directive fails at line 1" {
    const diag = try parseErr(
        \\timeout 500ms
    );
    try std.testing.expectEqual(@as(usize, 1), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "size") != null);
}

test "parse: missing timeout directive fails" {
    const diag = try parseErr(
        \\size 80 24
    );
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "timeout") != null);
}

test "parse: unknown directive fails at its line" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\what-is-this
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "unknown directive") != null);
}

test "parse: sleep directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep 250ms
        \\sleep 1s
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(usize, 2), s.directives.len);
    try std.testing.expectEqual(@as(u64, 250), s.directives[0].sleep);
    try std.testing.expectEqual(@as(u64, 1000), s.directives[1].sleep);
}

test "parse: sleep-until-flip directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\sleep-until-flip
    );
    defer s.deinit();
    try std.testing.expectEqual(@as(usize, 1), s.directives.len);
    try std.testing.expect(s.directives[0] == .sleep_until_flip);
}

test "parse: bytes directive — plain ASCII" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "hello"
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, "hello", s.directives[0].bytes);
}

test "parse: bytes directive — escape set" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "a\eb\nc\td\\e\"f\r"
    );
    defer s.deinit();
    // Expected bytes: 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D
    try std.testing.expectEqualSlices(
        u8,
        &[_]u8{ 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D },
        s.directives[0].bytes,
    );
}

test "parse: bytes directive — unclosed string fails" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "no-close
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: bytes directive — unknown escape fails" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes "bad \q escape"
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "escape") != null);
}

test "parse: bytes-hex directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex 1B 5B 35 20 71
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0x1B, 0x5B, 0x35, 0x20, 0x71 }, s.directives[0].bytes);
}

test "parse: bytes-hex accepts mixed case" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex aB cD eF
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0xAB, 0xCD, 0xEF }, s.directives[0].bytes);
}

test "parse: bytes-hex rejects odd-length token" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex 1B 5
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: bytes-hex rejects non-hex characters" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\bytes-hex 1B ZZ
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: capture directive — label stored" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture on-phase
    );
    defer s.deinit();
    try std.testing.expectEqualSlices(u8, "on-phase", s.directives[0].capture);
}

test "parse: capture directive — empty label fails" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\capture
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: assert-cell directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 5 3 cursor-bar-at
    );
    defer s.deinit();
    const a = s.directives[0].assert_cell;
    try std.testing.expectEqual(@as(u16, 5), a.row);
    try std.testing.expectEqual(@as(u16, 3), a.col);
    try std.testing.expectEqual(Predicate.cursor_bar_at, a.pred);
}

test "parse: assert-cell-at directive" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell-at on-phase 5 3 cursor-block-at
    );
    defer s.deinit();
    const a = s.directives[0].assert_cell_at;
    try std.testing.expectEqualSlices(u8, "on-phase", a.label);
    try std.testing.expectEqual(@as(u16, 5), a.row);
    try std.testing.expectEqual(@as(u16, 3), a.col);
    try std.testing.expectEqual(Predicate.cursor_block_at, a.pred);
}

test "parse: all five predicates round-trip through assert-cell" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 0 0 cell-matches-golden
        \\assert-cell 0 0 cursor-block-at
        \\assert-cell 0 0 cursor-bar-at
        \\assert-cell 0 0 cursor-underline-at
        \\assert-cell 0 0 cell-empty
    );
    defer s.deinit();
    try std.testing.expectEqual(Predicate.cell_matches_golden, s.directives[0].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cursor_block_at, s.directives[1].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cursor_bar_at, s.directives[2].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cursor_underline_at, s.directives[3].assert_cell.pred);
    try std.testing.expectEqual(Predicate.cell_empty, s.directives[4].assert_cell.pred);
}

test "parse: unknown predicate fails at line" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 0 0 totally-bogus
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: size 0 0 rejected" {
    const diag = try parseErr(
        \\size 0 0
        \\timeout 500ms
    );
    try std.testing.expectEqual(@as(usize, 1), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "non-zero") != null);
}

test "parse: assert-cell row out of bounds" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\assert-cell 24 0 cell-empty
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "out of bounds") != null);
}

test "parse: assert-cell-at col out of bounds" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\assert-cell-at foo 0 80 cell-empty
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "out of bounds") != null);
}

test "parse: bytes-hex rejects empty token list" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\bytes-hex
    );
    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,
    AssertCellWithoutCapture,
    PredicateOnMissingLabel,
    CallbackFailed,
} || std.mem.Allocator.Error;

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
    last_capture_label: ?[]const u8, // most recently captured label (owned by alloc)
    cell_geom: CellGeom, // renderer cell dimensions for assert predicates
    alloc: std.mem.Allocator,

    pub fn init(
        alloc: std.mem.Allocator,
        scenario: *const Scenario,
        origin_ns: i128,
        cell_geom: CellGeom,
    ) 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 = .{},
            .last_capture_label = null,
            .cell_geom = cell_geom,
            .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);
        if (self.last_capture_label) |lbl| self.alloc.free(lbl);
    }

    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 = 2 * blink_period_ns;
                        if (now_ns - started > blink_timeout_ns) {
                            return error.SleepUntilFlipTimeout;
                        }
                        return .working;
                    }
                },
                .bytes => |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 = io.capture(io.ctx, label) catch |err| {
                        std.log.warn("scenario: capture callback failed: {s}", .{@errorName(err)});
                        return error.CallbackFailed;
                    };
                    errdefer self.alloc.free(img.pixels);

                    // Dupe label for both the map key and last_capture_label. Do both
                    // allocs BEFORE committing to the map so any OOM unwinds cleanly.
                    const key_dup = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(key_dup);
                    const new_last = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(new_last);

                    // Reserve the map slot. getOrPut may OOM; errdefers above handle it.
                    const gop = try self.captures.getOrPut(self.alloc, key_dup);

                    // Past here: no more OOM-able operations — commit everything atomically.
                    if (gop.found_existing) {
                        // Free old pixel buffer and our freshly-allocated key_dup;
                        // the map retains the existing key slot.
                        self.alloc.free(gop.value_ptr.pixels);
                        self.alloc.free(key_dup);
                    }
                    gop.value_ptr.* = img;

                    if (self.last_capture_label) |old_last| self.alloc.free(old_last);
                    self.last_capture_label = new_last;

                    self.cursor += 1;
                },
                .assert_cell => |ac| {
                    const lbl = self.last_capture_label orelse {
                        std.debug.print("scenario: assert-cell with no prior capture\n", .{});
                        return error.AssertCellWithoutCapture;
                    };
                    const img = self.captures.get(lbl).?;
                    const result = evalPredicate(img, ac.row, ac.col, self.cell_geom, ac.pred, null);
                    if (!result.pass) {
                        std.debug.print("scenario: assert-cell ({d},{d}) {s} failed: {s}\n", .{
                            ac.row, ac.col, @tagName(ac.pred), result.reason,
                        });
                        return error.AssertFailed;
                    }
                    self.cursor += 1;
                },
                .assert_cell_at => |aca| {
                    const img = self.captures.get(aca.label) orelse {
                        return error.PredicateOnMissingLabel;
                    };
                    const result = evalPredicate(img, aca.row, aca.col, self.cell_geom, aca.pred, null);
                    if (!result.pass) {
                        std.debug.print("scenario: assert-cell-at {s} ({d},{d}) {s} failed: {s}\n", .{
                            aca.label, aca.row, aca.col, @tagName(aca.pred), result.reason,
                        });
                        return error.AssertFailed;
                    }
                    self.cursor += 1;
                },
            }
        }

        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 640×384 all-white PNG (80 cols × 8px, 24 rows × 16px).
        // Large enough for predicate tests on any cell in the 80×24 grid.
        const w: u32 = 640;
        const h: u32 = 384;
        const pixels = try self.alloc.alloc(u8, @as(usize, w) * h * 4);
        @memset(pixels, 255);
        return .{ .width = w, .height = h, .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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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(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, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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);
}

// ---------------------------------------------------------------
// Cell-region predicate evaluator
// ---------------------------------------------------------------

pub const EvalResult = struct {
    pass: bool,
    /// Short diagnostic for failure case (static string where possible).
    reason: []const u8 = "",
};

pub const CellGeom = struct {
    cell_w_px: u32,
    cell_h_px: u32,
};

/// Pixel brightness in [0, 1]: mean of R, G, B channels.
fn pixelBrightness(r: u8, g: u8, b: u8) f64 {
    return (@as(f64, @floatFromInt(r)) + @as(f64, @floatFromInt(g)) + @as(f64, @floatFromInt(b))) / (3.0 * 255.0);
}

/// `golden_for_cell_matches` is only consulted by `cell_matches_golden`;
/// callers may pass null otherwise. When null and pred needs it, result
/// is pass=false with reason="missing golden".
pub fn evalPredicate(
    image: png.Image,
    row: u16,
    col: u16,
    geom: CellGeom,
    pred: Predicate,
    golden_for_cell_matches: ?png.Image,
) EvalResult {
    const x0: u32 = @as(u32, col) * geom.cell_w_px;
    const y0: u32 = @as(u32, row) * geom.cell_h_px;

    // Bounds check: if cell rect extends past image, fail gracefully.
    if (x0 + geom.cell_w_px > image.width or y0 + geom.cell_h_px > image.height) {
        return .{ .pass = false, .reason = "cell out of bounds" };
    }

    switch (pred) {
        .cell_empty => {
            // Pass if every pixel in the cell has brightness ≤ 0.1.
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                    if (brightness > 0.1) {
                        return .{ .pass = false, .reason = "bright pixel in supposedly empty cell" };
                    }
                }
            }
            return .{ .pass = true };
        },
        .cursor_block_at => {
            // Pass if mean brightness over the cell rect is ≥ 0.5.
            var sum: f64 = 0;
            const total_px: u32 = geom.cell_w_px * geom.cell_h_px;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    sum += pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                }
            }
            const mean = sum / @as(f64, @floatFromInt(total_px));
            if (mean >= 0.5) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "cell too dark for block cursor" };
        },
        .cursor_bar_at => {
            // Pass if centroid x of bright pixels (brightness ≥ 0.5) is in left third
            // AND bright pixel count ≥ cell_h_px * 2 (matching 2px bar width).
            var bright_count: u32 = 0;
            var centroid_x_sum: f64 = 0;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                    if (brightness >= 0.5) {
                        bright_count += 1;
                        // x relative to cell origin
                        centroid_x_sum += @as(f64, @floatFromInt(x - x0));
                    }
                }
            }
            const threshold: u32 = geom.cell_h_px * 2;
            if (bright_count < threshold) {
                return .{ .pass = false, .reason = "bar cursor centroid not at cell left" };
            }
            const centroid_x = centroid_x_sum / @as(f64, @floatFromInt(bright_count));
            const left_third = @as(f64, @floatFromInt(geom.cell_w_px)) / 3.0;
            if (centroid_x < left_third) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "bar cursor centroid not at cell left" };
        },
        .cursor_underline_at => {
            // Pass if centroid y of bright pixels is in bottom third
            // AND bright pixel count ≥ cell_w_px * 2.
            var bright_count: u32 = 0;
            var centroid_y_sum: f64 = 0;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                    if (brightness >= 0.5) {
                        bright_count += 1;
                        // y relative to cell origin
                        centroid_y_sum += @as(f64, @floatFromInt(y - y0));
                    }
                }
            }
            const threshold: u32 = geom.cell_w_px * 2;
            if (bright_count < threshold) {
                return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" };
            }
            const centroid_y = centroid_y_sum / @as(f64, @floatFromInt(bright_count));
            const bottom_third_start = @as(f64, @floatFromInt(geom.cell_h_px)) * 2.0 / 3.0;
            if (centroid_y >= bottom_third_start) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" };
        },
        .cell_matches_golden => {
            const golden = golden_for_cell_matches orelse {
                return .{ .pass = false, .reason = "missing golden" };
            };
            if (golden.width != image.width or golden.height != image.height) {
                return .{ .pass = false, .reason = "golden size mismatch" };
            }
            // Compute RMSE over the cell rect inline.
            var sum_sq: f64 = 0;
            const total_px: u32 = geom.cell_w_px * geom.cell_h_px;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    // Also bounds-check golden image.
                    if (off + 3 >= golden.pixels.len) {
                        return .{ .pass = false, .reason = "golden image too small for cell rect" };
                    }
                    const dr = (@as(f64, @floatFromInt(image.pixels[off + 0])) - @as(f64, @floatFromInt(golden.pixels[off + 0]))) / 255.0;
                    const dg = (@as(f64, @floatFromInt(image.pixels[off + 1])) - @as(f64, @floatFromInt(golden.pixels[off + 1]))) / 255.0;
                    const db = (@as(f64, @floatFromInt(image.pixels[off + 2])) - @as(f64, @floatFromInt(golden.pixels[off + 2]))) / 255.0;
                    sum_sq += (dr * dr + dg * dg + db * db) / 3.0;
                }
            }
            const rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(total_px)));
            if (rmse <= imgdiff.RMSE_DEFAULT) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "cell RMSE exceeds threshold" };
        },
    }
}

// ---------------------------------------------------------------
// Predicate evaluator tests
// ---------------------------------------------------------------

/// Build a width×height PNG where every pixel is `color` (RGBA).
fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image {
    const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4);
    var i: usize = 0;
    while (i < pixels.len) : (i += 4) {
        pixels[i + 0] = color[0];
        pixels[i + 1] = color[1];
        pixels[i + 2] = color[2];
        pixels[i + 3] = color[3];
    }
    return .{ .width = w, .height = h, .pixels = pixels };
}

fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void {
    const start_x: u32 = @as(u32, col) * geom.cell_w_px;
    const start_y: u32 = @as(u32, row) * geom.cell_h_px;
    var y: u32 = start_y;
    while (y < start_y + geom.cell_h_px) : (y += 1) {
        var x: u32 = start_x;
        while (x < start_x + geom.cell_w_px) : (x += 1) {
            const off = (@as(usize, y) * img.width + x) * 4;
            img.pixels[off + 0] = color[0];
            img.pixels[off + 1] = color[1];
            img.pixels[off + 2] = color[2];
            img.pixels[off + 3] = color[3];
        }
    }
}

test "evalPredicate: cell out of bounds returns pass=false" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 8, 16, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Cell (0,1) would require x=8..16 but image is only 8 wide.
    const r = evalPredicate(img, 0, 1, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "out of bounds") != null);
}

test "evalPredicate: cell-empty passes on all-black image" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-empty fails on image with bright cell" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Make cell (0,0)'s first pixel bright.
    img.pixels[0] = 255;
    img.pixels[1] = 255;
    img.pixels[2] = 255;
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-block-at passes when cell is mostly bright" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Fill cell (0,0)'s 8x16 rect with white.
    fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 });
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cursor-block-at fails when cell is dark" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the leftmost 2 pixels of cell (0,0), full height.
    var y: u32 = 0;
    while (y < 16) : (y += 1) {
        var x: u32 = 0;
        while (x < 2) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the rightmost 2 pixels of cell (0,0), full height.
    var y: u32 = 0;
    while (y < 16) : (y += 1) {
        var x: u32 = 6;
        while (x < 8) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the bottom 2 rows of cell (0,0), full width.
    var y: u32 = 14;
    while (y < 16) : (y += 1) {
        var x: u32 = 0;
        while (x < 8) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-matches-golden passes when images match in the cell rect" {
    const alloc = std.testing.allocator;
    // 640×384: 80 cols × 8px wide, 24 rows × 16px tall — enough for cell (5,3).
    const img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(golden.pixels);
    const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(golden.pixels);
    // Corrupt cell (5,3) in img.
    fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 });
    const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null);
}

test "evalPredicate: cell-matches-golden fails when golden has different dimensions" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const golden = try makeSolid(alloc, 40, 24, .{ 10, 20, 30, 255 }); // half-width
    defer alloc.free(golden.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "size mismatch") != null);
}

test "evalPredicate: cell-matches-golden fails when golden height differs" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const golden = try makeSolid(alloc, 80, 12, .{ 10, 20, 30, 255 }); // half-height
    defer alloc.free(golden.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "size mismatch") != null);
}

// End-to-end tests: parse + state + predicate

test "tick+eval: assert-cell evaluates against last capture" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap
        \\assert-cell 0 0 cursor-block-at
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

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

    // TestIO.captureCb returns a 640×384 all-white PNG; cursor_block_at on cell (0,0)
    // will pass because mean brightness of an all-white cell is 1.0 >= 0.5.
    const r = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r);
}

test "tick+eval: assert-cell-at on missing label errors" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell-at nope 0 0 cell-empty
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

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

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

test "tick: duplicate capture label frees the prior image" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap
        \\capture snap
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    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());
    // Testing allocator will catch any leak here on deinit.
}

test "tick: assert-cell with no prior capture errors distinctly" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 0 0 cell-empty
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

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

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