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
18 changes: 13 additions & 5 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,18 @@ toolkit's:
detection is capped at 50k events (several rules are O(n²)); past it the trace
is fully inspectable but detection is skipped and the UI says so — the O(n)
detection rewrite is tracked in #36.

Still ahead in S3: the full message inspector + session panel (#30), the failure
panel (#31), and search / filter (#32). Interactive open (native dialog +
drag-drop) is deferred to #33 — it needs an ejected runner (see ADR-0006).
- **Message inspector + session panel (#30)** — selecting a timeline row unpacks
the event in the detail pane: normalized fields, the session it correlates
into (transaction id, status, connector, start/stop, event count) with a
jump-to-first-event control, a model-owned disclosure tree over the payload
(`ui.tree` + the ARIA keymap, bounded in depth/breadth/node-count for hostile
input), and the raw OCPP-J array pretty-printed. Jump selects the session's
first event (highlighting it and driving the panels); the literal timeline
viewport scroll rides the same runtime-eject as #33.

Still ahead in S3: the failure panel (#31) and search / filter (#32).
Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an
ejected runner (see ADR-0006).

## What's next

Expand All @@ -110,7 +118,7 @@ wall-clock replay, and a headless CLI mode.
| `repo` (tooling, CI) | ✅ done for S0 |
| `docs` (docs, ADRs) | ✅ done for S0 |
| `ocpp` (engine) | ✅ S2 + trusted ingestion (#29); O(n) detection pending (#36) |
| `ui` (native views) | 🚧 shell + timeline (S3, #27–#28); panes + search next |
| `ui` (native views) | 🚧 shell + timeline + message inspector (S3, #27–#28, #30); failure panel + search next |
| `capture` (live proxy) | ⬜ not started (S5) |
| `cli` (headless) | ⬜ not started (S4) |
| `conformance` | ✅ done for S2 (15/15, `contract-v1`) |
161 changes: 161 additions & 0 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,52 @@ fn findRowWithText(widget: canvas.Widget, text: []const u8) ?canvas.Widget {
return null;
}

fn countRole(widget: canvas.Widget, role: canvas.WidgetRole) usize {
var n: usize = if (widget.semantics.role == role) 1 else 0;
for (widget.children) |child| n += countRole(child, role);
return n;
}

/// The first `text` widget whose content starts with `prefix` — the raw JSON
/// view is one text node holding the whole pretty-printed array.
fn findTextPrefix(widget: canvas.Widget, prefix: []const u8) ?canvas.Widget {
if (widget.kind == .text and std.mem.startsWith(u8, widget.text, prefix)) return widget;
for (widget.children) |child| {
if (findTextPrefix(child, prefix)) |found| return found;
}
return null;
}

/// The first `treeitem` row whose own label text equals `key` — used to grab a
/// payload-tree row (flat rows, so a row's subtree holds only its own texts).
fn findTreeItem(widget: canvas.Widget, key: []const u8) ?canvas.Widget {
if (widget.semantics.role == .treeitem and findByText(widget, .text, key) != null) return widget;
for (widget.children) |child| {
if (findTreeItem(child, key)) |found| return found;
}
return null;
}

/// A single-Call trace whose MeterValues payload exercises every tree shape:
/// scalars, a nested object, an array, an over-deep chain, and an over-wide
/// array. `wide` holds 50 elements (past the breadth bound of 40); `deep`
/// nests 6 levels (past the depth bound). Caller owns nothing — `openBytes`
/// copies it.
fn nestedTraceBytes(a: std.mem.Allocator) ![]const u8 {
var buf: std.ArrayList(u8) = .empty;
try buf.appendSlice(a, "{\"events\":[{\"message\":[2,\"m1\",\"MeterValues\",{" ++
"\"connectorId\":1," ++
"\"meterValue\":[{\"timestamp\":\"2024-01-01T00:00:00Z\",\"sampledValue\":[{\"value\":\"42.5\",\"unit\":\"Wh\"}]}]," ++
"\"deep\":{\"a\":{\"b\":{\"c\":{\"d\":{\"e\":{\"f\":\"tooDeep\"}}}}}}," ++
"\"wide\":[");
for (0..50) |i| {
if (i > 0) try buf.appendSlice(a, ",");
try buf.append(a, '0' + @as(u8, @intCast(i % 10)));
}
try buf.appendSlice(a, "]}]}]}");
return buf.items;
}

test "the empty workspace offers the open-sample affordance" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
Expand Down Expand Up @@ -112,6 +158,117 @@ test "clicking a timeline row selects the event and the detail pane reflects it"
_ = try expectByText(tree.root, .text, "Message ID"); // a detail-row label
}

test "selecting an event renders every message-inspector section" {
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, .{ .select_event = 0 });

const tree = try buildTree(arena, &model);
_ = try expectByText(tree.root, .text, "Details");
_ = try expectByText(tree.root, .text, "Session");
_ = try expectByText(tree.root, .text, "Payload");
_ = try expectByText(tree.root, .text, "Raw message");
_ = try expectByText(tree.root, .button, "Jump to first event");
// The raw view pretty-prints the OCPP-J array into one text node opening
// with the array bracket on its own line.
const raw = findTextPrefix(tree.root, "[\n") orelse return error.WidgetNotFound;
try testing.expect(std.mem.indexOf(u8, raw.text, "BootNotification") != null);
// The payload renders as a disclosure tree with at least one treeitem row.
try testing.expect(countRole(tree.root, .treeitem) > 0);
}

test "the session panel reflects the event's session and jumps to the first event" {
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);
// Select a mid-session event (the StopTransaction Call, well past event 0).
workspace.update(&model, .{ .select_event = 12 });

var tree = try buildTree(arena, &model);
// The sample's one session carries transactionId 100001 and completed.
_ = try expectByText(tree.root, .text, "100001");
_ = try expectByText(tree.root, .text, "completed");

// Jump-to-first-event selects the session's first event (the BootNotification).
const jump = try expectByText(tree.root, .button, "Jump to first event");
main.update(&model, tree.msgForPointer(jump.id, .up).?);
try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.selected_event);

tree = try buildTree(arena, &model);
_ = try expectByText(tree.root, .text, "evt-0001");
}

test "the payload tree renders object, array, and scalar shapes" {
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();
model.openBytes("nested.json", try nestedTraceBytes(arena));
workspace.update(&model, .{ .select_event = 0 });

const tree = try buildTree(arena, &model);
// Object field (scalar), a nested array, and an array-index row.
_ = try expectByText(tree.root, .text, "connectorId");
_ = try expectByText(tree.root, .text, "meterValue");
_ = try expectByText(tree.root, .text, "sampledValue");
// A scalar string leaf renders quoted; a number renders bare.
_ = try expectByText(tree.root, .text, "\"Wh\"");
_ = try expectByText(tree.root, .text, "1"); // connectorId's value
}

test "the payload tree bounds depth and breadth" {
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();
model.openBytes("nested.json", try nestedTraceBytes(arena));
workspace.update(&model, .{ .select_event = 0 });

const tree = try buildTree(arena, &model);
// The over-deep chain stops before its leaf: "tooDeep" never renders.
try testing.expect(findByText(tree.root, .text, "\"tooDeep\"") == null);
// The over-wide array shows a truncation marker instead of all 50 elements.
try testing.expect(findByText(tree.root, .text, "... 10 more") != null);
// The whole tree stays within the tracked node budget.
try testing.expect(countRole(tree.root, .treeitem) <= workspace.max_payload_tree_nodes);
}

test "toggling a payload container collapses its children" {
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();
model.openBytes("nested.json", try nestedTraceBytes(arena));
workspace.update(&model, .{ .select_event = 0 });

var tree = try buildTree(arena, &model);
// "timestamp" lives under meterValue → visible while expanded.
_ = try expectByText(tree.root, .text, "timestamp");
const mv = findTreeItem(tree.root, "meterValue") orelse return error.WidgetNotFound;

// Collapse meterValue through its row's on_press.
main.update(&model, tree.msgForPointer(mv.id, .up).?);
tree = try buildTree(arena, &model);
// meterValue itself stays; its descendant "timestamp" is gone.
_ = try expectByText(tree.root, .text, "meterValue");
try testing.expect(findByText(tree.root, .text, "timestamp") == null);
}

test "the virtual window stays viewport-sized at dataset scale" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
Expand Down Expand Up @@ -183,6 +340,10 @@ test "the inspector view passes the accessibility sweep (empty and loaded)" {
workspace.update(&model, .open_sample); // loaded state
tree = try buildTree(arena, &model);
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep);

workspace.update(&model, .{ .select_event = 5 }); // detail pane: tree, session, raw
tree = try buildTree(arena, &model);
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep);
}

test "the inspector view lays out through the canvas engine" {
Expand Down
Loading
Loading