facett-log/src/lib.rs
//! **facett-log** — a generic **timestamped event-log / console** pane, factored
//! out of nornir's viz action-log overlay (`src/viz/action_log.rs`). The nornir
//! original couples a ring buffer to four sinks (stderr, a `/tmp` file, and a
//! durable Iceberg warehouse table) — all *domain* infrastructure. THIS is just
//! the **reusable UI half**: a bounded ring of `(seq, stamp, kind, detail)`
//! entries painted as kind-coloured rows in a scroll area, with a `state_json`
//! that exposes the live tail + counts so a headless driver reads exactly what
//! the overlay shows.
//!
//! The host owns the sinks (file/warehouse/stderr) and feeds entries in via
//! [`LogView::push`]; this crate keeps no I/O, no clock, no threads — the
//! timestamp is a plain string the host stamps (FC-7: time is injected, never
//! read ambiently here), so the view renders identically headless.
//!
//! ## The canonical Elm split (FC-2 / FC-3 / FC-8 / FC-9)
//! Every input is a [`Msg`] and the **only** mutation path is
//! [`LogView::update`]; [`LogView::view`] is a **pure** function of `&self` that
//! paints and *returns* the `Msg`s it produced. The in-view console controls —
//! the per-**kind** level filter, the free-text **search**, the **follow-tail**
//! toggle, and row **selection** — all route through the same `Msg`s a headless
//! driver ([`facett_core::harness`]) feeds, so live == headless. The
//! [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge (form 3, so the
//! rich `state_json` below is preserved verbatim) writes the [`Facet`] impl.
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Elm, FacetCaps, a11y};
use serde::{Deserialize, Serialize};
/// Max entries retained in the ring (older ones drop off the front). Mirrors the
/// nornir overlay's cap; the host's durable sink keeps the full history.
pub const DEFAULT_CAP: usize = 256;
/// Coarse action category — drives the row colour + the readable tag. Kept small
/// and greppable, matching nornir's `viz::action_log::Kind` vocabulary so a host
/// can map its own events onto these words.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
/// App lifecycle (launch, workspace switch, reload).
Life,
/// A tab became active.
Tab,
/// A button / control was clicked.
Click,
/// A user-issued query (search / lookup / call path).
Query,
/// An outgoing call/RPC to a backend.
Rpc,
/// An error surfaced anywhere in the flow.
Error,
}
/// Every [`Kind`], in the canonical order the header filter + `by_kind` tally use.
pub const ALL_KINDS: [Kind; 6] =
[Kind::Life, Kind::Tab, Kind::Click, Kind::Query, Kind::Rpc, Kind::Error];
impl Kind {
/// The short, greppable tag painted in the row and emitted in `state_json`.
pub fn tag(self) -> &'static str {
match self {
Kind::Life => "LIFE",
Kind::Tab => "TAB",
Kind::Click => "CLICK",
Kind::Query => "QUERY",
Kind::Rpc => "RPC",
Kind::Error => "ERROR",
}
}
/// Parse a tag (case-insensitive) back to a `Kind`; `None` if unknown.
pub fn from_tag(s: &str) -> Option<Kind> {
match s.to_ascii_uppercase().as_str() {
"LIFE" => Some(Kind::Life),
"TAB" => Some(Kind::Tab),
"CLICK" => Some(Kind::Click),
"QUERY" => Some(Kind::Query),
"RPC" => Some(Kind::Rpc),
"ERROR" => Some(Kind::Error),
_ => None,
}
}
/// The row colour for this kind, on the active [`Theme`](facett_core::theme::Theme).
/// Errors are the one warm red used across facett (no semantic red in the base
/// palette); the rest pick palette accents so the log re-skins per theme.
fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
match self {
Kind::Error => facett_core::look::RED,
Kind::Rpc => th.accent,
Kind::Query => th.point,
Kind::Click => th.text,
Kind::Tab => th.accent,
Kind::Life => th.text_dim,
}
}
}
/// One timestamped log entry. `stamp` is a host-supplied wall-clock string
/// (`HH:MM:SS.mmm` in nornir) so the view holds no clock (FC-7).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
/// Monotonic sequence number — stable ordering even within one stamp.
pub seq: u64,
/// Pre-formatted wall-clock stamp (host-stamped; the view never reads a clock).
pub stamp: String,
/// The action category.
pub kind: Kind,
/// Free-form detail (`tab=Bench`, `Search.Query q="foo"`, an error text, …).
pub detail: String,
}
/// A robot-/CLI-addressable control message — the named boundary a headless
/// driver (or the host toolbar / canvas gestures) drives the view through.
/// [`LogView::update`] applies it; it is the **only** way to mutate a
/// [`LogState`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Ingest one entry (the host's primary input): assigns the next `seq`,
/// stamps it with the host-supplied `stamp`, and rolls the ring.
Push {
/// The action category.
kind: Kind,
/// The host-stamped wall-clock string (FC-7).
stamp: String,
/// Free-form detail.
detail: String,
},
/// Empty the ring (the lifetime `count` counter is preserved).
Clear,
/// Toggle one [`Kind`] in/out of the level filter (hidden when present).
ToggleKind(Kind),
/// Set the case-insensitive substring the `detail` must contain to show.
SetSearch(String),
/// Reset the level filter and the search box (show everything).
ClearFilters,
/// Flip stick-to-bottom (auto-scroll to the newest row).
ToggleFollowTail,
/// Select the row with this `seq` (no-op if it is not in the ring).
Select(u64),
/// Clear any row selection.
ClearSelection,
}
/// Side work as data (FC-8). Uninhabited on purpose: the log renders a host-fed
/// trail and performs **no** I/O — every [`Msg`] mutates only the in-memory
/// [`LogState`] — so [`LogView::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// **The complete observable state (FC-1 / FC-3)** of a [`LogView`], in one
/// serializable, round-trippable struct: the bounded ring + lifetime `seq`
/// counter, plus the mutable console controls (the per-kind level filter, the
/// search substring, the follow-tail toggle, and the selected row). The **only**
/// thing that mutates it is [`LogView::update`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LogState {
/// Tab/panel title (the deck keys facets off it).
pub title: String,
/// Ring capacity (older entries drop off the front past it).
pub cap: usize,
/// Lifetime entry counter — survives ring drop (the header total).
pub seq: u64,
/// The bounded ring, oldest→newest.
pub ring: std::collections::VecDeque<Entry>,
/// Kinds toggled **off** in the level filter (empty = every kind shown).
pub hidden_kinds: Vec<Kind>,
/// Case-insensitive `detail` substring filter (empty = no text filter).
pub search: String,
/// Stick-to-bottom: auto-scroll to the newest row.
pub follow_tail: bool,
/// Selected row `seq` (stable key, FC-5), if any.
pub selected: Option<u64>,
}
impl LogState {
fn empty(title: String, cap: usize) -> Self {
Self {
title,
cap,
seq: 0,
ring: std::collections::VecDeque::new(),
hidden_kinds: Vec::new(),
search: String::new(),
follow_tail: true,
selected: None,
}
}
}
/// A scrollable, kind-coloured event-log pane over a bounded ring of [`Entry`]s.
/// The host pushes entries; the pure [`view`](Self::view) paints the (filtered)
/// tail and exposes it as JSON, and every input flows through [`update`](Self::update).
pub struct LogView {
/// All observable state (FC-3).
state: LogState,
}
impl LogView {
/// An empty log titled `title`, with the [`DEFAULT_CAP`] ring.
pub fn new(title: impl Into<String>) -> Self {
Self { state: LogState::empty(title.into(), DEFAULT_CAP) }
}
/// Override the ring capacity (older entries drop off the front past it).
pub fn with_cap(mut self, cap: usize) -> Self {
self.state.cap = cap.max(1);
while self.state.ring.len() > self.state.cap {
self.state.ring.pop_front();
}
self
}
/// **FC-3** — read the complete observable state at any frame boundary.
pub fn state(&self) -> &LogState {
&self.state
}
/// Re-title the view (the deck keys facets off [`Facet::title`]). Title is
/// host/deck metadata, not an interactive input, so it is set directly rather
/// than through a `Msg`.
pub fn set_title(&mut self, title: impl Into<String>) {
self.state.title = title.into();
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. The in-view console
/// controls route their [`Msg`]s here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Push { kind, stamp, detail } => {
self.state.seq += 1;
let entry = Entry { seq: self.state.seq, stamp, kind, detail };
if self.state.ring.len() == self.state.cap {
self.state.ring.pop_front();
}
self.state.ring.push_back(entry);
}
Msg::Clear => self.state.ring.clear(),
Msg::ToggleKind(k) => {
if let Some(i) = self.state.hidden_kinds.iter().position(|h| *h == k) {
self.state.hidden_kinds.remove(i);
} else {
self.state.hidden_kinds.push(k);
}
}
Msg::SetSearch(s) => self.state.search = s,
Msg::ClearFilters => {
self.state.hidden_kinds.clear();
self.state.search.clear();
}
Msg::ToggleFollowTail => self.state.follow_tail = !self.state.follow_tail,
Msg::Select(seq) => {
if self.state.ring.iter().any(|e| e.seq == seq) {
self.state.selected = Some(seq);
}
}
Msg::ClearSelection => self.state.selected = None,
}
Vec::new()
}
/// Record one entry (the convenience wrapper over [`Msg::Push`]): assigns the
/// next `seq`, stamps it, rolls the ring, and returns the assigned `seq`.
pub fn push(&mut self, kind: Kind, stamp: impl Into<String>, detail: impl Into<String>) -> u64 {
let _ = self.update(Msg::Push { kind, stamp: stamp.into(), detail: detail.into() });
self.state.seq
}
/// Total entries recorded this session (the live counter; survives ring drop).
pub fn count(&self) -> u64 {
self.state.seq
}
/// The most recent `n` entries of the whole ring (oldest→newest), ignoring the
/// filter — the raw history a host inspects.
pub fn recent(&self, n: usize) -> Vec<Entry> {
let start = self.state.ring.len().saturating_sub(n);
self.state.ring.iter().skip(start).cloned().collect()
}
/// How many entries of the given `kind` are currently in the ring — drives the
/// error badge in the header and the per-kind tally in `state_json`.
pub fn count_kind(&self, kind: Kind) -> usize {
self.state.ring.iter().filter(|e| e.kind == kind).count()
}
/// Whether `e` passes the active level filter + search — the single predicate
/// the painter and `state_json` both read, so what a test asserts IS what paints.
fn is_visible(&self, e: &Entry) -> bool {
if self.state.hidden_kinds.contains(&e.kind) {
return false;
}
if !self.state.search.is_empty()
&& !e.detail.to_lowercase().contains(&self.state.search.to_lowercase())
{
return false;
}
true
}
/// The entries currently shown (filtered), oldest→newest.
fn visible(&self) -> Vec<&Entry> {
self.state.ring.iter().filter(|e| self.is_visible(e)).collect()
}
/// The selected [`Entry`], resolved from its stable `seq`, if any.
pub fn selected_entry(&self) -> Option<&Entry> {
let s = self.state.selected?;
self.state.ring.iter().find(|e| e.seq == s)
}
/// **Discovery-contract `local()`** — the canonical no-arg ctor the test
/// matrix calls. Seeds a small realistic trail (a launch, a tab switch, a
/// click, a query, an RPC, and one error) so the pane renders real rows with
/// no host attached.
pub fn local() -> Self {
let mut v = Self::new("Log");
v.push(Kind::Life, "10:00:00.000", "action-log started");
v.push(Kind::Tab, "10:00:01.120", "tab=Warehouse");
v.push(Kind::Click, "10:00:02.340", "button=Refresh");
v.push(Kind::Query, "10:00:03.010", "Search.Query q=\"ingest\"");
v.push(Kind::Rpc, "10:00:03.012", "Warehouse.Tables ws=demo");
v.push(Kind::Error, "10:00:04.500", "scan failed: table not found");
v
}
/// **Discovery-contract `remote()`** — same surface, the variant a host builds
/// from a server-streamed trail. No transport here, so identical to `local()`
/// but re-titled, kept so the matrix sees both ctors.
pub fn remote() -> Self {
let mut v = Self::local();
v.state.title = "Log (remote)".into();
v
}
/// The whole ring as one text block (oldest→newest), formatted exactly as the
/// rows paint (`stamp [TAG] detail`) — the copyable view of the log.
pub fn as_text(&self) -> String {
self.state
.ring
.iter()
.map(|e| format!("{} [{}] {}", e.stamp, e.kind.tag(), e.detail))
.collect::<Vec<_>>()
.join("\n")
}
}
// ── typed copy (§16) — the log is a read-only text source ─────────────────────
impl CopySource for LogView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
if self.state.ring.is_empty() {
return None;
}
Some(ClipPayload::Text(self.as_text()))
}
}
impl LogView {
/// **FC-9 pure view** — paints the header (total + error tally + follow-tail
/// toggle), the filter row (search box + per-kind chips), and the kind-coloured
/// (filtered) tail, and **returns** the [`Msg`]s this frame's interactions
/// produced. It does **not** mutate `self`; the
/// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
/// returned messages through [`update`](Self::update), so a headless driver
/// feeding the same [`Msg`]s reproduces the live behaviour exactly.
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = facett_core::theme(ui);
// ── header: total + error tally + follow-tail ─────────────────────────
let errs = self.count_kind(Kind::Error);
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(format!("{} entries", self.count())).color(th.text).strong());
ui.separator();
ui.label(
egui::RichText::new(format!("✗ {errs} error"))
.color(if errs > 0 { facett_core::look::RED } else { th.text_dim }),
);
ui.separator();
let mut follow = self.state.follow_tail;
if ui.checkbox(&mut follow, "follow").on_hover_text("stick to the newest row").changed() {
msgs.push(Msg::ToggleFollowTail);
}
});
// ── filter row: search box + per-kind level chips ─────────────────────
let visible = self.visible();
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("🔍").color(th.text_dim));
// Pure text-edit pattern: edit a local buffer, emit on change.
let mut buf = self.state.search.clone();
let resp = ui.add(egui::TextEdit::singleline(&mut buf).hint_text("filter detail…").desired_width(140.0));
if resp.changed() {
msgs.push(Msg::SetSearch(buf));
}
for k in ALL_KINDS {
let shown = !self.state.hidden_kinds.contains(&k);
if ui.selectable_label(shown, k.tag()).on_hover_text("toggle this kind").clicked() {
msgs.push(Msg::ToggleKind(k));
}
}
let filtering = !self.state.hidden_kinds.is_empty() || !self.state.search.is_empty();
if filtering {
ui.label(egui::RichText::new(format!("({} shown)", visible.len())).small().color(th.text_dim));
if ui.button("clear").on_hover_text("reset filters").clicked() {
msgs.push(Msg::ClearFilters);
}
}
});
ui.separator();
// ── the (filtered) tail, kind-coloured, newest at the bottom ──────────
let base_id = ui.id().with("log-rows");
let mut clicked: Option<u64> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(self.state.follow_tail)
.show(ui, |ui| {
for e in &visible {
let col = e.kind.color(&th);
let text = format!("{} [{}] {}", e.stamp, e.kind.tag(), e.detail);
let is_sel = self.state.selected == Some(e.seq);
// Stable id + AccessKit node keyed on the entry seq (FC-4/FC-5) so a
// robot driver finds a specific line, reads its text, and clicks it.
let resp = ui
.push_id(a11y::stable_id(base_id, e.seq.to_string()), |ui| {
ui.selectable_label(is_sel, egui::RichText::new(&text).monospace().color(col))
})
.inner;
let label = text.clone();
resp.widget_info(move || a11y::Semantics::list_item(label.clone(), is_sel).widget_info());
if resp.clicked() {
clicked = Some(e.seq);
}
}
});
// Click toggles selection — emitted as a `Msg`, applied by `update` (so a
// headless `Msg::Select`/`ClearSelection` drives the identical transition).
if let Some(seq) = clicked {
if self.state.selected == Some(seq) {
msgs.push(Msg::ClearSelection);
} else {
msgs.push(Msg::Select(seq));
}
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-log::LogView::view",
"ui_render",
true,
&format!("entries={} errors={} shown={}", self.count(), errs, visible.len()),
);
msgs
}
/// The rich `state_json` the deck / render-matrix read: the header counts, the
/// per-kind tally, the active console controls, and the (filtered) tail exactly
/// as the overlay paints it.
fn state_json_impl(&self) -> serde_json::Value {
// Per-kind tally so a driver asserts category counts without re-deriving.
let mut by_kind = serde_json::Map::new();
for k in ALL_KINDS {
by_kind.insert(k.tag().to_string(), serde_json::json!(self.count_kind(k)));
}
// The live (filtered) tail — last 64 as the overlay paints them.
let visible = self.visible();
let start = visible.len().saturating_sub(64);
let tail = visible[start..]
.iter()
.map(|e| {
serde_json::json!({
"seq": e.seq,
"stamp": e.stamp,
"kind": e.kind.tag(),
"detail": e.detail,
})
})
.collect::<Vec<_>>();
serde_json::json!({
"title": self.state.title,
"count": self.count(),
"cap": self.state.cap,
"in_ring": self.state.ring.len(),
"errors": self.count_kind(Kind::Error),
"by_kind": by_kind,
// Console controls (FC-3): the observable, addressable filter surface.
"hidden_kinds": self.state.hidden_kinds.iter().map(|k| k.tag()).collect::<Vec<_>>(),
"search": self.state.search,
"follow_tail": self.state.follow_tail,
"selected": self.state.selected,
"visible": visible.len(),
// The live tail (last 64) as the overlay paints it — what the user sees.
"tail": tail,
})
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
//
// The Model/Msg/Effect/pure-view split lives on the inherent methods; the `Elm`
// impl delegates to them verbatim.
impl Elm for LogView {
type Model = LogState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.state.title
}
fn state(&self) -> &LogState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
LogView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
LogView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for LogView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), and the extra overrides
// below. **Form 3** (`custom_state_json`) because the log publishes a RICHER
// `state_json` than plain `serde(state())` — the per-kind tally + the filtered
// tail + the console controls the render matrix reads — so the macro's default
// serde `state_json` is suppressed and the custom one supplied here.
facett_core::impl_facet_via_elm!(LogView, custom_state_json, {
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn state_json(&self) -> serde_json::Value {
self.state_json_impl()
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`Elm::update`](facett_core::Elm::update)
/// mutation path. Malformed / unknown JSON is ignored (never a panic), so a
/// headless robot or the shared `probe_and_emit` harness drives the console
/// filter / selection without the concrete `Msg` type at the call site.
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn selection_json(&self) -> serde_json::Value {
match self.selected_entry() {
Some(e) => serde_json::json!({
"seq": e.seq,
"stamp": e.stamp,
"kind": e.kind.tag(),
"detail": e.detail,
}),
None => serde_json::Value::Null,
}
}
fn kind(&self) -> &'static str {
"log"
}
/// Themed (kind colours come from the palette), **resizable** (the scroll area
/// flexes to the host's size), a row is click-**selectable**, and the detail is
/// **searchable** (the free-text + per-kind filter).
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().selectable().searchable()
}
});
#[cfg(test)]
mod tests {
use super::*;
use facett_core::{Facet, harness};
#[test]
fn ring_caps_and_orders() {
let mut v = LogView::new("L").with_cap(10);
for i in 0..(10 + 5) {
v.push(Kind::Click, "00:00:00.000", format!("click {i}"));
}
let recent = v.recent(usize::MAX);
// Ring never exceeds cap; seq strictly increasing; count tracks every push.
assert_eq!(recent.len(), 10);
for w in recent.windows(2) {
assert!(w[1].seq > w[0].seq);
}
assert_eq!(v.count(), 15);
// The oldest five rolled off the front: first retained is click 5.
assert_eq!(recent.first().unwrap().detail, "click 5");
}
#[test]
fn kinds_have_distinct_tags_and_roundtrip() {
let mut tags: Vec<&str> = ALL_KINDS.iter().map(|k| k.tag()).collect();
let n = tags.len();
tags.sort_unstable();
tags.dedup();
assert_eq!(tags.len(), n, "tags are distinct");
for k in ALL_KINDS {
assert_eq!(Kind::from_tag(k.tag()), Some(k), "tag round-trips");
}
assert_eq!(Kind::from_tag("nope"), None);
}
/// inject-assert (LAW 6): feed a real trail, assert `state_json` reports the
/// exact tail, counts, and per-kind tally a driver would read.
#[test]
fn state_json_reports_tail_counts_and_kinds() {
let mut v = LogView::new("Log");
v.push(Kind::Life, "10:00:00.000", "started");
v.push(Kind::Tab, "10:00:01.000", "tab=Warehouse");
v.push(Kind::Error, "10:00:02.000", "boom");
v.push(Kind::Error, "10:00:03.000", "boom again");
let j = Facet::state_json(&v);
assert_eq!(j["count"], 4);
assert_eq!(j["in_ring"], 4);
assert_eq!(j["errors"], 2);
assert_eq!(j["by_kind"]["ERROR"], 2);
assert_eq!(j["by_kind"]["LIFE"], 1);
assert_eq!(j["by_kind"]["TAB"], 1);
assert_eq!(j["by_kind"]["CLICK"], 0);
// Default console controls are inert (nothing hidden, no search).
assert_eq!(j["visible"], 4);
assert_eq!(j["search"], "");
assert_eq!(j["follow_tail"], true);
assert!(j["hidden_kinds"].as_array().unwrap().is_empty());
let tail = j["tail"].as_array().unwrap();
assert_eq!(tail.len(), 4);
// Oldest→newest, with kind tag + detail surfaced.
assert_eq!(tail[0]["kind"], "LIFE");
assert_eq!(tail[0]["detail"], "started");
assert_eq!(tail[3]["kind"], "ERROR");
assert_eq!(tail[3]["detail"], "boom again");
assert_eq!(tail[3]["seq"], 4);
}
#[test]
fn copy_payload_is_the_whole_ring_as_text() {
let mut v = LogView::new("Log");
v.push(Kind::Life, "10:00:00.000", "started");
v.push(Kind::Error, "10:00:02.000", "boom");
let p = v.copy_payload().expect("non-empty log copies");
assert_eq!(p.kind(), ClipKind::Text);
let text = p.as_text();
assert_eq!(text, "10:00:00.000 [LIFE] started\n10:00:02.000 [ERROR] boom");
// The Facet copy() hook mirrors the same text.
assert_eq!(<LogView as Facet>::copy(&mut v), Some(text));
// An empty log offers nothing to copy.
assert!(LogView::new("empty").copy_payload().is_none());
}
#[test]
fn local_ctor_seeds_a_real_renderable_trail() {
let v = LogView::local();
let j = Facet::state_json(&v);
// local() must produce a non-empty, sane object (the find-them-all gate).
assert!(j.as_object().map(|o| !o.is_empty()).unwrap_or(false));
assert!(v.count() >= 5, "local seeds a multi-line trail");
assert_eq!(j["errors"], 1, "local includes exactly one error line");
assert_eq!(<LogView as Facet>::title(&v), "Log");
assert_eq!(<LogView as Facet>::title(&LogView::remote()), "Log (remote)");
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting `LogState` — no egui, no GPU. ──────────
#[test]
fn harness_push_and_clear_drive_the_ring() {
let mut v = LogView::new("Log");
let snap = harness::snapshot(
&mut v,
[
Msg::Push { kind: Kind::Life, stamp: "t0".into(), detail: "up".into() },
Msg::Push { kind: Kind::Error, stamp: "t1".into(), detail: "boom".into() },
],
);
assert_eq!(snap.seq, 2, "two pushes bumped the lifetime counter");
assert_eq!(snap.ring.len(), 2);
// Clear empties the ring but keeps the lifetime `seq`.
let snap = harness::snapshot(&mut v, [Msg::Clear]);
assert!(snap.ring.is_empty());
assert_eq!(snap.seq, 2, "Clear preserves the lifetime count");
assert_eq!(&snap, v.state(), "snapshot is a clone of the live state");
}
#[test]
fn harness_level_filter_and_search_narrow_the_visible_tail() {
let mut v = LogView::local();
// Hide CLICK + QUERY → those rows drop out of the visible tail.
harness::drive(&mut v, [Msg::ToggleKind(Kind::Click), Msg::ToggleKind(Kind::Query)]);
let j = Facet::state_json(&v);
assert_eq!(j["visible"], 4, "6 seeded − 2 hidden kinds = 4 shown");
assert_eq!(j["count"], 6, "the lifetime count still reflects every push");
let tags: Vec<&str> = j["tail"].as_array().unwrap().iter().map(|e| e["kind"].as_str().unwrap()).collect();
assert!(!tags.contains(&"CLICK") && !tags.contains(&"QUERY"), "hidden kinds are gone: {tags:?}");
// Free-text search on `detail` (case-insensitive) narrows further.
harness::drive(&mut v, [Msg::ClearFilters, Msg::SetSearch("SCAN".into())]);
let j = Facet::state_json(&v);
assert_eq!(j["visible"], 1, "only the 'scan failed' error matches 'SCAN'");
assert_eq!(j["search"], "SCAN");
assert_eq!(j["tail"][0]["kind"], "ERROR");
// ClearFilters restores everything.
harness::drive(&mut v, [Msg::ClearFilters]);
assert_eq!(Facet::state_json(&v)["visible"], 6);
}
#[test]
fn harness_select_transitions_and_selection_json() {
let mut v = LogView::local();
assert!(v.state().selected.is_none());
assert_eq!(v.selection_json(), serde_json::Value::Null);
// Select an existing row by its seq.
harness::drive(&mut v, [Msg::Select(6)]);
assert_eq!(v.state().selected, Some(6));
assert_eq!(v.selection_json()["kind"], "ERROR");
assert_eq!(v.selection_json()["seq"], 6);
// Selecting an absent seq is a no-op.
harness::drive(&mut v, [Msg::Select(999)]);
assert_eq!(v.state().selected, Some(6), "selecting an absent seq is a no-op");
// ClearSelection resets it.
harness::drive(&mut v, [Msg::ClearSelection]);
assert!(v.state().selected.is_none());
}
#[test]
fn harness_follow_tail_toggles() {
let mut v = LogView::local();
assert!(v.state().follow_tail, "follow-tail defaults on");
let snap = harness::snapshot(&mut v, [Msg::ToggleFollowTail]);
assert!(!snap.follow_tail, "toggled off");
let snap = harness::snapshot(&mut v, [Msg::ToggleFollowTail]);
assert!(snap.follow_tail, "toggled back on");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
let mut v = LogView::local();
// FC-8: the log does no I/O, so the effect stream is always empty.
let effects = harness::drive(
&mut v,
[Msg::ToggleKind(Kind::Rpc), Msg::SetSearch("q".into()), Msg::Select(4)],
);
assert!(effects.is_empty(), "FC-8: log emits no Effects");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(v.state()).unwrap();
assert_eq!(json["selected"], 4);
assert_eq!(json["search"], "q");
let back: LogState = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
}
}