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