a73x

4e65a5d1

scenario: add parser for the scenario runner DSL

a73x   2026-04-19 09:16

Parses size/timeout header + sleep/sleep-until-flip/bytes/
bytes-hex/capture/assert-cell/assert-cell-at directives. Errors
return a Diagnostic with line number and static message.

Pure — no I/O, no state machine yet. Plan 2 Task 2 builds on this.

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

diff --git a/build.zig b/build.zig
index 46cbc6a..880ec3a 100644
--- a/build.zig
+++ b/build.zig
@@ -382,6 +382,25 @@ pub fn build(b: *std.Build) void {
    const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod });
    test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step);

    // scenario — DSL parser, state machine, predicate evaluator (pure)
    const scenario_mod = b.createModule(.{
        .root_source_file = b.path("src/scenario.zig"),
        .target = target,
        .optimize = optimize,
    });
    scenario_mod.addImport("png", png_mod);
    scenario_mod.addImport("imgdiff", imgdiff_lib_mod);

    const scenario_test_mod = b.createModule(.{
        .root_source_file = b.path("src/scenario.zig"),
        .target = target,
        .optimize = optimize,
    });
    scenario_test_mod.addImport("png", png_mod);
    scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod);
    const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod });
    test_step.dependOn(&b.addRunArtifact(scenario_tests).step);

    // imgdiff — standalone PNG comparison CLI
    const imgdiff_mod = b.createModule(.{
        .root_source_file = b.path("src/tools/imgdiff.zig"),
diff --git a/src/scenario.zig b/src/scenario.zig
new file mode 100644
index 0000000..2d22461
--- /dev/null
+++ b/src/scenario.zig
@@ -0,0 +1,699 @@
//! 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");

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