a73x

a36f7122

scenario: add ScenarioState + tick state machine

a73x   2026-04-19 09:22

Commit message
scenario: add ScenarioState + tick state machine

Tick advances the directive cursor on a monotonic timeline,
invoking caller-supplied callbacks for bytes/capture/flip.
sleep-until-flip rendezvouses with the blink timer via a
caller-observed flag; times out at 2x blink period.

Assert-cell predicates still stubbed — Task 3 wires the
predicate evaluator.

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

src/scenario.zig
Old New
@@ -752,3 +752,332 @@ test "parse: bytes-hex rejects empty token list" {
752 try std.testing.expectEqual(@as(usize, 3), diag.line); 752 try std.testing.expectEqual(@as(usize, 3), diag.line);
753 try std.testing.expect(std.mem.indexOf(u8, diag.message, "at least one token") != null); 753 try std.testing.expect(std.mem.indexOf(u8, diag.message, "at least one token") != null);
754 } 754 }
755
756 // ---------------------------------------------------------------
757 // Tick state machine
758 // ---------------------------------------------------------------
759
760 /// Caller-supplied side-effects. The state machine itself is pure.
761 pub const TickIO = struct {
762 ctx: *anyopaque,
763 /// Write bytes into the terminal's VT parser (caller decides whether
764 /// that goes through pty master or direct term.write).
765 write_bytes: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!void,
766 /// Perform an offscreen render and return an owned png.Image.
767 /// The Scenario will take ownership; caller must not free.
768 capture: *const fn (ctx: *anyopaque, label: []const u8) anyerror!png.Image,
769 /// Return true if the blink timer flipped since the previous tick.
770 /// Used by sleep-until-flip. If blink isn't armed at all, calls
771 /// to this return false forever and sleep-until-flip will time out.
772 blink_just_flipped: *const fn (ctx: *anyopaque) bool,
773 };
774
775 pub const TickError = error{
776 ScenarioTimeout,
777 SleepUntilFlipTimeout,
778 AssertFailed,
779 PredicateOnMissingLabel,
780 } || std.mem.Allocator.Error || anyerror; // callbacks can surface anyerror
781
782 pub const TickOutcome = enum {
783 working, // directives remain; call tick again later
784 done, // no more directives
785 };
786
787 pub const ScenarioState = struct {
788 scenario: *const Scenario,
789 cursor: usize, // index into scenario.directives
790 origin_ns: i128, // monotonic wall-clock at start
791 deadline_ns: i128, // origin + timeout + slack; exceeding this → TickError.ScenarioTimeout
792 scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
793 sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
794 captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
795 alloc: std.mem.Allocator,
796
797 pub fn init(
798 alloc: std.mem.Allocator,
799 scenario: *const Scenario,
800 origin_ns: i128,
801 ) ScenarioState {
802 return .{
803 .scenario = scenario,
804 .cursor = 0,
805 .origin_ns = origin_ns,
806 .deadline_ns = origin_ns + @as(i128, scenario.timeout_ms) * std.time.ns_per_ms,
807 .scheduled_offset_ns = 0,
808 .sleep_until_flip_started_ns = null,
809 .captures = .{},
810 .alloc = alloc,
811 };
812 }
813
814 pub fn deinit(self: *ScenarioState) void {
815 var it = self.captures.iterator();
816 while (it.next()) |entry| {
817 self.alloc.free(entry.key_ptr.*);
818 self.alloc.free(entry.value_ptr.pixels);
819 }
820 self.captures.deinit(self.alloc);
821 }
822
823 pub fn isDone(self: *const ScenarioState) bool {
824 return self.cursor >= self.scenario.directives.len;
825 }
826
827 pub fn tick(self: *ScenarioState, now_ns: i128, io: TickIO) TickError!TickOutcome {
828 // Check scenario-level timeout first.
829 if (now_ns > self.deadline_ns) return error.ScenarioTimeout;
830
831 const elapsed_ns = now_ns - self.origin_ns;
832
833 // Loop over directives that are ready to execute.
834 while (self.cursor < self.scenario.directives.len) {
835 // Check if the current directive's scheduled time has arrived.
836 if (elapsed_ns < self.scheduled_offset_ns) break;
837
838 const directive = self.scenario.directives[self.cursor];
839
840 switch (directive) {
841 .sleep => |ms| {
842 self.scheduled_offset_ns += @as(i128, ms) * std.time.ns_per_ms;
843 self.cursor += 1;
844 },
845 .sleep_until_flip => {
846 if (self.sleep_until_flip_started_ns == null) {
847 // First encounter: stamp the start time and hold.
848 self.sleep_until_flip_started_ns = now_ns;
849 return .working;
850 }
851 // Subsequent encounter: check flip or timeout.
852 if (io.blink_just_flipped(io.ctx)) {
853 self.sleep_until_flip_started_ns = null;
854 self.cursor += 1;
855 // Continue the loop to execute the next directive.
856 } else {
857 const started = self.sleep_until_flip_started_ns.?;
858 const blink_timeout_ns: i128 = 500 * std.time.ns_per_ms * 2;
859 if (now_ns - started > blink_timeout_ns) {
860 return error.SleepUntilFlipTimeout;
861 }
862 return .working;
863 }
864 },
865 .bytes => |slice| {
866 try io.write_bytes(io.ctx, slice);
867 self.cursor += 1;
868 },
869 .capture => |label| {
870 const img = try io.capture(io.ctx, label);
871 // Dupe the label into ScenarioState.alloc so captures outlive the arena.
872 const label_copy = try self.alloc.dupe(u8, label);
873 errdefer self.alloc.free(label_copy);
874 try self.captures.put(self.alloc, label_copy, img);
875 self.cursor += 1;
876 },
877 .assert_cell => unreachable, // Task 3 wires the predicate evaluator.
878 .assert_cell_at => unreachable, // Task 3 wires the predicate evaluator.
879 }
880 }
881
882 return if (self.isDone()) .done else .working;
883 }
884 };
885
886 // ---------------------------------------------------------------
887 // Tick tests
888 // ---------------------------------------------------------------
889
890 const TestIO = struct {
891 writes: std.ArrayListUnmanaged(u8) = .{},
892 captures_called: std.ArrayListUnmanaged([]const u8) = .{},
893 flip_stub: bool = false,
894 alloc: std.mem.Allocator,
895
896 pub fn writeBytes(ctx: *anyopaque, bytes: []const u8) anyerror!void {
897 const self: *TestIO = @ptrCast(@alignCast(ctx));
898 try self.writes.appendSlice(self.alloc, bytes);
899 }
900
901 pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
902 const self: *TestIO = @ptrCast(@alignCast(ctx));
903 const label_copy = try self.alloc.dupe(u8, label);
904 try self.captures_called.append(self.alloc, label_copy);
905 // Return a 1x1 white pixel that the Scenario takes ownership of.
906 const pixels = try self.alloc.alloc(u8, 4);
907 pixels[0] = 255;
908 pixels[1] = 255;
909 pixels[2] = 255;
910 pixels[3] = 255;
911 return .{ .width = 1, .height = 1, .pixels = pixels };
912 }
913
914 pub fn flipCb(ctx: *anyopaque) bool {
915 const self: *TestIO = @ptrCast(@alignCast(ctx));
916 const v = self.flip_stub;
917 self.flip_stub = false;
918 return v;
919 }
920
921 pub fn io(self: *TestIO) TickIO {
922 return .{
923 .ctx = self,
924 .write_bytes = writeBytes,
925 .capture = captureCb,
926 .blink_just_flipped = flipCb,
927 };
928 }
929
930 pub fn deinit(self: *TestIO) void {
931 for (self.captures_called.items) |lbl| self.alloc.free(lbl);
932 self.captures_called.deinit(self.alloc);
933 self.writes.deinit(self.alloc);
934 }
935 };
936
937 test "tick: empty scenario is immediately done" {
938 var s = try parseOk(
939 \\size 80 24
940 \\timeout 1000ms
941 );
942 defer s.deinit();
943 var state = ScenarioState.init(std.testing.allocator, &s, 0);
944 defer state.deinit();
945
946 try std.testing.expect(state.isDone());
947 }
948
949 test "tick: bytes directive fires write_bytes immediately" {
950 var s = try parseOk(
951 \\size 80 24
952 \\timeout 1000ms
953 \\bytes "abc"
954 );
955 defer s.deinit();
956 var state = ScenarioState.init(std.testing.allocator, &s, 0);
957 defer state.deinit();
958
959 var tio = TestIO{ .alloc = std.testing.allocator };
960 defer tio.deinit();
961
962 const r = try state.tick(0, tio.io());
963 try std.testing.expectEqual(TickOutcome.done, r);
964 try std.testing.expectEqualSlices(u8, "abc", tio.writes.items);
965 try std.testing.expect(state.isDone());
966 }
967
968 test "tick: sleep holds advancement until time passes" {
969 var s = try parseOk(
970 \\size 80 24
971 \\timeout 5000ms
972 \\sleep 500ms
973 \\bytes "x"
974 );
975 defer s.deinit();
976 var state = ScenarioState.init(std.testing.allocator, &s, 0);
977 defer state.deinit();
978
979 var tio = TestIO{ .alloc = std.testing.allocator };
980 defer tio.deinit();
981
982 // At t=0, sleep is consumed (moves scheduled offset forward); bytes is held.
983 const r1 = try state.tick(0, tio.io());
984 try std.testing.expectEqual(TickOutcome.working, r1);
985 try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
986
987 // At t=250ms, still under scheduled offset — no advance.
988 const r2 = try state.tick(250 * std.time.ns_per_ms, tio.io());
989 try std.testing.expectEqual(TickOutcome.working, r2);
990 try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
991
992 // At t=500ms, bytes fires.
993 const r3 = try state.tick(500 * std.time.ns_per_ms, tio.io());
994 try std.testing.expectEqual(TickOutcome.done, r3);
995 try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
996 }
997
998 test "tick: capture calls io.capture and stores the result under the label" {
999 var s = try parseOk(
1000 \\size 80 24
1001 \\timeout 5000ms
1002 \\capture snap1
1003 );
1004 defer s.deinit();
1005 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1006 defer state.deinit();
1007
1008 var tio = TestIO{ .alloc = std.testing.allocator };
1009 defer tio.deinit();
1010
1011 _ = try state.tick(0, tio.io());
1012 try std.testing.expect(state.isDone());
1013 try std.testing.expectEqual(@as(usize, 1), tio.captures_called.items.len);
1014 try std.testing.expectEqualSlices(u8, "snap1", tio.captures_called.items[0]);
1015 try std.testing.expect(state.captures.contains("snap1"));
1016 }
1017
1018 test "tick: scenario timeout fires" {
1019 var s = try parseOk(
1020 \\size 80 24
1021 \\timeout 100ms
1022 \\sleep 500ms
1023 \\bytes "x"
1024 );
1025 defer s.deinit();
1026 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1027 defer state.deinit();
1028
1029 var tio = TestIO{ .alloc = std.testing.allocator };
1030 defer tio.deinit();
1031
1032 // 200ms is past the 100ms scenario timeout.
1033 const r = state.tick(200 * std.time.ns_per_ms, tio.io());
1034 try std.testing.expectError(error.ScenarioTimeout, r);
1035 }
1036
1037 test "tick: sleep-until-flip holds until flip callback returns true" {
1038 var s = try parseOk(
1039 \\size 80 24
1040 \\timeout 5000ms
1041 \\sleep-until-flip
1042 \\bytes "x"
1043 );
1044 defer s.deinit();
1045 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1046 defer state.deinit();
1047
1048 var tio = TestIO{ .alloc = std.testing.allocator };
1049 defer tio.deinit();
1050
1051 // No flip yet — tick holds at the sleep-until-flip directive.
1052 tio.flip_stub = false;
1053 const r1 = try state.tick(100 * std.time.ns_per_ms, tio.io());
1054 try std.testing.expectEqual(TickOutcome.working, r1);
1055 try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
1056
1057 // Flip observed — sleep-until-flip advances; bytes fires.
1058 tio.flip_stub = true;
1059 const r2 = try state.tick(200 * std.time.ns_per_ms, tio.io());
1060 try std.testing.expectEqual(TickOutcome.done, r2);
1061 try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
1062 }
1063
1064 test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
1065 var s = try parseOk(
1066 \\size 80 24
1067 \\timeout 10000ms
1068 \\sleep-until-flip
1069 );
1070 defer s.deinit();
1071 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1072 defer state.deinit();
1073
1074 var tio = TestIO{ .alloc = std.testing.allocator };
1075 defer tio.deinit();
1076
1077 // After 2 * 500ms = 1s with no flip observed, fail.
1078 // First tick enters the directive, stamps the start time.
1079 _ = try state.tick(0, tio.io());
1080 // Second tick at t=1.1s — over the 2x blink_period budget.
1081 const r = state.tick(1_100 * std.time.ns_per_ms, tio.io());
1082 try std.testing.expectError(error.SleepUntilFlipTimeout, r);
1083 }