Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}