a36f7122
scenario: add ScenarioState + tick state machine
a73x 2026-04-19 09:22
Commit message
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 | } | ||