diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 9711573..83822a8 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -11,8 +11,8 @@ **S4 — Analysis parity+ (0.3.0): in progress.** S0–S3 are complete (the engine and the native inspector). S4 reaches parity with the toolkit's analysis surface; report generation (#41), anonymize-on-export (#42), semantic trace diff (#43), and -the replay engine core (#44) have landed. The manual-scrub replay transport (#44) -and the headless CLI (#45) remain (see [ROADMAP.md](ROADMAP.md)). +replay (#44 — engine + manual-scrub transport) have landed. Only the headless CLI +(#45) remains (see [ROADMAP.md](ROADMAP.md)). ## What's done @@ -143,18 +143,19 @@ In progress. Landed so far: errorCode), added/removed events, a failure-set delta by code, and a first-session summary comparison. Includes a recursive JSON deep-equality check and a compact-JSON renderer for the changed values. -- **Replay engine core (#44)** — `src/ocpp/replay.zig`, a deterministic, - timer-free `ReplayEngine` (step / stepBack / jumpTo / getState / reset) at - parity with the toolkit, exposing the failures detected at each position. The - manual-scrub UI transport is the remaining part of #44; real wall-clock - auto-play is deferred to the runner-eject bucket (#33), since the zero-config - runner exposes no timer effect (spiked). +- **Replay (#44)** — `src/ocpp/replay.zig`, a deterministic, timer-free + `ReplayEngine` (step / stepBack / jumpTo / getState / reset) at parity with the + toolkit, plus a manual-scrub **transport** in the timeline pane (First / Prev / + Next / Last + a position readout) that steps the selection over the visible + (filtered) events, reusing `select_event`. Real wall-clock auto-play is deferred + to the runner-eject bucket (#33), since the zero-config runner exposes no timer + effect (spiked). ## What's next -Remaining **S4 — Analysis parity+ (0.3.0)** work: the manual-scrub replay -transport (#44 — wall-clock auto-play deferred to #33) and the headless CLI -subcommands (#45). Then **S5 — Live capture** (the flagship). +Remaining **S4 — Analysis parity+ (0.3.0)** work: the headless CLI subcommands +(#45) — a clean `main`-branch (argv → engine → stdout), per the spike. Then +**S5 — Live capture** (the flagship). ## Known blockers / decisions pending @@ -167,7 +168,7 @@ subcommands (#45). Then **S5 — Live capture** (the flagship). | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | | `ocpp` (engine) | ✅ S2 + ingestion (#29) + reports (#41) + anonymize (#42) + diff (#43) + replay core (#44); O(n) detection pending (#36) | -| `ui` (native views) | ✅ S3 inspector — timeline, message inspector, session + failure panels, search & filter (#27–#32) | +| `ui` (native views) | ✅ S3 inspector (#27–#32) + replay transport (#44) | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/src/tests.zig b/src/tests.zig index 31d1e74..a14e83a 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -8,6 +8,7 @@ const native_sdk = @import("native_sdk"); const main = @import("main.zig"); const workspace = @import("ui/workspace.zig"); const inspector = @import("ui/inspector.zig"); +const types = @import("ocpp/ocpp.zig").types; const canvas = native_sdk.canvas; const testing = std.testing; @@ -518,3 +519,60 @@ test "the inspector view lays out through the canvas engine" { const layout = try canvas.layoutWidgetTree(tree.root, native_sdk.geometry.RectF.init(0, 0, 1200, 800), &nodes); try testing.expect(layout.nodes.len > 0); } + +test "the replay transport steps the selection through the timeline" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .open_sample); // 22 events, no filter + + // Nothing selected yet → Next selects the first event. + var tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "Next")).id, .up).?); + try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.selected_event); + + // Next advances; Prev retreats. + tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "Next")).id, .up).?); + try testing.expectEqual(@as(?usize, 1), model.activeTrace().?.selected_event); + + tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "Prev")).id, .up).?); + try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.selected_event); + + // Last jumps to the final event; First returns to the top. + tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "Last")).id, .up).?); + try testing.expectEqual(@as(?usize, 21), model.activeTrace().?.selected_event); + + tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "First")).id, .up).?); + try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.selected_event); +} + +test "the replay transport steps over the filtered set, skipping hidden events" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .open_sample); + workspace.update(&model, .{ .toggle_type_filter = .call_result }); // responses only + + // Next lands on a CallResult — a hidden Call is never selected. + var tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "Next")).id, .up).?); + const first_sel = model.activeTrace().?.selected_event.?; + try testing.expectEqual(types.MessageType.call_result, model.activeTrace().?.events[first_sel].message_type); + + // Next again advances to a later CallResult. + tree = try buildTree(arena, &model); + workspace.update(&model, tree.msgForPointer((try expectByText(tree.root, .button, "Next")).id, .up).?); + const second_sel = model.activeTrace().?.selected_event.?; + try testing.expectEqual(types.MessageType.call_result, model.activeTrace().?.events[second_sel].message_type); + try testing.expect(second_sel > first_sel); +} diff --git a/src/ui/inspector.zig b/src/ui/inspector.zig index 2fdd1cd..03efc38 100644 --- a/src/ui/inspector.zig +++ b/src/ui/inspector.zig @@ -248,10 +248,84 @@ fn payloadContainsText(value: std.json.Value, needle: []const u8, depth: usize) /// `filtered` null = show all events (display index == event index); non-null = /// the filtered event indices, so only matching rows exist and hidden rows never /// become widgets (the window stays viewport-sized either way). +// --- replay transport (#44): manual step / scrub over the (filtered) timeline --- +// +// A step control that walks the current selection through the visible events — +// first / prev / next / last — reusing `select_event` with view-computed target +// indices, so stepping and clicking share one selection model. The current event +// is highlighted in the timeline exactly as a click would. Real wall-clock +// auto-play needs a timer source the zero-config runner does not expose, so it is +// deferred to the runner-eject bucket (#33). + +const TransportTargets = struct { + first: ?usize = null, + prev: ?usize = null, + next: ?usize = null, + last: ?usize = null, +}; + +/// Real event index for display position `d` under the (optional) filter. +fn realIndexAt(filtered: ?[]const usize, d: usize) usize { + return if (filtered) |fi| fi[d] else d; +} + +/// Display position of the current selection within the visible set, or null +/// when nothing is selected or the selection is hidden by the active filter. +fn transportPosition(t: *const LoadedTrace, filtered: ?[]const usize, count: usize) ?usize { + const sel = t.selected_event orelse return null; + if (filtered) |fi| { + for (fi, 0..) |real, d| if (real == sel) return d; + return null; + } + return if (sel < count) sel else null; +} + +/// Target real-indices for the four step controls. All null when there are no +/// visible events. Boundaries clamp (prev at the start / next at the end stay +/// put); with no current position, prev → last and next → first. +fn transportTargets(filtered: ?[]const usize, count: usize, cur: ?usize) TransportTargets { + if (count == 0) return .{}; + const first = realIndexAt(filtered, 0); + const last = realIndexAt(filtered, count - 1); + const prev = if (cur) |c| realIndexAt(filtered, if (c > 0) c - 1 else 0) else last; + const next = if (cur) |c| realIndexAt(filtered, if (c + 1 < count) c + 1 else count - 1) else first; + return .{ .first = first, .prev = prev, .next = next, .last = last }; +} + +fn replayTransport(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize) Node { + const count = if (filtered) |fi| fi.len else t.events.len; + const cur = transportPosition(t, filtered, count); + const targets = transportTargets(filtered, count, cur); + const muted = canvas.StyleTokenRefs{ .foreground = .text_muted }; + const pos: []const u8 = if (cur) |c| + (std.fmt.allocPrint(ui.arena, "{d} / {d}", .{ c + 1, count }) catch "…") + else + (std.fmt.allocPrint(ui.arena, "- / {d}", .{count}) catch "…"); + return ui.row(.{ .padding = 6, .gap = 4, .cross = .center }, .{ + ui.text(.{ .style_tokens = muted }, "Replay"), + transportButton(ui, "First", targets.first), + transportButton(ui, "Prev", targets.prev), + transportButton(ui, "Next", targets.next), + transportButton(ui, "Last", targets.last), + ui.spacer(1), + ui.text(.{ .style_tokens = muted }, pos), + }); +} + +fn transportButton(ui: *Ui, label: []const u8, target: ?usize) Node { + return ui.button(.{ + .on_press = if (target) |ti| Msg{ .select_event = ti } else null, + .variant = .ghost, + .size = .sm, + }, label); +} + fn timelinePane(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize) Node { const count = if (filtered) |fi| fi.len else t.events.len; if (filtered != null and count == 0) { return ui.column(.{ .min_width = 360, .grow = 1 }, .{ + replayTransport(ui, t, filtered), + ui.separator(.{}), timelineHeader(ui), ui.separator(.{}), centeredNote(ui, "No matching events"), @@ -281,6 +355,8 @@ fn timelinePane(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize) Node { row.* = node; } return ui.column(.{ .min_width = 360, .grow = 1 }, .{ + replayTransport(ui, t, filtered), + ui.separator(.{}), timelineHeader(ui), ui.separator(.{}), ui.virtualList(opts, window, .{rows}),