a73x

4e65a5d1

scenario: add parser for the scenario runner DSL

a73x   2026-04-19 09:16

Commit message
scenario: add parser for the scenario runner DSL

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>

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 }