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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ studio/
├── src/
│ ├── main.zig # app wiring + CLI-arg trace loading (re-exports the TEA types)
│ ├── ui/ # the inspector: builder view + workspace state
│ │ ├── workspace.zig # Model, Msg, update — the bounded multi-trace workspace
│ │ ├── inspector.zig # the canvas.Ui builder view (ADR-0006)
│ │ ├── workspace.zig # Model, Msg, update — traces, selection, filter/search state
│ │ ├── inspector.zig # canvas.Ui builder view: timeline, inspector, panels, filter (ADR-0006)
│ │ └── ui.zig # aggregate root (test discovery)
│ ├── ocpp/ # pure, headless OCPP engine (types, parser, detection, …)
│ │ └── conformance/ # vendored shared-contract fixtures + goldens + harness
Expand Down
26 changes: 17 additions & 9 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

## Active milestone

**S3 — Inspector UI (0.2.0): in progress.** The engine (S0–S2) is complete; the
native inspector is now being built (see [ROADMAP.md](ROADMAP.md)).
**S3 — Inspector UI (0.2.0): complete.** The engine (S0–S2) and the native
inspector (open, virtualized timeline, message inspector, session + failure
panels, search & filter) are done. Next up is **S4 — Analysis parity+ (0.3.0)**
(see [ROADMAP.md](ROADMAP.md)).

## What's done

Expand Down Expand Up @@ -70,9 +72,9 @@ toolkit's:

**Exit criteria met:** 15/15 scenarios match the locked goldens.

## What's in progress
### S3 — Inspector UI (0.2.0) ✅

**S3 — Inspector UI (0.2.0).** Landed so far:
Every issue landed:

- **Inspector shell (#27)** — the placeholder counter is replaced by a Zig
`canvas.Ui` builder view (ADR-0006), a bounded multi-trace workspace `Model` /
Expand Down Expand Up @@ -103,14 +105,20 @@ toolkit's:
steps and affected events (accordion) and jumps to its primary event, so a
failure and its evidence line up. A clean trace shows a positive
"no failures detected" state; the status bar carries the severity breakdown.
- **Search & filter (#32)** — a toolbar over the timeline: a free-text search
field (matching action / unique id / payload, case-insensitive) plus AND-composable
toggle facets (message type, direction, severity). The filtered index set is
derived in the build arena and drives the virtual list, so filtering a huge
trace stays viewport-sized (hidden rows never become widgets); the status bar
shows the match count and an empty result shows a quiet "no matching events".

Still ahead in S3: search / filter (#32), which closes the milestone.
Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an
ejected runner (see ADR-0006).
Deferred out of S3: interactive open — native dialog + drag-drop (#33) — and the
timeline viewport scroll on jump (session/failure), both of which need an ejected
runner (see ADR-0006). Tracked as follow-ups, not blockers.

## What's next

After S3: **S4 — Analysis parity+ (0.3.0)** — reports, anonymize, diff,
**S4 — Analysis parity+ (0.3.0)** — reports (Markdown / HTML), anonymize, diff,
wall-clock replay, and a headless CLI mode.

## Known blockers / decisions pending
Expand All @@ -124,7 +132,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 + inspector + failure panel (S3, #27–#28, #30–#31); search next |
| `ui` (native views) | ✅ S3 inspector — timeline, message inspector, session + failure panels, search & filter (#27–#32) |
| `capture` (live proxy) | ⬜ not started (S5) |
| `cli` (headless) | ⬜ not started (S4) |
| `conformance` | ✅ done for S2 (15/15, `contract-v1`) |
15 changes: 9 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ the stable part.
| **S0** | 0.0.x | Foundation | ✅ Complete |
| **S1** | 0.1.0 | Engine core | ✅ Complete |
| **S2** | 0.1.0 | Detection + conformance | ✅ Complete |
| **S3** | 0.2.0 | Inspector UI | Next up |
| **S4** | 0.3.0 | Analysis parity+ | Planned |
| **S3** | 0.2.0 | Inspector UI | ✅ Complete |
| **S4** | 0.3.0 | Analysis parity+ | Next up |
| **S5** | 0.4.0 | Live capture ⭐ | Planned |
| **S6** | 1.0.0 | 1.0 polish | Planned |

Expand Down Expand Up @@ -40,11 +40,14 @@ detected failures against locked goldens generated from the toolkit.
**Exit criteria met:** 15/15 scenarios match the `contract-v1` goldens under
`native test`.

## S3 — Inspector UI (0.2.0)
## S3 — Inspector UI (0.2.0)

The native inspector: open and drag-drop traces, a virtualized event timeline,
the per-message inspector, the failure panel, search / filter, and a
multi-trace workspace. Handles traces far larger than a browser tab can hold.
The native inspector: a virtualized event timeline, the per-message inspector
(raw / normalized / payload tree), the session and failure panels, search /
filter, and a multi-trace workspace — handling traces far larger than a browser
tab can hold. Traces open from command-line paths and a built-in sample;
interactive open (native dialog + drag-drop) is deferred to #33 (needs an
ejected runner, ADR-0006).

## S4 — Analysis parity+ (0.3.0)

Expand Down
112 changes: 112 additions & 0 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,112 @@ test "the failure panel lists failures, expands steps, and jumps to the event" {
_ = try expectByText(tree.root, .text, "Affected");
}

fn countNodes(widget: canvas.Widget) usize {
var n: usize = 1;
for (widget.children) |child| n += countNodes(child);
return n;
}

fn statusText(tree: Ui.Tree) []const u8 {
return (findKind(tree.root, .status_bar) orelse return "").text;
}

test "a message-type facet narrows the timeline and the Clear button restores it" {
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);

var tree = try buildTree(arena, &model);
// The filter bar renders its facet buttons and a search field.
_ = try expectByText(tree.root, .button, "Call");
try testing.expect(findKind(tree.root, .search_field) != null);
try testing.expect(std.mem.indexOf(u8, statusText(tree), "22 events") != null);

// Filter to Call messages: the status count reflects the match subset.
const call = try expectByText(tree.root, .button, "Call");
main.update(&model, tree.msgForPointer(call.id, .up).?);
tree = try buildTree(arena, &model);
try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22 events") != null);

// Clear restores the full timeline.
const clear = try expectByText(tree.root, .button, "Clear");
main.update(&model, tree.msgForPointer(clear.id, .up).?);
tree = try buildTree(arena, &model);
try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22") == null);
try testing.expect(std.mem.indexOf(u8, statusText(tree), "22 events") != null);
}

test "typing in the search field filters, and clearing it through the input path restores" {
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);

var tree = try buildTree(arena, &model);
const field = findKind(tree.root, .search_field) orelse return error.WidgetNotFound;

// Type an action name: only its event(s) remain, and BootNotification stays.
main.update(&model, tree.msgForTextEdit(field.id, .{ .insert_text = "BootNotification" }).?);
tree = try buildTree(arena, &model);
try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22 events") != null);
_ = try expectByText(tree.root, .text, "BootNotification");

// The search-field clear affordance (x / Escape) arrives as `.clear`.
const field2 = findKind(tree.root, .search_field) orelse return error.WidgetNotFound;
main.update(&model, tree.msgForTextEdit(field2.id, .clear).?);
tree = try buildTree(arena, &model);
try testing.expect(std.mem.indexOf(u8, statusText(tree), " of 22") == null);
try testing.expect(std.mem.indexOf(u8, statusText(tree), "22 events") != null);
}

test "a non-matching search shows the empty-result state" {
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);
// Drive the query directly through update (same path the field dispatches).
workspace.update(&model, .{ .search_input = .{ .insert_text = "zzz-no-such-event" } });

const tree = try buildTree(arena, &model);
_ = try expectByText(tree.root, .text, "No matching events");
try testing.expect(std.mem.indexOf(u8, statusText(tree), "0 of 22 events") != null);
}

test "filtering a large trace keeps the widget tree viewport-sized" {
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();

// 2000 events (Heartbeat call/result pairs) as JSONL — well past a viewport.
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(testing.allocator);
var i: usize = 0;
while (i < 1000) : (i += 1) {
try buf.appendSlice(testing.allocator, "{\"message\":[2,\"m\",\"Heartbeat\",{}]}\n{\"message\":[3,\"m\",{}]}\n");
}
model.openBytes("big.jsonl", buf.items);
// Filter to Calls (~1000 matches): the window, not the match count, bounds nodes.
workspace.update(&model, .{ .toggle_type_filter = .call });

const tree = try buildTree(arena, &model);
try testing.expect(std.mem.indexOf(u8, statusText(tree), "of 2000 events") != null);
// No materialization of hidden rows: the whole tree stays viewport-sized.
try testing.expect(countNodes(tree.root) < 1024);
}

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 @@ -391,6 +497,12 @@ test "the inspector view passes the accessibility sweep (empty and loaded)" {
workspace.update(&failing, .{ .select_failure = 0 });
tree = try buildTree(arena, &failing);
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep);

// The filter bar (search field + facets) with an active query.
workspace.update(&model, .{ .search_input = .{ .insert_text = "Status" } });
workspace.update(&model, .{ .toggle_type_filter = .call });
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