4e65a5d1
scenario: add parser for the scenario runner DSL
a73x 2026-04-19 09:16
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -382,6 +382,25 @@ pub fn build(b: *std.Build) void { | |||
| 382 | const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod }); | 382 | const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod }); |
| 383 | test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step); | 383 | test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step); |
| 384 | 384 | ||
| 385 | // scenario — DSL parser, state machine, predicate evaluator (pure) | ||
| 386 | const scenario_mod = b.createModule(.{ | ||
| 387 | .root_source_file = b.path("src/scenario.zig"), | ||
| 388 | .target = target, | ||
| 389 | .optimize = optimize, | ||
| 390 | }); | ||
| 391 | scenario_mod.addImport("png", png_mod); | ||
| 392 | scenario_mod.addImport("imgdiff", imgdiff_lib_mod); | ||
| 393 | |||
| 394 | const scenario_test_mod = b.createModule(.{ | ||
| 395 | .root_source_file = b.path("src/scenario.zig"), | ||
| 396 | .target = target, | ||
| 397 | .optimize = optimize, | ||
| 398 | }); | ||
| 399 | scenario_test_mod.addImport("png", png_mod); | ||
| 400 | scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod); | ||
| 401 | const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod }); | ||
| 402 | test_step.dependOn(&b.addRunArtifact(scenario_tests).step); | ||
| 403 | |||
| 385 | // imgdiff — standalone PNG comparison CLI | 404 | // imgdiff — standalone PNG comparison CLI |
| 386 | const imgdiff_mod = b.createModule(.{ | 405 | const imgdiff_mod = b.createModule(.{ |
| 387 | .root_source_file = b.path("src/tools/imgdiff.zig"), | 406 | .root_source_file = b.path("src/tools/imgdiff.zig"), |
src/scenario.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,699 @@ | |||
| 1 | //! Scenario runner — parser, state machine, and cell predicate evaluator. | ||
| 2 | //! | ||
| 3 | //! All APIs in this file are pure. Side-effectful work (pty writes, | ||
| 4 | //! offscreen renders) is represented as callbacks that the caller | ||
| 5 | //! supplies. See docs/superpowers/specs/2026-04-19-scenario-runner-design.md. | ||
| 6 | |||
| 7 | const std = @import("std"); | ||
| 8 | const png = @import("png"); | ||
| 9 | const imgdiff = @import("imgdiff"); | ||
| 10 | |||
| 11 | // --------------------------------------------------------------- | ||
| 12 | // Directive types | ||
| 13 | // --------------------------------------------------------------- | ||
| 14 | |||
| 15 | pub const Predicate = enum { | ||
| 16 | cell_matches_golden, | ||
| 17 | cursor_block_at, | ||
| 18 | cursor_bar_at, | ||
| 19 | cursor_underline_at, | ||
| 20 | cell_empty, | ||
| 21 | }; | ||
| 22 | |||
| 23 | pub const Directive = union(enum) { | ||
| 24 | sleep: u64, // ms | ||
| 25 | sleep_until_flip, | ||
| 26 | bytes: []const u8, // owned by Scenario.arena | ||
| 27 | capture: []const u8, // label, owned by Scenario.arena | ||
| 28 | assert_cell: AssertCell, | ||
| 29 | assert_cell_at: AssertCellAt, | ||
| 30 | |||
| 31 | pub const AssertCell = struct { | ||
| 32 | row: u16, | ||
| 33 | col: u16, | ||
| 34 | pred: Predicate, | ||
| 35 | }; | ||
| 36 | pub const AssertCellAt = struct { | ||
| 37 | label: []const u8, // owned by Scenario.arena | ||
| 38 | row: u16, | ||
| 39 | col: u16, | ||
| 40 | pred: Predicate, | ||
| 41 | }; | ||
| 42 | }; | ||
| 43 | |||
| 44 | pub const Scenario = struct { | ||
| 45 | cols: u16, | ||
| 46 | rows: u16, | ||
| 47 | timeout_ms: u64, | ||
| 48 | directives: []const Directive, | ||
| 49 | |||
| 50 | // Owns directive payload slices. | ||
| 51 | arena: std.heap.ArenaAllocator, | ||
| 52 | |||
| 53 | pub fn deinit(self: *Scenario) void { | ||
| 54 | self.arena.deinit(); | ||
| 55 | } | ||
| 56 | }; | ||
| 57 | |||
| 58 | pub const Diagnostic = struct { | ||
| 59 | line: usize = 0, | ||
| 60 | message: []const u8 = "", // static string | ||
| 61 | }; | ||
| 62 | |||
| 63 | pub const ParseError = error{ParseFailed} || std.mem.Allocator.Error; | ||
| 64 | |||
| 65 | // --------------------------------------------------------------- | ||
| 66 | // Parser implementation | ||
| 67 | // --------------------------------------------------------------- | ||
| 68 | |||
| 69 | fn parsePredicate(token: []const u8) ?Predicate { | ||
| 70 | if (std.mem.eql(u8, token, "cell-matches-golden")) return .cell_matches_golden; | ||
| 71 | if (std.mem.eql(u8, token, "cursor-block-at")) return .cursor_block_at; | ||
| 72 | if (std.mem.eql(u8, token, "cursor-bar-at")) return .cursor_bar_at; | ||
| 73 | if (std.mem.eql(u8, token, "cursor-underline-at")) return .cursor_underline_at; | ||
| 74 | if (std.mem.eql(u8, token, "cell-empty")) return .cell_empty; | ||
| 75 | return null; | ||
| 76 | } | ||
| 77 | |||
| 78 | /// Parse a duration token of the form "NNms" or "NNs". Returns ms. | ||
| 79 | fn parseDuration(token: []const u8, diag: *Diagnostic, line: usize) ParseError!u64 { | ||
| 80 | if (std.mem.endsWith(u8, token, "ms")) { | ||
| 81 | const n = std.fmt.parseInt(u64, token[0 .. token.len - 2], 10) catch { | ||
| 82 | diag.line = line; | ||
| 83 | diag.message = "invalid sleep duration"; | ||
| 84 | return error.ParseFailed; | ||
| 85 | }; | ||
| 86 | return n; | ||
| 87 | } else if (std.mem.endsWith(u8, token, "s")) { | ||
| 88 | const n = std.fmt.parseInt(u64, token[0 .. token.len - 1], 10) catch { | ||
| 89 | diag.line = line; | ||
| 90 | diag.message = "invalid sleep duration"; | ||
| 91 | return error.ParseFailed; | ||
| 92 | }; | ||
| 93 | return n * 1000; | ||
| 94 | } else { | ||
| 95 | diag.line = line; | ||
| 96 | diag.message = "sleep duration must end in 'ms' or 's'"; | ||
| 97 | return error.ParseFailed; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | /// Parse a double-quoted string with escape sequences. | ||
| 102 | /// Escape set: \n \t \r \e (→ 0x1B) \\ \" | ||
| 103 | fn parseQuotedString( | ||
| 104 | arena_alloc: std.mem.Allocator, | ||
| 105 | token: []const u8, | ||
| 106 | diag: *Diagnostic, | ||
| 107 | line: usize, | ||
| 108 | ) ParseError![]const u8 { | ||
| 109 | if (token.len < 2 or token[0] != '"' or token[token.len - 1] != '"') { | ||
| 110 | diag.line = line; | ||
| 111 | diag.message = "bytes string must be double-quoted"; | ||
| 112 | return error.ParseFailed; | ||
| 113 | } | ||
| 114 | const inner = token[1 .. token.len - 1]; | ||
| 115 | var buf: std.ArrayList(u8) = .empty; | ||
| 116 | var i: usize = 0; | ||
| 117 | while (i < inner.len) { | ||
| 118 | if (inner[i] == '\\') { | ||
| 119 | if (i + 1 >= inner.len) { | ||
| 120 | diag.line = line; | ||
| 121 | diag.message = "trailing backslash in bytes string"; | ||
| 122 | return error.ParseFailed; | ||
| 123 | } | ||
| 124 | const ch = inner[i + 1]; | ||
| 125 | switch (ch) { | ||
| 126 | 'n' => try buf.append(arena_alloc, '\n'), | ||
| 127 | 't' => try buf.append(arena_alloc, '\t'), | ||
| 128 | 'r' => try buf.append(arena_alloc, '\r'), | ||
| 129 | 'e' => try buf.append(arena_alloc, 0x1B), | ||
| 130 | '\\' => try buf.append(arena_alloc, '\\'), | ||
| 131 | '"' => try buf.append(arena_alloc, '"'), | ||
| 132 | else => { | ||
| 133 | diag.line = line; | ||
| 134 | diag.message = "unknown escape sequence in bytes string"; | ||
| 135 | return error.ParseFailed; | ||
| 136 | }, | ||
| 137 | } | ||
| 138 | i += 2; | ||
| 139 | } else { | ||
| 140 | try buf.append(arena_alloc, inner[i]); | ||
| 141 | i += 1; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | return buf.toOwnedSlice(arena_alloc); | ||
| 145 | } | ||
| 146 | |||
| 147 | /// Split a line into whitespace-separated tokens. | ||
| 148 | /// Handles the special case of a quoted string as the second token for `bytes`. | ||
| 149 | fn nextToken(s: []const u8, start: usize) ?struct { tok: []const u8, end: usize } { | ||
| 150 | var i = start; | ||
| 151 | // skip leading whitespace | ||
| 152 | while (i < s.len and (s[i] == ' ' or s[i] == '\t')) i += 1; | ||
| 153 | if (i >= s.len) return null; | ||
| 154 | const tok_start = i; | ||
| 155 | if (s[i] == '"') { | ||
| 156 | // consume until closing quote (no nesting) | ||
| 157 | i += 1; | ||
| 158 | while (i < s.len and s[i] != '"') { | ||
| 159 | if (s[i] == '\\') i += 1; // skip escaped char | ||
| 160 | if (i < s.len) i += 1; | ||
| 161 | } | ||
| 162 | if (i < s.len) i += 1; // consume closing quote | ||
| 163 | return .{ .tok = s[tok_start..i], .end = i }; | ||
| 164 | } else { | ||
| 165 | while (i < s.len and s[i] != ' ' and s[i] != '\t') i += 1; | ||
| 166 | return .{ .tok = s[tok_start..i], .end = i }; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | pub fn parse( | ||
| 171 | gpa: std.mem.Allocator, | ||
| 172 | source: []const u8, | ||
| 173 | diag: *Diagnostic, | ||
| 174 | ) ParseError!Scenario { | ||
| 175 | var arena = std.heap.ArenaAllocator.init(gpa); | ||
| 176 | errdefer arena.deinit(); | ||
| 177 | const alloc = arena.allocator(); | ||
| 178 | |||
| 179 | var directives: std.ArrayList(Directive) = .empty; | ||
| 180 | |||
| 181 | var has_size = false; | ||
| 182 | var has_timeout = false; | ||
| 183 | var cols: u16 = 0; | ||
| 184 | var rows: u16 = 0; | ||
| 185 | var timeout_ms: u64 = 0; | ||
| 186 | |||
| 187 | var lines = std.mem.splitScalar(u8, source, '\n'); | ||
| 188 | var line_num: usize = 0; | ||
| 189 | |||
| 190 | while (lines.next()) |raw_line| { | ||
| 191 | line_num += 1; | ||
| 192 | const line = std.mem.trim(u8, raw_line, " \t\r"); | ||
| 193 | |||
| 194 | // Skip blank lines and comments | ||
| 195 | if (line.len == 0 or line[0] == '#') continue; | ||
| 196 | |||
| 197 | // Get first token (directive keyword) | ||
| 198 | const kw_res = nextToken(line, 0) orelse continue; | ||
| 199 | const kw = kw_res.tok; | ||
| 200 | var pos = kw_res.end; | ||
| 201 | |||
| 202 | if (std.mem.eql(u8, kw, "size")) { | ||
| 203 | if (has_timeout) { | ||
| 204 | diag.line = line_num; | ||
| 205 | diag.message = "size directive must appear before timeout"; | ||
| 206 | return error.ParseFailed; | ||
| 207 | } | ||
| 208 | if (has_size) { | ||
| 209 | diag.line = line_num; | ||
| 210 | diag.message = "duplicate size directive"; | ||
| 211 | return error.ParseFailed; | ||
| 212 | } | ||
| 213 | const cols_res = nextToken(line, pos) orelse { | ||
| 214 | diag.line = line_num; | ||
| 215 | diag.message = "size directive requires cols and rows"; | ||
| 216 | return error.ParseFailed; | ||
| 217 | }; | ||
| 218 | pos = cols_res.end; | ||
| 219 | const rows_res = nextToken(line, pos) orelse { | ||
| 220 | diag.line = line_num; | ||
| 221 | diag.message = "size directive requires cols and rows"; | ||
| 222 | return error.ParseFailed; | ||
| 223 | }; | ||
| 224 | cols = std.fmt.parseInt(u16, cols_res.tok, 10) catch { | ||
| 225 | diag.line = line_num; | ||
| 226 | diag.message = "size cols must be a positive integer"; | ||
| 227 | return error.ParseFailed; | ||
| 228 | }; | ||
| 229 | rows = std.fmt.parseInt(u16, rows_res.tok, 10) catch { | ||
| 230 | diag.line = line_num; | ||
| 231 | diag.message = "size rows must be a positive integer"; | ||
| 232 | return error.ParseFailed; | ||
| 233 | }; | ||
| 234 | has_size = true; | ||
| 235 | } else if (std.mem.eql(u8, kw, "timeout")) { | ||
| 236 | if (!has_size) { | ||
| 237 | diag.line = line_num; | ||
| 238 | diag.message = "expected size directive first"; | ||
| 239 | return error.ParseFailed; | ||
| 240 | } | ||
| 241 | if (has_timeout) { | ||
| 242 | diag.line = line_num; | ||
| 243 | diag.message = "duplicate timeout directive"; | ||
| 244 | return error.ParseFailed; | ||
| 245 | } | ||
| 246 | const dur_res = nextToken(line, pos) orelse { | ||
| 247 | diag.line = line_num; | ||
| 248 | diag.message = "timeout directive requires a duration"; | ||
| 249 | return error.ParseFailed; | ||
| 250 | }; | ||
| 251 | timeout_ms = try parseDuration(dur_res.tok, diag, line_num); | ||
| 252 | has_timeout = true; | ||
| 253 | } else { | ||
| 254 | // Body directives require size + timeout first | ||
| 255 | if (!has_size) { | ||
| 256 | diag.line = line_num; | ||
| 257 | diag.message = "expected size directive first"; | ||
| 258 | return error.ParseFailed; | ||
| 259 | } | ||
| 260 | if (!has_timeout) { | ||
| 261 | diag.line = line_num; | ||
| 262 | diag.message = "expected timeout directive before body directives"; | ||
| 263 | return error.ParseFailed; | ||
| 264 | } | ||
| 265 | |||
| 266 | if (std.mem.eql(u8, kw, "sleep")) { | ||
| 267 | const dur_res = nextToken(line, pos) orelse { | ||
| 268 | diag.line = line_num; | ||
| 269 | diag.message = "sleep directive requires a duration"; | ||
| 270 | return error.ParseFailed; | ||
| 271 | }; | ||
| 272 | const ms = try parseDuration(dur_res.tok, diag, line_num); | ||
| 273 | try directives.append(alloc, .{ .sleep = ms }); | ||
| 274 | } else if (std.mem.eql(u8, kw, "sleep-until-flip")) { | ||
| 275 | try directives.append(alloc, .sleep_until_flip); | ||
| 276 | } else if (std.mem.eql(u8, kw, "bytes")) { | ||
| 277 | const str_res = nextToken(line, pos) orelse { | ||
| 278 | diag.line = line_num; | ||
| 279 | diag.message = "bytes directive requires a quoted string"; | ||
| 280 | return error.ParseFailed; | ||
| 281 | }; | ||
| 282 | // Verify it starts with a quote; if not, the string was unquoted/unterminated | ||
| 283 | if (str_res.tok.len == 0 or str_res.tok[0] != '"') { | ||
| 284 | diag.line = line_num; | ||
| 285 | diag.message = "bytes string must be double-quoted"; | ||
| 286 | return error.ParseFailed; | ||
| 287 | } | ||
| 288 | // Check for unclosed quote: the token should end with '"' too | ||
| 289 | if (str_res.tok.len < 2 or str_res.tok[str_res.tok.len - 1] != '"') { | ||
| 290 | diag.line = line_num; | ||
| 291 | diag.message = "unterminated string in bytes directive"; | ||
| 292 | return error.ParseFailed; | ||
| 293 | } | ||
| 294 | const payload = try parseQuotedString(alloc, str_res.tok, diag, line_num); | ||
| 295 | try directives.append(alloc, .{ .bytes = payload }); | ||
| 296 | } else if (std.mem.eql(u8, kw, "bytes-hex")) { | ||
| 297 | var hex_buf: std.ArrayList(u8) = .empty; | ||
| 298 | var cur_pos = pos; | ||
| 299 | while (nextToken(line, cur_pos)) |tok_res| { | ||
| 300 | cur_pos = tok_res.end; | ||
| 301 | const tok = tok_res.tok; | ||
| 302 | if (tok.len != 2) { | ||
| 303 | diag.line = line_num; | ||
| 304 | diag.message = "bytes-hex tokens must be exactly 2 hex characters"; | ||
| 305 | return error.ParseFailed; | ||
| 306 | } | ||
| 307 | const byte = std.fmt.parseInt(u8, tok, 16) catch { | ||
| 308 | diag.line = line_num; | ||
| 309 | diag.message = "bytes-hex token is not valid hexadecimal"; | ||
| 310 | return error.ParseFailed; | ||
| 311 | }; | ||
| 312 | try hex_buf.append(alloc, byte); | ||
| 313 | } | ||
| 314 | try directives.append(alloc, .{ .bytes = try hex_buf.toOwnedSlice(alloc) }); | ||
| 315 | } else if (std.mem.eql(u8, kw, "capture")) { | ||
| 316 | const lbl_res = nextToken(line, pos) orelse { | ||
| 317 | diag.line = line_num; | ||
| 318 | diag.message = "capture directive requires a label"; | ||
| 319 | return error.ParseFailed; | ||
| 320 | }; | ||
| 321 | const lbl = lbl_res.tok; | ||
| 322 | if (lbl.len == 0) { | ||
| 323 | diag.line = line_num; | ||
| 324 | diag.message = "capture label must not be empty"; | ||
| 325 | return error.ParseFailed; | ||
| 326 | } | ||
| 327 | // Validate label chars: [a-zA-Z0-9_-]+ | ||
| 328 | for (lbl) |ch| { | ||
| 329 | if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') { | ||
| 330 | diag.line = line_num; | ||
| 331 | diag.message = "capture label contains invalid characters"; | ||
| 332 | return error.ParseFailed; | ||
| 333 | } | ||
| 334 | } | ||
| 335 | const label_copy = try alloc.dupe(u8, lbl); | ||
| 336 | try directives.append(alloc, .{ .capture = label_copy }); | ||
| 337 | } else if (std.mem.eql(u8, kw, "assert-cell")) { | ||
| 338 | const row_res = nextToken(line, pos) orelse { | ||
| 339 | diag.line = line_num; | ||
| 340 | diag.message = "assert-cell requires row col predicate"; | ||
| 341 | return error.ParseFailed; | ||
| 342 | }; | ||
| 343 | pos = row_res.end; | ||
| 344 | const col_res = nextToken(line, pos) orelse { | ||
| 345 | diag.line = line_num; | ||
| 346 | diag.message = "assert-cell requires row col predicate"; | ||
| 347 | return error.ParseFailed; | ||
| 348 | }; | ||
| 349 | pos = col_res.end; | ||
| 350 | const pred_res = nextToken(line, pos) orelse { | ||
| 351 | diag.line = line_num; | ||
| 352 | diag.message = "assert-cell requires row col predicate"; | ||
| 353 | return error.ParseFailed; | ||
| 354 | }; | ||
| 355 | const row = std.fmt.parseInt(u16, row_res.tok, 10) catch { | ||
| 356 | diag.line = line_num; | ||
| 357 | diag.message = "assert-cell row must be an integer"; | ||
| 358 | return error.ParseFailed; | ||
| 359 | }; | ||
| 360 | const col = std.fmt.parseInt(u16, col_res.tok, 10) catch { | ||
| 361 | diag.line = line_num; | ||
| 362 | diag.message = "assert-cell col must be an integer"; | ||
| 363 | return error.ParseFailed; | ||
| 364 | }; | ||
| 365 | const pred = parsePredicate(pred_res.tok) orelse { | ||
| 366 | diag.line = line_num; | ||
| 367 | diag.message = "unknown predicate"; | ||
| 368 | return error.ParseFailed; | ||
| 369 | }; | ||
| 370 | try directives.append(alloc, .{ .assert_cell = .{ .row = row, .col = col, .pred = pred } }); | ||
| 371 | } else if (std.mem.eql(u8, kw, "assert-cell-at")) { | ||
| 372 | const lbl_res = nextToken(line, pos) orelse { | ||
| 373 | diag.line = line_num; | ||
| 374 | diag.message = "assert-cell-at requires label row col predicate"; | ||
| 375 | return error.ParseFailed; | ||
| 376 | }; | ||
| 377 | pos = lbl_res.end; | ||
| 378 | const row_res = nextToken(line, pos) orelse { | ||
| 379 | diag.line = line_num; | ||
| 380 | diag.message = "assert-cell-at requires label row col predicate"; | ||
| 381 | return error.ParseFailed; | ||
| 382 | }; | ||
| 383 | pos = row_res.end; | ||
| 384 | const col_res = nextToken(line, pos) orelse { | ||
| 385 | diag.line = line_num; | ||
| 386 | diag.message = "assert-cell-at requires label row col predicate"; | ||
| 387 | return error.ParseFailed; | ||
| 388 | }; | ||
| 389 | pos = col_res.end; | ||
| 390 | const pred_res = nextToken(line, pos) orelse { | ||
| 391 | diag.line = line_num; | ||
| 392 | diag.message = "assert-cell-at requires label row col predicate"; | ||
| 393 | return error.ParseFailed; | ||
| 394 | }; | ||
| 395 | const lbl = lbl_res.tok; | ||
| 396 | for (lbl) |ch| { | ||
| 397 | if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') { | ||
| 398 | diag.line = line_num; | ||
| 399 | diag.message = "assert-cell-at label contains invalid characters"; | ||
| 400 | return error.ParseFailed; | ||
| 401 | } | ||
| 402 | } | ||
| 403 | const label_copy = try alloc.dupe(u8, lbl); | ||
| 404 | const row = std.fmt.parseInt(u16, row_res.tok, 10) catch { | ||
| 405 | diag.line = line_num; | ||
| 406 | diag.message = "assert-cell-at row must be an integer"; | ||
| 407 | return error.ParseFailed; | ||
| 408 | }; | ||
| 409 | const col = std.fmt.parseInt(u16, col_res.tok, 10) catch { | ||
| 410 | diag.line = line_num; | ||
| 411 | diag.message = "assert-cell-at col must be an integer"; | ||
| 412 | return error.ParseFailed; | ||
| 413 | }; | ||
| 414 | const pred = parsePredicate(pred_res.tok) orelse { | ||
| 415 | diag.line = line_num; | ||
| 416 | diag.message = "unknown predicate"; | ||
| 417 | return error.ParseFailed; | ||
| 418 | }; | ||
| 419 | try directives.append(alloc, .{ .assert_cell_at = .{ | ||
| 420 | .label = label_copy, | ||
| 421 | .row = row, | ||
| 422 | .col = col, | ||
| 423 | .pred = pred, | ||
| 424 | } }); | ||
| 425 | } else { | ||
| 426 | diag.line = line_num; | ||
| 427 | diag.message = "unknown directive"; | ||
| 428 | return error.ParseFailed; | ||
| 429 | } | ||
| 430 | } | ||
| 431 | } | ||
| 432 | |||
| 433 | if (!has_size) { | ||
| 434 | diag.line = 1; | ||
| 435 | diag.message = "missing size directive"; | ||
| 436 | return error.ParseFailed; | ||
| 437 | } | ||
| 438 | if (!has_timeout) { | ||
| 439 | diag.line = line_num; | ||
| 440 | diag.message = "missing timeout directive"; | ||
| 441 | return error.ParseFailed; | ||
| 442 | } | ||
| 443 | |||
| 444 | return Scenario{ | ||
| 445 | .cols = cols, | ||
| 446 | .rows = rows, | ||
| 447 | .timeout_ms = timeout_ms, | ||
| 448 | .directives = try directives.toOwnedSlice(alloc), | ||
| 449 | .arena = arena, | ||
| 450 | }; | ||
| 451 | } | ||
| 452 | |||
| 453 | // --------------------------------------------------------------- | ||
| 454 | // Parser tests | ||
| 455 | // --------------------------------------------------------------- | ||
| 456 | |||
| 457 | fn parseOk(source: []const u8) !Scenario { | ||
| 458 | var diag: Diagnostic = .{}; | ||
| 459 | return parse(std.testing.allocator, source, &diag) catch |err| { | ||
| 460 | std.debug.print("unexpected parse error at line {}: {s}\n", .{ diag.line, diag.message }); | ||
| 461 | return err; | ||
| 462 | }; | ||
| 463 | } | ||
| 464 | |||
| 465 | fn parseErr(source: []const u8) !Diagnostic { | ||
| 466 | var diag: Diagnostic = .{}; | ||
| 467 | const result = parse(std.testing.allocator, source, &diag); | ||
| 468 | try std.testing.expectError(error.ParseFailed, result); | ||
| 469 | return diag; | ||
| 470 | } | ||
| 471 | |||
| 472 | test "parse: minimum valid scenario" { | ||
| 473 | var s = try parseOk( | ||
| 474 | \\size 80 24 | ||
| 475 | \\timeout 1000ms | ||
| 476 | ); | ||
| 477 | defer s.deinit(); | ||
| 478 | try std.testing.expectEqual(@as(u16, 80), s.cols); | ||
| 479 | try std.testing.expectEqual(@as(u16, 24), s.rows); | ||
| 480 | try std.testing.expectEqual(@as(u64, 1000), s.timeout_ms); | ||
| 481 | try std.testing.expectEqual(@as(usize, 0), s.directives.len); | ||
| 482 | } | ||
| 483 | |||
| 484 | test "parse: comments and blank lines ignored" { | ||
| 485 | var s = try parseOk( | ||
| 486 | \\# leading comment | ||
| 487 | \\ | ||
| 488 | \\size 80 24 | ||
| 489 | \\ # indented comment | ||
| 490 | \\timeout 500ms | ||
| 491 | \\ | ||
| 492 | ); | ||
| 493 | defer s.deinit(); | ||
| 494 | try std.testing.expectEqual(@as(u16, 80), s.cols); | ||
| 495 | } | ||
| 496 | |||
| 497 | test "parse: missing size directive fails at line 1" { | ||
| 498 | const diag = try parseErr( | ||
| 499 | \\timeout 500ms | ||
| 500 | ); | ||
| 501 | try std.testing.expectEqual(@as(usize, 1), diag.line); | ||
| 502 | try std.testing.expect(std.mem.indexOf(u8, diag.message, "size") != null); | ||
| 503 | } | ||
| 504 | |||
| 505 | test "parse: missing timeout directive fails" { | ||
| 506 | const diag = try parseErr( | ||
| 507 | \\size 80 24 | ||
| 508 | ); | ||
| 509 | try std.testing.expect(std.mem.indexOf(u8, diag.message, "timeout") != null); | ||
| 510 | } | ||
| 511 | |||
| 512 | test "parse: unknown directive fails at its line" { | ||
| 513 | const diag = try parseErr( | ||
| 514 | \\size 80 24 | ||
| 515 | \\timeout 500ms | ||
| 516 | \\what-is-this | ||
| 517 | ); | ||
| 518 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 519 | try std.testing.expect(std.mem.indexOf(u8, diag.message, "unknown directive") != null); | ||
| 520 | } | ||
| 521 | |||
| 522 | test "parse: sleep directive" { | ||
| 523 | var s = try parseOk( | ||
| 524 | \\size 80 24 | ||
| 525 | \\timeout 5000ms | ||
| 526 | \\sleep 250ms | ||
| 527 | \\sleep 1s | ||
| 528 | ); | ||
| 529 | defer s.deinit(); | ||
| 530 | try std.testing.expectEqual(@as(usize, 2), s.directives.len); | ||
| 531 | try std.testing.expectEqual(@as(u64, 250), s.directives[0].sleep); | ||
| 532 | try std.testing.expectEqual(@as(u64, 1000), s.directives[1].sleep); | ||
| 533 | } | ||
| 534 | |||
| 535 | test "parse: sleep-until-flip directive" { | ||
| 536 | var s = try parseOk( | ||
| 537 | \\size 80 24 | ||
| 538 | \\timeout 5000ms | ||
| 539 | \\sleep-until-flip | ||
| 540 | ); | ||
| 541 | defer s.deinit(); | ||
| 542 | try std.testing.expectEqual(@as(usize, 1), s.directives.len); | ||
| 543 | try std.testing.expect(s.directives[0] == .sleep_until_flip); | ||
| 544 | } | ||
| 545 | |||
| 546 | test "parse: bytes directive — plain ASCII" { | ||
| 547 | var s = try parseOk( | ||
| 548 | \\size 80 24 | ||
| 549 | \\timeout 5000ms | ||
| 550 | \\bytes "hello" | ||
| 551 | ); | ||
| 552 | defer s.deinit(); | ||
| 553 | try std.testing.expectEqualSlices(u8, "hello", s.directives[0].bytes); | ||
| 554 | } | ||
| 555 | |||
| 556 | test "parse: bytes directive — escape set" { | ||
| 557 | var s = try parseOk( | ||
| 558 | \\size 80 24 | ||
| 559 | \\timeout 5000ms | ||
| 560 | \\bytes "a\eb\nc\td\\e\"f\r" | ||
| 561 | ); | ||
| 562 | defer s.deinit(); | ||
| 563 | // Expected bytes: 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D | ||
| 564 | try std.testing.expectEqualSlices( | ||
| 565 | u8, | ||
| 566 | &[_]u8{ 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D }, | ||
| 567 | s.directives[0].bytes, | ||
| 568 | ); | ||
| 569 | } | ||
| 570 | |||
| 571 | test "parse: bytes directive — unclosed string fails" { | ||
| 572 | const diag = try parseErr( | ||
| 573 | \\size 80 24 | ||
| 574 | \\timeout 5000ms | ||
| 575 | \\bytes "no-close | ||
| 576 | ); | ||
| 577 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 578 | } | ||
| 579 | |||
| 580 | test "parse: bytes directive — unknown escape fails" { | ||
| 581 | const diag = try parseErr( | ||
| 582 | \\size 80 24 | ||
| 583 | \\timeout 5000ms | ||
| 584 | \\bytes "bad \q escape" | ||
| 585 | ); | ||
| 586 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 587 | try std.testing.expect(std.mem.indexOf(u8, diag.message, "escape") != null); | ||
| 588 | } | ||
| 589 | |||
| 590 | test "parse: bytes-hex directive" { | ||
| 591 | var s = try parseOk( | ||
| 592 | \\size 80 24 | ||
| 593 | \\timeout 5000ms | ||
| 594 | \\bytes-hex 1B 5B 35 20 71 | ||
| 595 | ); | ||
| 596 | defer s.deinit(); | ||
| 597 | try std.testing.expectEqualSlices(u8, &[_]u8{ 0x1B, 0x5B, 0x35, 0x20, 0x71 }, s.directives[0].bytes); | ||
| 598 | } | ||
| 599 | |||
| 600 | test "parse: bytes-hex accepts mixed case" { | ||
| 601 | var s = try parseOk( | ||
| 602 | \\size 80 24 | ||
| 603 | \\timeout 5000ms | ||
| 604 | \\bytes-hex aB cD eF | ||
| 605 | ); | ||
| 606 | defer s.deinit(); | ||
| 607 | try std.testing.expectEqualSlices(u8, &[_]u8{ 0xAB, 0xCD, 0xEF }, s.directives[0].bytes); | ||
| 608 | } | ||
| 609 | |||
| 610 | test "parse: bytes-hex rejects odd-length token" { | ||
| 611 | const diag = try parseErr( | ||
| 612 | \\size 80 24 | ||
| 613 | \\timeout 5000ms | ||
| 614 | \\bytes-hex 1B 5 | ||
| 615 | ); | ||
| 616 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 617 | } | ||
| 618 | |||
| 619 | test "parse: bytes-hex rejects non-hex characters" { | ||
| 620 | const diag = try parseErr( | ||
| 621 | \\size 80 24 | ||
| 622 | \\timeout 5000ms | ||
| 623 | \\bytes-hex 1B ZZ | ||
| 624 | ); | ||
| 625 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 626 | } | ||
| 627 | |||
| 628 | test "parse: capture directive — label stored" { | ||
| 629 | var s = try parseOk( | ||
| 630 | \\size 80 24 | ||
| 631 | \\timeout 5000ms | ||
| 632 | \\capture on-phase | ||
| 633 | ); | ||
| 634 | defer s.deinit(); | ||
| 635 | try std.testing.expectEqualSlices(u8, "on-phase", s.directives[0].capture); | ||
| 636 | } | ||
| 637 | |||
| 638 | test "parse: capture directive — empty label fails" { | ||
| 639 | const diag = try parseErr( | ||
| 640 | \\size 80 24 | ||
| 641 | \\timeout 5000ms | ||
| 642 | \\capture | ||
| 643 | ); | ||
| 644 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 645 | } | ||
| 646 | |||
| 647 | test "parse: assert-cell directive" { | ||
| 648 | var s = try parseOk( | ||
| 649 | \\size 80 24 | ||
| 650 | \\timeout 5000ms | ||
| 651 | \\assert-cell 5 3 cursor-bar-at | ||
| 652 | ); | ||
| 653 | defer s.deinit(); | ||
| 654 | const a = s.directives[0].assert_cell; | ||
| 655 | try std.testing.expectEqual(@as(u16, 5), a.row); | ||
| 656 | try std.testing.expectEqual(@as(u16, 3), a.col); | ||
| 657 | try std.testing.expectEqual(Predicate.cursor_bar_at, a.pred); | ||
| 658 | } | ||
| 659 | |||
| 660 | test "parse: assert-cell-at directive" { | ||
| 661 | var s = try parseOk( | ||
| 662 | \\size 80 24 | ||
| 663 | \\timeout 5000ms | ||
| 664 | \\assert-cell-at on-phase 5 3 cursor-block-at | ||
| 665 | ); | ||
| 666 | defer s.deinit(); | ||
| 667 | const a = s.directives[0].assert_cell_at; | ||
| 668 | try std.testing.expectEqualSlices(u8, "on-phase", a.label); | ||
| 669 | try std.testing.expectEqual(@as(u16, 5), a.row); | ||
| 670 | try std.testing.expectEqual(@as(u16, 3), a.col); | ||
| 671 | try std.testing.expectEqual(Predicate.cursor_block_at, a.pred); | ||
| 672 | } | ||
| 673 | |||
| 674 | test "parse: all five predicates round-trip through assert-cell" { | ||
| 675 | var s = try parseOk( | ||
| 676 | \\size 80 24 | ||
| 677 | \\timeout 5000ms | ||
| 678 | \\assert-cell 0 0 cell-matches-golden | ||
| 679 | \\assert-cell 0 0 cursor-block-at | ||
| 680 | \\assert-cell 0 0 cursor-bar-at | ||
| 681 | \\assert-cell 0 0 cursor-underline-at | ||
| 682 | \\assert-cell 0 0 cell-empty | ||
| 683 | ); | ||
| 684 | defer s.deinit(); | ||
| 685 | try std.testing.expectEqual(Predicate.cell_matches_golden, s.directives[0].assert_cell.pred); | ||
| 686 | try std.testing.expectEqual(Predicate.cursor_block_at, s.directives[1].assert_cell.pred); | ||
| 687 | try std.testing.expectEqual(Predicate.cursor_bar_at, s.directives[2].assert_cell.pred); | ||
| 688 | try std.testing.expectEqual(Predicate.cursor_underline_at, s.directives[3].assert_cell.pred); | ||
| 689 | try std.testing.expectEqual(Predicate.cell_empty, s.directives[4].assert_cell.pred); | ||
| 690 | } | ||
| 691 | |||
| 692 | test "parse: unknown predicate fails at line" { | ||
| 693 | const diag = try parseErr( | ||
| 694 | \\size 80 24 | ||
| 695 | \\timeout 5000ms | ||
| 696 | \\assert-cell 0 0 totally-bogus | ||
| 697 | ); | ||
| 698 | try std.testing.expectEqual(@as(usize, 3), diag.line); | ||
| 699 | } | ||