add observations view to allow displaying checks
This commit is contained in:
parent
ae8061d618
commit
4ed3b91fce
1 changed files with 391 additions and 0 deletions
391
src/views/observations_view.zig
Normal file
391
src/views/observations_view.zig
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
//! View model joining `analytics/observations` engine output with the
|
||||
//! `data/journal` acknowledgments file. Produces a flat list of
|
||||
//! `FindingRow`s ready for both the CLI findings table and the TUI
|
||||
//! review tab.
|
||||
//!
|
||||
//! ## Responsibility split
|
||||
//!
|
||||
//! - The **engine** (`analytics/observations.zig`) produces a
|
||||
//! `CheckPanel` whose `pending` slice has one entry per registered
|
||||
//! check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`.
|
||||
//! - The **journal** (`data/journal.zig`) holds the user's
|
||||
//! acknowledgments — durable records keyed by `(observation, target)`.
|
||||
//! - This **view** matches the two: every `Observation` in a
|
||||
//! `warn`/`flag` result becomes a `FindingRow`. The row is marked
|
||||
//! acked iff a `JournalEntry` exists with `state == .acknowledged`
|
||||
//! and matching `(observation, target)`.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//!
|
||||
//! `FindingRow.text` and friends are **borrowed** from the panel and
|
||||
//! journal — the view does NOT copy strings. This keeps allocation
|
||||
//! cheap (one slice for the rows array) and is safe because callers
|
||||
//! always hold the panel and journal alive for the entire render
|
||||
//! frame. `FindingsView.deinit` only frees the rows slice.
|
||||
//!
|
||||
//! ## Filtering
|
||||
//!
|
||||
//! Resolved entries (state == `.resolved`) are never shown — by
|
||||
//! definition, the engine no longer emits the finding, so there's
|
||||
//! nothing to suppress. Only `acknowledged` rows can be filtered out
|
||||
//! via `show_acked = false`.
|
||||
|
||||
const std = @import("std");
|
||||
const observations = @import("../analytics/observations.zig");
|
||||
const journal_mod = @import("../data/journal.zig");
|
||||
|
||||
const Observation = observations.Observation;
|
||||
const Severity = observations.Severity;
|
||||
const CheckPanel = observations.CheckPanel;
|
||||
const Journal = journal_mod.Journal;
|
||||
const JournalEntry = journal_mod.JournalEntry;
|
||||
|
||||
/// One displayable finding. Borrows all string data from the
|
||||
/// underlying `Observation` and (optionally) the matching journal
|
||||
/// entry. Lifetime is bounded by the panel + journal.
|
||||
pub const FindingRow = struct {
|
||||
severity: Severity,
|
||||
/// Borrowed from `Observation.kind`.
|
||||
kind: []const u8,
|
||||
/// Borrowed from `Observation.target`.
|
||||
target: []const u8,
|
||||
/// Borrowed from `Observation.text`.
|
||||
text: []const u8,
|
||||
/// True iff a journal entry matches `(kind, target)` with state
|
||||
/// `.acknowledged`. When true, `ack_entry` is non-null.
|
||||
is_acked: bool,
|
||||
/// The matching journal entry (when `is_acked == true`), so
|
||||
/// renderers can pull notes/acknowledged_at out without re-doing
|
||||
/// the lookup. Null when no ack matches.
|
||||
ack_entry: ?*const JournalEntry = null,
|
||||
};
|
||||
|
||||
/// Result of `build`. Owns only the `rows` slice; all string data
|
||||
/// is borrowed.
|
||||
pub const FindingsView = struct {
|
||||
rows: []FindingRow,
|
||||
/// Number of un-acked findings (severity warn or flag, not
|
||||
/// suppressed). Independent of `show_acked` — counts the underlying
|
||||
/// engine output.
|
||||
total_active: usize,
|
||||
/// Number of findings whose journal entry is in `.acknowledged`
|
||||
/// state.
|
||||
total_acked: usize,
|
||||
/// Number of journal entries currently in `.resolved` state. These
|
||||
/// are never rendered as rows; surfaced only for the header line
|
||||
/// ("3 active, 1 acked, 2 resolved").
|
||||
total_resolved: usize,
|
||||
|
||||
pub fn deinit(self: *FindingsView, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.rows);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// Build a `FindingsView` from a panel + journal. `show_acked`
|
||||
/// controls whether acked findings appear in `rows` (they're always
|
||||
/// counted in `total_acked`).
|
||||
pub fn build(
|
||||
allocator: std.mem.Allocator,
|
||||
panel: *const CheckPanel,
|
||||
journal: *const Journal,
|
||||
show_acked: bool,
|
||||
) !FindingsView {
|
||||
var rows = std.ArrayList(FindingRow).empty;
|
||||
errdefer rows.deinit(allocator);
|
||||
|
||||
var total_active: usize = 0;
|
||||
var total_acked: usize = 0;
|
||||
|
||||
for (panel.pending) |pc| {
|
||||
const obs_slice: []const Observation = switch (pc.state) {
|
||||
.complete => |r| switch (r) {
|
||||
.warn => |o| o,
|
||||
.flag => |o| o,
|
||||
.pass, .skipped, .err => continue,
|
||||
},
|
||||
};
|
||||
|
||||
for (obs_slice) |obs| {
|
||||
const ack = findAck(journal, obs.kind, obs.target);
|
||||
const is_acked = ack != null;
|
||||
|
||||
if (is_acked) {
|
||||
total_acked += 1;
|
||||
} else {
|
||||
total_active += 1;
|
||||
}
|
||||
|
||||
if (is_acked and !show_acked) continue;
|
||||
|
||||
try rows.append(allocator, .{
|
||||
.severity = obs.severity,
|
||||
.kind = obs.kind,
|
||||
.target = obs.target,
|
||||
.text = obs.text,
|
||||
.is_acked = is_acked,
|
||||
.ack_entry = ack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Count resolved entries for the header. These never produce rows
|
||||
// — the engine's failure to re-emit the finding is what marks
|
||||
// them resolved — but we surface the count so the user knows
|
||||
// their journal still tracks them.
|
||||
var total_resolved: usize = 0;
|
||||
for (journal.entries) |*e| {
|
||||
if (e.ack.state == .resolved) total_resolved += 1;
|
||||
}
|
||||
|
||||
return .{
|
||||
.rows = try rows.toOwnedSlice(allocator),
|
||||
.total_active = total_active,
|
||||
.total_acked = total_acked,
|
||||
.total_resolved = total_resolved,
|
||||
};
|
||||
}
|
||||
|
||||
/// Look up a journal entry by `(observation, target)`, returning
|
||||
/// only entries currently in `.acknowledged` state. Returns null
|
||||
/// when no match.
|
||||
///
|
||||
/// Linear scan is fine for realistic journal sizes (tens of entries).
|
||||
/// If a portfolio ever accumulates hundreds of acks we can index by
|
||||
/// (observation, target) at load time.
|
||||
fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const JournalEntry {
|
||||
for (journal.entries) |*e| {
|
||||
if (e.ack.state != .acknowledged) continue;
|
||||
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
|
||||
if (!std.mem.eql(u8, e.ack.target, target)) continue;
|
||||
return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
fn makeObs(kind: []const u8, target: []const u8, text: []const u8) Observation {
|
||||
return .{
|
||||
.severity = .warn,
|
||||
.kind = kind,
|
||||
.target = target,
|
||||
.text = text,
|
||||
};
|
||||
}
|
||||
|
||||
fn makePanel(allocator: std.mem.Allocator, obs_slice: []const Observation) !CheckPanel {
|
||||
// Allocate a single-check panel with the supplied observations as
|
||||
// a `warn` result. Test-only helper. Dupes all strings so that
|
||||
// `panel.deinit`'s `freeObservations` path (which calls
|
||||
// `allocator.free` on each string) operates on owned memory rather
|
||||
// than caller-supplied literals.
|
||||
const owned_obs = try allocator.alloc(Observation, obs_slice.len);
|
||||
errdefer allocator.free(owned_obs);
|
||||
for (obs_slice, 0..) |o, i| {
|
||||
owned_obs[i] = .{
|
||||
.severity = o.severity,
|
||||
.kind = try allocator.dupe(u8, o.kind),
|
||||
.target = try allocator.dupe(u8, o.target),
|
||||
.text = try allocator.dupe(u8, o.text),
|
||||
};
|
||||
}
|
||||
|
||||
const check_singleton = struct {
|
||||
const c: observations.Check = .{
|
||||
.name = "test_check",
|
||||
.label = "Test Check",
|
||||
// SAFETY: test-only Check; tests fabricate panels with
|
||||
// pre-baked CheckResults via `state = .{ .complete = ... }`,
|
||||
// so `runChecks` never dispatches via this fn pointer.
|
||||
.run = undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const pending = try allocator.alloc(observations.PendingCheck, 1);
|
||||
pending[0] = .{
|
||||
.check = &check_singleton.c,
|
||||
.state = .{ .complete = .{ .warn = owned_obs } },
|
||||
};
|
||||
return .{ .allocator = allocator, .pending = pending };
|
||||
}
|
||||
|
||||
fn makeJournalWithAck(
|
||||
allocator: std.mem.Allocator,
|
||||
observation: []const u8,
|
||||
target: []const u8,
|
||||
state: journal_mod.State,
|
||||
) !Journal {
|
||||
const obs_dup = try allocator.dupe(u8, observation);
|
||||
const tgt_dup = try allocator.dupe(u8, target);
|
||||
const empty_notes = try allocator.alloc([]const u8, 0);
|
||||
const entries = try allocator.alloc(JournalEntry, 1);
|
||||
entries[0] = .{
|
||||
.ack = .{
|
||||
.observation = obs_dup,
|
||||
.target = tgt_dup,
|
||||
.acknowledged_at = Date.fromYmd(2026, 1, 1),
|
||||
.state = state,
|
||||
},
|
||||
.notes = empty_notes,
|
||||
};
|
||||
return .{ .allocator = allocator, .entries = entries };
|
||||
}
|
||||
|
||||
fn makeEmptyJournal(allocator: std.mem.Allocator) !Journal {
|
||||
const entries = try allocator.alloc(JournalEntry, 0);
|
||||
return .{ .allocator = allocator, .entries = entries };
|
||||
}
|
||||
|
||||
test "build: unacked finding produces a row, no ack_entry" {
|
||||
const obs = [_]Observation{
|
||||
makeObs("position_concentration", "NVDA", "NVDA at 18%"),
|
||||
};
|
||||
var panel = try makePanel(testing.allocator, &obs);
|
||||
defer panel.deinit();
|
||||
|
||||
var journal = try makeEmptyJournal(testing.allocator);
|
||||
defer journal.deinit();
|
||||
|
||||
var view = try build(testing.allocator, &panel, &journal, false);
|
||||
defer view.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), view.rows.len);
|
||||
try testing.expectEqual(@as(usize, 1), view.total_active);
|
||||
try testing.expectEqual(@as(usize, 0), view.total_acked);
|
||||
try testing.expect(!view.rows[0].is_acked);
|
||||
try testing.expect(view.rows[0].ack_entry == null);
|
||||
}
|
||||
|
||||
test "build: acked finding suppressed when show_acked is false" {
|
||||
const obs = [_]Observation{
|
||||
makeObs("position_concentration", "NVDA", "NVDA at 18%"),
|
||||
};
|
||||
var panel = try makePanel(testing.allocator, &obs);
|
||||
defer panel.deinit();
|
||||
|
||||
var journal = try makeJournalWithAck(
|
||||
testing.allocator,
|
||||
"position_concentration",
|
||||
"NVDA",
|
||||
.acknowledged,
|
||||
);
|
||||
defer journal.deinit();
|
||||
|
||||
var view = try build(testing.allocator, &panel, &journal, false);
|
||||
defer view.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), view.rows.len);
|
||||
try testing.expectEqual(@as(usize, 0), view.total_active);
|
||||
try testing.expectEqual(@as(usize, 1), view.total_acked);
|
||||
}
|
||||
|
||||
test "build: acked finding rendered when show_acked is true" {
|
||||
const obs = [_]Observation{
|
||||
makeObs("position_concentration", "NVDA", "NVDA at 18%"),
|
||||
};
|
||||
var panel = try makePanel(testing.allocator, &obs);
|
||||
defer panel.deinit();
|
||||
|
||||
var journal = try makeJournalWithAck(
|
||||
testing.allocator,
|
||||
"position_concentration",
|
||||
"NVDA",
|
||||
.acknowledged,
|
||||
);
|
||||
defer journal.deinit();
|
||||
|
||||
var view = try build(testing.allocator, &panel, &journal, true);
|
||||
defer view.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), view.rows.len);
|
||||
try testing.expect(view.rows[0].is_acked);
|
||||
try testing.expect(view.rows[0].ack_entry != null);
|
||||
}
|
||||
|
||||
test "build: resolved entries don't filter findings" {
|
||||
// Engine emits a finding for NVDA. Journal has a *resolved* entry
|
||||
// for NVDA. The finding should NOT be suppressed — resolved means
|
||||
// "engine stopped emitting it last time", but here it's emitting
|
||||
// again.
|
||||
const obs = [_]Observation{
|
||||
makeObs("position_concentration", "NVDA", "NVDA at 18%"),
|
||||
};
|
||||
var panel = try makePanel(testing.allocator, &obs);
|
||||
defer panel.deinit();
|
||||
|
||||
var journal = try makeJournalWithAck(
|
||||
testing.allocator,
|
||||
"position_concentration",
|
||||
"NVDA",
|
||||
.resolved,
|
||||
);
|
||||
defer journal.deinit();
|
||||
|
||||
var view = try build(testing.allocator, &panel, &journal, false);
|
||||
defer view.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), view.rows.len);
|
||||
try testing.expect(!view.rows[0].is_acked);
|
||||
try testing.expectEqual(@as(usize, 1), view.total_resolved);
|
||||
}
|
||||
|
||||
test "build: target mismatch — different symbol doesn't match ack" {
|
||||
const obs = [_]Observation{
|
||||
makeObs("position_concentration", "NVDA", "NVDA at 18%"),
|
||||
makeObs("position_concentration", "AAPL", "AAPL at 17%"),
|
||||
};
|
||||
var panel = try makePanel(testing.allocator, &obs);
|
||||
defer panel.deinit();
|
||||
|
||||
// Ack only NVDA; AAPL should still appear.
|
||||
var journal = try makeJournalWithAck(
|
||||
testing.allocator,
|
||||
"position_concentration",
|
||||
"NVDA",
|
||||
.acknowledged,
|
||||
);
|
||||
defer journal.deinit();
|
||||
|
||||
var view = try build(testing.allocator, &panel, &journal, false);
|
||||
defer view.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), view.rows.len);
|
||||
try testing.expectEqualStrings("AAPL", view.rows[0].target);
|
||||
try testing.expectEqual(@as(usize, 1), view.total_active);
|
||||
try testing.expectEqual(@as(usize, 1), view.total_acked);
|
||||
}
|
||||
|
||||
test "build: pass/skipped/err checks contribute no rows" {
|
||||
// Build a panel with a pass result by hand.
|
||||
const check_singleton = struct {
|
||||
const c: observations.Check = .{
|
||||
.name = "pass_check",
|
||||
.label = "Pass Check",
|
||||
// SAFETY: test-only Check; the panel is built with a
|
||||
// pre-baked `.complete = .pass` result so `runChecks`
|
||||
// never dispatches through this pointer.
|
||||
.run = undefined,
|
||||
};
|
||||
};
|
||||
const pending = try testing.allocator.alloc(observations.PendingCheck, 1);
|
||||
pending[0] = .{
|
||||
.check = &check_singleton.c,
|
||||
.state = .{ .complete = .pass },
|
||||
};
|
||||
var panel = CheckPanel{ .allocator = testing.allocator, .pending = pending };
|
||||
defer panel.deinit();
|
||||
|
||||
var journal = try makeEmptyJournal(testing.allocator);
|
||||
defer journal.deinit();
|
||||
|
||||
var view = try build(testing.allocator, &panel, &journal, false);
|
||||
defer view.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), view.rows.len);
|
||||
try testing.expectEqual(@as(usize, 0), view.total_active);
|
||||
try testing.expectEqual(@as(usize, 0), view.total_acked);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue