Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (#44wall-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

Expand All @@ -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`) |
58 changes: 58 additions & 0 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
76 changes: 76 additions & 0 deletions src/ui/inspector.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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}),
Expand Down
Loading