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

nordisk/dwarves

dwarves-gui/src/fleet.rs

//! **The Fleet coverage-board tab** (autotest North Star) — a full-system-autotest
//! run rendered as a board: rows = fleet members (`nornir`/`dwarves`/`holger`/`korp`…),
//! columns = driven surfaces × {fat/egui, thin/web}, each cell green/red/grey, under a
//! prominent `Gap == ∅` verdict banner (green when fully covered, red naming the missing
//! nodes).
//!
//! Thin host wrapper that **MOUNTS the SHARED [`facett_fleet_view::FleetView`]** — the
//! fleet analogue of the `facett-matrix` grid the Benches tab reuses. dwarves invents no
//! second coverage model here: the view is fed a [`FleetRunView`] built from
//! `nornir-testmatrix`'s own [`GateReport`] + [`TestResultRow`]s + declared members
//! (consumed as data), and does its own board layout + banner + `state_json` oracle.
//!
//! ## The feed (offline seam, host-agnostic — the DEFAULT)
//! Like the Release/Benches/Saga tabs, this reads an artifact a fleet run writes rather
//! than requiring a live container run: **`$DWARVES_FLEET_RUN`** — a whole-file JSON
//! [`FleetRunView`], or a tagged JSONL stream (`{"report":…}` / `{"row":…}` /
//! `{"member":…}` / `{"run":…}`) of the edda `crates/nornir-fleet` harness's [`GateReport`] +
//! emitted [`TestResultRow`]s + members.
//!
//! ## The demo board is gone (audit 2026-09-10)
//! Missing/unreadable/empty used to mean `FleetView::local()` — the shared component's
//! *discovery-contract demo*: a full member×surface board with a deliberate
//! `chat@thin` hole, so the tab opened on a RED `Gap == ∅` banner naming a missing node
//! that no run had looked for. Its intent was honesty (better red than falsely green),
//! but a coverage board is a claim about what was DRIVEN, and none of it was. A
//! `source: "demo"` tag rode alongside; nobody reads a tag off a banner.
//!
//! Now: no fleet run ⇒ no board, and the shared
//! [`facett_core::empty::EmptyState`] names `$DWARVES_FLEET_RUN` and says why.
//! `state_json`'s `source` is `file` or `empty`, and `empty` carries the named block.
//!
//! ## The LIVE feed (seam left open — no container run is built here)
//! Both live legs land in the SAME [`FleetTab::with_run`] one-liner the offline seam
//! already exercises:
//! - **fat / in-process**: build the [`FleetRunView`] from an in-process
//!   `edda crates/nornir-fleet` `FleetHarness` run (harness → `GateReport` + rows + members).
//!   TODO(fat): pull the fleet harness + call its headless run entry here once it
//!   exposes one (it currently drives containers/robots — out of scope for a view tab).
//! - **thin / server-streamed**: build the [`FleetRunView`] from `TestResultRow`s
//!   streamed off a running fleet server + its final `GateReport`.
//!   TODO(thin): wire once the fleet stream RPC lands (mirror `nornir-funnel-embed`).

use facett_core::empty::EmptyState;
use facett_core::Facet;
use facett_fleet_view::{FleetMember, FleetRunView, FleetView, GateReport, TestResultRow};

/// The env var naming a fleet-run output file the offline seam loads.
const FLEET_RUN_ENV: &str = "DWARVES_FLEET_RUN";

/// The fleet feed, named the way an operator would go look it up.
const FLEET_FEED: &str = "$DWARVES_FLEET_RUN (nornir-fleet GateReport + TestResultRows)";

/// One line of a tagged-JSONL fleet-run stream: the harness's `GateReport`, an emitted
/// `TestResultRow`, a declared `FleetMember`, or a whole `FleetRunView` in one object.
/// Externally tagged (`{"report":…}` / `{"row":…}` / `{"member":…}` / `{"run":…}`) so a
/// heterogeneous stream parses unambiguously line by line.
#[derive(serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum FleetLine {
    Report(GateReport),
    Row(TestResultRow),
    Member(FleetMember),
    Run(FleetRunView),
}

/// The Fleet coverage-board tab: the SHARED [`FleetView`], fed offline from a fleet-run
/// file (or the never-blank demo board).
pub struct FleetTab {
    /// The reused shared board — dwarves mounts it, never re-implements it.
    inner: FleetView,
    /// Where the board came from: `"file"` (a real run) or `"empty"` (no run).
    source: &'static str,
    /// Set when no fleet run was found — the NAMED empty state, drawn in place of the
    /// board. This is what replaced `FleetView::local()`'s demo grid + red banner.
    empty: Option<EmptyState>,
}

impl FleetTab {
    /// Load the board from `$DWARVES_FLEET_RUN` if present + parseable, else the NAMED
    /// empty state over that env var.
    pub fn new() -> Self {
        match Self::load_env() {
            Some(run) => Self::with_run(run),
            None => Self::unrun(),
        }
    }

    /// **Mount the shared view over a concrete run** — the single seam the offline file
    /// load AND the (TODO) fat/thin LIVE feeds all funnel through.
    pub fn with_run(run: FleetRunView) -> Self {
        let empty = (run.rows.is_empty() && run.members.is_empty()).then(|| {
            EmptyState::no_rows(
                FLEET_FEED,
                "the loaded fleet run has no members and no rows",
            )
            .with_hint("drive the fleet harness so it emits TestResultRows")
        });
        let view = FleetView::new("Fleet").with_run(run.report, run.rows, run.members);
        Self {
            inner: view,
            source: "file",
            empty,
        }
    }

    /// **No fleet run** — an EMPTY board carrying the named reason. A coverage board
    /// nobody drove has no verdict to render, so it renders none.
    pub fn unrun() -> Self {
        let where_ = match std::env::var(FLEET_RUN_ENV) {
            Ok(p) if !p.trim().is_empty() => format!("`{p}` is missing, unreadable or empty"),
            _ => format!("{FLEET_RUN_ENV} is not set"),
        };
        Self {
            inner: FleetView::new("Fleet"),
            source: "empty",
            empty: Some(
                EmptyState::not_configured(FLEET_FEED, where_)
                    .with_hint("run the fleet harness and point $DWARVES_FLEET_RUN at its output"),
            ),
        }
    }

    /// The offline seam: read `$DWARVES_FLEET_RUN` and parse it. `None` on
    /// absence / unreadable / empty ⇒ the caller falls back to [`unrun`](Self::unrun).
    fn load_env() -> Option<FleetRunView> {
        let path = std::env::var_os(FLEET_RUN_ENV)?;
        if path.is_empty() {
            return None;
        }
        let text = std::fs::read_to_string(&path).ok()?;
        Self::parse(&text)
    }

    /// Parse a fleet-run file: a whole-file JSON [`FleetRunView`], else a tagged-JSONL
    /// stream of the harness's report + rows + members. `None` if nothing usable is
    /// found (an empty/garbage file falls back to the demo, never blank).
    fn parse(text: &str) -> Option<FleetRunView> {
        let t = text.trim();
        if t.is_empty() {
            return None;
        }
        // (1) whole-file JSON FleetRunView — the canonical round-trippable form.
        if let Ok(run) = serde_json::from_str::<FleetRunView>(t) {
            if !run.rows.is_empty() || !run.members.is_empty() {
                return Some(run);
            }
        }
        // (2) tagged JSONL — one envelope per line (malformed lines skipped, never a panic).
        let mut run = FleetRunView::default();
        let mut saw = false;
        for line in t.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            match serde_json::from_str::<FleetLine>(line) {
                Ok(FleetLine::Report(r)) => {
                    run.report = r;
                    saw = true;
                }
                Ok(FleetLine::Row(row)) => {
                    run.rows.push(row);
                    saw = true;
                }
                Ok(FleetLine::Member(m)) => {
                    run.members.push(m);
                    saw = true;
                }
                Ok(FleetLine::Run(r)) => {
                    run = r;
                    saw = true;
                }
                Err(_) => {}
            }
        }
        if saw && (!run.rows.is_empty() || !run.members.is_empty()) {
            Some(run)
        } else {
            None
        }
    }
}

impl Default for FleetTab {
    fn default() -> Self {
        Self::new()
    }
}

impl Facet for FleetTab {
    fn title(&self) -> &str {
        "Fleet"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        if let Some(e) = &self.empty {
            // No run ⇒ no grid and no `Gap == ∅` banner in either colour. The banner is
            // a verdict on a run; with no run there is nothing to be a verdict about.
            e.ui(ui);
            return;
        }
        Facet::ui(&mut self.inner, ui);
    }

    fn state_json(&self) -> serde_json::Value {
        // The shared board's state is already self-complete (gate_green, missing,
        // member×cell verdicts, tallies) — pass it through, tagging the component +
        // where the run came from (`file`/`demo`) so the gate tells a real run apart.
        let mut j = Facet::state_json(&self.inner);
        if let Some(obj) = j.as_object_mut() {
            obj.insert("component".into(), serde_json::json!("FleetView"));
            obj.insert("source".into(), serde_json::json!(self.source));
            obj.insert("feed".into(), serde_json::json!(FLEET_FEED));
            if let Some(e) = &self.empty {
                obj.insert("fleet_empty".into(), e.state_json());
                // No run ⇒ no verdict. The inner board over zero members folds
                // `gate_green: true` / `gap_clean: true`; a gate nobody ran is not green.
                for k in ["gate_green", "gap_clean"] {
                    obj.insert(k.into(), serde_json::Value::Null);
                }
            }
        }
        j
    }

    fn update_json(&mut self, msg_json: &str) {
        // The warehouse selector broadcasts `{"set_workspace":…}` to every tab. The
        // fleet board is CONSTELLATION-wide (not bound to one workspace), so swallow it.
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            if v.get("set_workspace").is_some() {
                return;
            }
        }
        // Everything else (select_member / clear_selection) drives the shared view.
        Facet::update_json(&mut self.inner, msg_json);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tabbed::TabbedView;
    // `Panel` is only needed to box the tab for the robot drive below — a test-only
    // need, so it is imported here rather than at module scope (where it would be an
    // unused import in the non-test lib build).
    use facett_core::Panel;
    use facett_fleet_view::{status, MemberHealth};

    /// The two viz tabs × {fat, thin} the fleet tests drive, as surface KEYS.
    const SURFACES: [&str; 4] = [
        "viz_tab:chat@fat",
        "viz_tab:chat@thin",
        "viz_tab:board@fat",
        "viz_tab:board@thin",
    ];

    /// **The gate report, DERIVED** — `SURFACES` differenced against a covered set with
    /// `hole` (if any) knocked out of it, exactly as `GateReport::compute` does at a real
    /// gate. Mirrors `facett-fleet-view`'s own `report_missing` helper.
    ///
    /// The holed report used to be hand-forged instead: `report.missing.push(hole)` on top
    /// of a `GateReport::default()`. That did not compile (`missing` lives on `gap`, and it
    /// holds `SurfaceNode`s, not strings) — and had it compiled it would have been the very
    /// thing this whole pane is being cleaned of: a gap NAMING a missing node while the
    /// report's own counts (`covered`/`total`) and its `is_clean()` verdict still said the
    /// fleet was fully covered. Deriving it keeps the banner, the counts and the cells
    /// consistent by construction.
    fn report_missing(hole: Option<&str>) -> GateReport {
        // `fleet_testmatrix` is `nornir-testmatrix` renamed — deliberately the REGISTRY
        // copy `facett-fleet-view` builds its `GateReport` from, not this crate's local
        // path copy behind the `testmatrix` feature. See the dev-dependency's note.
        use fleet_testmatrix::coverage::Allowlist;
        use fleet_testmatrix::discover::{viz_tabs, Surface};
        let mut surface = Surface::new();
        surface.extend(viz_tabs(["chat", "board"]));
        let covered: std::collections::BTreeSet<String> = surface
            .nodes
            .iter()
            .map(|n| n.key_str())
            .filter(|k| hole != Some(k.as_str()))
            .collect();
        GateReport::compute(
            "run-fleet",
            "nordisk",
            &surface,
            &covered,
            &Allowlist::new(),
        )
    }

    /// A fully-covered run: the gate report is derived over a covered set with no hole
    /// (`Gap == ∅`, green), and every member drives every surface PASS → every cell green.
    fn covered_run() -> FleetRunView {
        let surfaces = SURFACES;
        let mut rows = Vec::new();
        for member in ["nornir", "dwarves", "holger"] {
            for s in surfaces {
                rows.push(TestResultRow {
                    run_id: "run-fleet-green".into(),
                    repo: member.into(),
                    suite: member.into(),
                    test_name: format!("{member}::{s}"),
                    status: status::PASS.into(),
                    duration_ms: 1.0,
                    ts_micros: 100,
                    message: String::new(),
                    aspect: s.into(),
                    metric: 0.0,
                });
            }
        }
        let members = vec![
            FleetMember::new("nornir", MemberHealth::Ready),
            FleetMember::new("dwarves", MemberHealth::Ready),
            FleetMember::new("holger", MemberHealth::Ready),
        ];
        FleetRunView {
            report: report_missing(None),
            rows,
            members,
            selected: None,
        }
    }

    /// ROBOT SCENARIO (autotest North Star) — the **Fleet** coverage board mounted in the
    /// shell and driven over the remote seam (`state_json`/`update_json`), the SAME channel
    /// a `nornir-robotui` scenario / thin client use. Asserts the THREE verdicts of the
    /// mounted shared `FleetView`, and — the point of splitting (b) from (c) — that the
    /// board keeps *coverage* and *passing* apart:
    ///   (a) a fully-covered, all-passing `FleetRunView` shows the GREEN `Gap == ∅`
    ///       banner (`gate_green`, `cells_red == 0`, no `missing`) with EVERY
    ///       member×surface cell green;
    ///   (b) a surface NOBODY drove (`viz_tab:chat@thin`) shows the RED banner NAMING
    ///       that missing node, `gap_clean == false`, and every member's cell for it
    ///       GREY — `driven:false`, and **not red**, because nothing was measured;
    ///   (c) a surface that WAS driven and FAILED shows that one cell red and the
    ///       member's row red, while `Gap == ∅` stays CLEAN — every surface was
    ///       exercised, one of them broke, and those are different facts.
    ///
    /// (b) and (c) are the assertion that changed when the gate report started being
    /// DERIVED (see `report_missing`). The old (b) pushed a FAIL row for the undriven
    /// surface and asserted a red cell — the board claiming a measured failure on a
    /// test that never ran, which is the same class of thing as the demo board this
    /// pane just stopped drawing.
    ///
    /// Asserts over `state_json`, not "it rendered". Emits a `nornir-testmatrix` cell.
    #[test]
    fn robot_fleet_view_over_the_seam() {
        // ── (a) fully covered → GREEN Gap==∅ banner + all cells green ──────────────
        let green: Box<dyn Panel> = Box::new(FleetTab::with_run(covered_run()));
        let mut app = TabbedView::new("dwarves — Fleet", vec![("Fleet".into(), green)]);
        Facet::update_json(&mut app, r#"{"set_tab":"Fleet"}"#);
        let st = Facet::state_json(&app);
        let pane = &st["fleet"];
        let all_cells_green = pane["members"].as_array().unwrap().iter().all(|m| {
            m["cells"]
                .as_array()
                .unwrap()
                .iter()
                .all(|c| c["green"] == true)
        });
        let green_ok = pane["component"] == "FleetView"
            && pane["source"] == "file"
            && pane["gate_green"] == true
            && pane["cells_red"] == 0
            && pane["cells_green"].as_u64().unwrap_or(0) >= 12
            && pane["missing"].as_array().unwrap().is_empty()
            && all_cells_green;

        // ── (b) a surface NOBODY drove → RED banner naming it, and a GREY cell ────────
        //
        // The run is built HERE (a real `FleetRunView` with a hole punched in it); it
        // used to be `FleetTab::demo()`, the shared component's built-in demo board,
        // which the SHIPPED tab drew whenever no fleet run was present.
        //
        // The hole is cut from EVERY member's rows, because the gate's `covered` set is
        // global: a surface one member skipped is still covered if another drove it. Cut
        // it everywhere and the derived report and the rows agree about the same world.
        //
        // And the cell for it must be GREY, not red. This is the assertion that changed
        // when the report started being derived, and the change is a fix rather than a
        // concession. The old version pushed a FAIL row for the hole and asserted
        // `cells_red >= 1` — so the board claimed a *measured failure* on a surface no
        // test had ever executed. "Nobody looked" and "we looked and it broke" are
        // different facts, and a coverage board that renders them the same colour is
        // the same class of lie as the demo board this pane just stopped drawing.
        let hole = "viz_tab:chat@thin";
        let mut holed = covered_run();
        holed.rows.retain(|r| r.aspect != hole);
        // The gap is DERIVED from the same surface with `hole` knocked out of `covered`
        // — so the banner, the `missing` list and the covered/total counts cannot
        // disagree with one another. See `report_missing`.
        holed.report = report_missing(Some(hole));
        let red: Box<dyn Panel> = Box::new(FleetTab::with_run(holed));
        let mut app = TabbedView::new("dwarves — Fleet", vec![("Fleet".into(), red)]);
        Facet::update_json(&mut app, r#"{"set_tab":"Fleet"}"#);
        let st = Facet::state_json(&app);
        let pane = &st["fleet"];
        let names_missing = pane["missing"]
            .as_array()
            .unwrap()
            .iter()
            .any(|m| m == hole);
        // EVERY member's cell for the undriven surface is grey: not driven, not green,
        // and — the point — not red.
        let hole_cells_grey = pane["members"].as_array().unwrap().iter().all(|m| {
            m["cells"]
                .as_array()
                .unwrap()
                .iter()
                .find(|c| c["surface"] == hole)
                .map(|c| c["driven"] == false && c["green"] == false && c["red"] == false)
                .unwrap_or(false)
        });
        let red_ok = pane["source"] == "file"
            && pane["gate_green"] == false
            && pane["gap_clean"] == false
            && names_missing
            && hole_cells_grey
            // Nothing was measured as broken, so nothing is red.
            && pane["cells_red"] == 0
            && pane["cells_not_driven"].as_u64().unwrap_or(0) == 3;

        // ── (c) a surface that WAS driven and FAILED → a RED cell, gap still clean ────
        //
        // The other half of the distinction (b) turns on, and the reason a red cell
        // means something: coverage and passing are different verdicts. Here every
        // surface is driven — so `Gap == ∅` is GREEN — while one member's run failed,
        // which is red on the board and red on that member's row.
        let mut failed = covered_run();
        for r in failed.rows.iter_mut() {
            if r.repo == "nornir" && r.aspect == hole {
                r.status = status::FAIL.into();
                r.message = "the thin chat tab threw".into();
            }
        }
        let failing: Box<dyn Panel> = Box::new(FleetTab::with_run(failed));
        let mut app = TabbedView::new("dwarves — Fleet", vec![("Fleet".into(), failing)]);
        Facet::update_json(&mut app, r#"{"set_tab":"Fleet"}"#);
        let st_f = Facet::state_json(&app);
        let pane_f = &st_f["fleet"];
        let nornir = pane_f["members"]
            .as_array()
            .unwrap()
            .iter()
            .find(|m| m["name"] == "nornir")
            .unwrap();
        let failed_cell_red = nornir["cells"]
            .as_array()
            .unwrap()
            .iter()
            .find(|c| c["surface"] == hole)
            .map(|c| c["red"] == true && c["driven"] == true && c["green"] == false)
            .unwrap_or(false);
        let fail_ok = failed_cell_red
            && nornir["row_red"] == true
            && pane_f["cells_red"].as_u64().unwrap_or(0) == 1
            // Every surface WAS driven, so the coverage gate is clean even though a
            // run failed — the two verdicts are independent and the board says both.
            && pane_f["gap_clean"] == true
            && pane_f["missing"].as_array().unwrap().is_empty();

        let drivable = green_ok && red_ok && fail_ok;
        assert!(
            drivable,
            "the Fleet coverage board drives both verdicts over the remote seam: {st}"
        );

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "fleet_view_drivable",
            drivable,
            "Fleet coverage-board tab (autotest North Star): MOUNTS the shared facett_fleet_view::FleetView over a FleetRunView (nornir-testmatrix GateReport + TestResultRows + members) whose gate report is DERIVED by GateReport::compute rather than hand-forged — a fully-covered all-passing run shows the GREEN Gap==∅ banner (gate_green, cells_red==0, every member×surface cell green); a surface NOBODY drove (viz_tab:chat@thin) shows the RED banner naming the missing node with gap_clean==false and every member's cell for it GREY (driven:false, NOT red — nothing was measured); and a surface that WAS driven and FAILED shows exactly that cell red plus a red member row while Gap==∅ stays clean — so the board keeps coverage and passing apart instead of painting 'nobody looked' the same colour as 'we looked and it broke' — driven headless over state_json/update_json",
        );
        let _ = drivable;
    }

    /// **THE DEMO BOARD IS GONE** (audit 2026-09-10). With no fleet run the tab used to
    /// mount `FleetView::local()` — a full member×surface grid with a deliberate
    /// `chat@thin` hole and a RED `Gap == ∅` banner naming a missing node no run had
    /// looked for. Pins:
    ///
    ///   (a) with `$DWARVES_FLEET_RUN` unset the tab has ZERO members and ZERO cells;
    ///   (b) it carries the NAMED empty state over `$DWARVES_FLEET_RUN`;
    ///   (c) `source` is `empty`, never `demo`;
    ///   (d) the demo's own missing-node id appears NOWHERE — no banner, red or green.
    ///
    /// RED-when-broken: restore any fallback board and (a)/(d) fail.
    #[test]
    fn an_undriven_fleet_names_its_feed_never_a_demo_board() {
        // The env var is read at construction; hold the shared lock so a concurrent
        // test cannot repoint it mid-read.
        let _env = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let had = std::env::var_os(FLEET_RUN_ENV);
        unsafe { std::env::remove_var(FLEET_RUN_ENV) };
        let tab = FleetTab::new();
        let st = Facet::state_json(&tab);
        if let Some(v) = had {
            unsafe { std::env::set_var(FLEET_RUN_ENV, v) };
        }

        // (a) + (b) + (c)
        assert_eq!(
            st["source"], "empty",
            "`demo` is not a source any more: {st}"
        );
        assert_eq!(
            st["members"].as_array().map(|m| m.len()).unwrap_or(9),
            0,
            "no members: {st}"
        );
        assert_eq!(
            st["cells_green"].as_u64().unwrap_or(9),
            0,
            "no green cells: {st}"
        );
        assert_eq!(
            st["cells_red"].as_u64().unwrap_or(9),
            0,
            "no red cells either: {st}"
        );
        assert_eq!(st["fleet_empty"]["empty"], true);
        assert_eq!(
            st["fleet_empty"]["feed"], FLEET_FEED,
            "the empty state NAMES the feed"
        );
        assert_eq!(st["fleet_empty"]["cause"], "not-configured");

        // (d) the demo board's invented hole appears NOWHERE.
        let whole = st.to_string();
        assert!(
            !whole.contains("viz_tab:chat@thin"),
            "the demo board's invented missing node is gone: {whole}"
        );
    }

    /// The offline file seam parses BOTH accepted shapes (whole-file JSON `FleetRunView`
    /// and tagged JSONL of report + rows + members), and an empty/garbage file yields
    /// `None` (the caller falls back to the never-blank demo).
    #[test]
    fn offline_seam_parses_json_and_jsonl() {
        // whole-file JSON round-trip.
        let run = covered_run();
        let json = serde_json::to_string(&run).unwrap();
        let parsed = FleetTab::parse(&json).expect("whole-file FleetRunView parses");
        assert_eq!(parsed.rows.len(), run.rows.len());
        assert_eq!(parsed.members.len(), 3);

        // tagged JSONL: one report + rows + members, one envelope per line.
        let mut jsonl = String::new();
        jsonl.push_str(&serde_json::json!({ "report": GateReport::default() }).to_string());
        jsonl.push('\n');
        for row in &run.rows {
            jsonl.push_str(&serde_json::json!({ "row": row }).to_string());
            jsonl.push('\n');
        }
        jsonl.push_str(
            &serde_json::json!({ "member": FleetMember::new("korp", MemberHealth::Failed) })
                .to_string(),
        );
        jsonl.push('\n');
        let parsed = FleetTab::parse(&jsonl).expect("tagged JSONL parses");
        assert_eq!(parsed.rows.len(), run.rows.len());
        assert!(parsed.members.iter().any(|m| m.name == "korp"));

        // empty / garbage ⇒ None (demo fallback).
        assert!(FleetTab::parse("   ").is_none());
        assert!(FleetTab::parse("not json at all\n{").is_none());
    }
}