Skip to main content

facett_graphview/
metro.rs

1//! **Metro / subway-line view** — render a test-coverage **chain** as a transit
2//! line. Each line runs from a UI button (the START), through the functions on
3//! the chain that **emit** a `functional_status` (the STATIONS), past the
4//! pass-through functions that carry no emitter (thin ticks, "ridden through"),
5//! into the gRPC handler (END) and finally the warehouse table (TERMINUS).
6//!
7//! A **station is lit** when its emit actually fired while the start button was
8//! driven; **unlit** when the emit never ran (a gap in coverage). A line is
9//! **green** only when every gating station (Start / Emitter / Grpc / Terminus)
10//! is lit — pass-throughs never gate, they are just ridden through.
11//!
12//! # Pure + UI-agnostic model
13//! [`MetroStation`] / [`MetroLine`] / [`MetroMap`] are plain data with no egui or
14//! backend dependency. nornir (or any host) builds a [`MetroMap`] from its
15//! marker-matrix and hands it to [`MetroView`] — facett never learns what a
16//! "warehouse" is, only how to draw the line.
17//!
18//! [`MetroView`] is the canonical [`facett_core::Facet`]: it drops into a
19//! `FacetDeck` and exposes the whole drawn map (lines, per-station {label, kind,
20//! lit}, per-line + overall `green`) through [`Facet::state_json`] (LAW 6) so a
21//! headless test/robot asserts what was drawn without a single pixel.
22
23use facett_core::{FacetCaps, Theme, theme};
24use serde::{Deserialize, Serialize};
25
26/// What a station is on the chain — drives its glyph and whether it **gates**
27/// the line's green verdict.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub enum StationKind {
30    /// The UI button atom whose `.clicked()` handler starts the chain.
31    Start,
32    /// A function on the chain that emits a `functional_status` — a real station.
33    Emitter,
34    /// A function on the chain with **no** emitter — ridden through, a thin tick.
35    PassThrough,
36    /// The gRPC handler at the server boundary.
37    Grpc,
38    /// The warehouse table the chain ultimately lands in.
39    Terminus,
40    /// A **caller arm** off the gRPC station: the request reaches the verb from
41    /// the **UI** (the viz tab code paths). Drawn as a diverging branch with a
42    /// distinct glyph/colour and a "UI" marker.
43    UiClient,
44    /// A **caller arm** off the gRPC station: the request reaches the verb from
45    /// the **CLI** (the `nornir`/`nornir-mcp` binaries). Diverging branch, "CLI"
46    /// marker.
47    CliClient,
48}
49
50impl StationKind {
51    /// Stable lowercase tag (matches `state_json`).
52    pub fn as_str(self) -> &'static str {
53        match self {
54            StationKind::Start => "start",
55            StationKind::Emitter => "emitter",
56            StationKind::PassThrough => "passthrough",
57            StationKind::Grpc => "grpc",
58            StationKind::Terminus => "terminus",
59            StationKind::UiClient => "ui_client",
60            StationKind::CliClient => "cli_client",
61        }
62    }
63
64    /// Parse a host tag; unknown ⇒ [`StationKind::PassThrough`] (the inert kind).
65    pub fn parse(s: &str) -> Self {
66        match s {
67            "start" => StationKind::Start,
68            "emitter" => StationKind::Emitter,
69            "grpc" => StationKind::Grpc,
70            "terminus" => StationKind::Terminus,
71            "ui_client" => StationKind::UiClient,
72            "cli_client" => StationKind::CliClient,
73            _ => StationKind::PassThrough,
74        }
75    }
76
77    /// The short caller **marker** for a client arm (`UI`/`CLI`), or `None` for the
78    /// trunk station kinds. Drives the marker glyph drawn at the arm endpoint and
79    /// the `marker` field in `state_json`.
80    pub fn marker(self) -> Option<&'static str> {
81        match self {
82            StationKind::UiClient => Some("UI"),
83            StationKind::CliClient => Some("CLI"),
84            _ => None,
85        }
86    }
87
88    /// Is this a **caller-arm** kind (a fork branch off the gRPC station) rather
89    /// than a trunk station?
90    pub fn is_client_arm(self) -> bool {
91        matches!(self, StationKind::UiClient | StationKind::CliClient)
92    }
93
94    /// The glyph drawn for this kind: ▶ Start, ◆ Grpc, ■ Terminus; emitters are a
95    /// plain circle (drawn by the painter, no glyph), pass-throughs a minor tick;
96    /// caller arms get a distinct ▲ (UI) / ◇ (CLI) so the fork reads at a glance.
97    pub fn glyph(self) -> &'static str {
98        match self {
99            StationKind::Start => "▶",
100            StationKind::Emitter => "●",
101            StationKind::PassThrough => "·",
102            StationKind::Grpc => "◆",
103            StationKind::Terminus => "■",
104            StationKind::UiClient => "▲",
105            StationKind::CliClient => "◇",
106        }
107    }
108
109    /// Does this kind **gate** the line's green verdict? Every trunk kind but the
110    /// inert pass-through must be lit for the line to be green. Caller arms
111    /// (`UiClient`/`CliClient`) are **coverage forks**, not trunk gates — an
112    /// unlit arm means "this verb wasn't exercised through that client", which is
113    /// informational, never a trunk-red. They never gate.
114    pub fn gates(self) -> bool {
115        !matches!(self, StationKind::PassThrough | StationKind::UiClient | StationKind::CliClient)
116    }
117
118    /// Is this a full **station** (a filled circle / glyph stop) vs. a thin
119    /// pass-through tick? Pass-throughs are the only non-stations; caller arms are
120    /// drawn stops (on the diverging branch).
121    pub fn is_station(self) -> bool {
122        !matches!(self, StationKind::PassThrough)
123    }
124}
125
126/// One **caller arm** diverging off a trunk station (in practice the gRPC stop):
127/// a marked client (UI viz vs CLI) that invokes the verb. The renderer draws it
128/// as a branch leaving the fork point at an angle, distinct from the linear trunk.
129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
130pub struct MetroBranch {
131    /// Stable id (e.g. `caller::ui::Ops.RunTestMatrix`). Selection identity.
132    pub id: String,
133    /// Short human label drawn on the arm (e.g. the client/binary name).
134    pub label: String,
135    /// The arm kind — `UiClient` or `CliClient` (carries the `UI`/`CLI` marker).
136    pub kind: StationKind,
137    /// `true` = the verb was exercised through THIS client in the latest coverage
138    /// run (a LIT arm). `false` = not yet proven through this client (unlit) —
139    /// honest, never a trunk-red (arms don't gate).
140    pub lit: bool,
141}
142
143impl MetroBranch {
144    /// Construct a caller arm. The `kind` should be a client-arm kind
145    /// (`UiClient`/`CliClient`); the marker is derived from it.
146    pub fn new(id: impl Into<String>, label: impl Into<String>, kind: StationKind, lit: bool) -> Self {
147        Self { id: id.into(), label: label.into(), kind, lit }
148    }
149
150    /// The arm's caller marker (`UI`/`CLI`), or `None` if a non-client kind was
151    /// (mis)used.
152    pub fn marker(&self) -> Option<&'static str> {
153        self.kind.marker()
154    }
155}
156
157/// One stop on a metro line.
158#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
159pub struct MetroStation {
160    /// Stable id (the fully-qualified fn / button atom). Selection identity.
161    pub id: String,
162    /// Short human label drawn under the stop.
163    pub label: String,
164    pub kind: StationKind,
165    /// `true` = this emit fired when the start was driven (a LIT stop).
166    /// Pass-throughs default to `true` (nothing to light) and never gate.
167    pub lit: bool,
168    /// **Caller-side FORKS** off this station (the small DAG branch list). Empty
169    /// for an ordinary linear stop; on the gRPC station it carries the marked
170    /// caller arms (UI viz / CLI) that invoke the verb. The renderer draws these
171    /// as diverging arms at the fork point, NOT as duplicate trunk rows.
172    pub branches: Vec<MetroBranch>,
173}
174
175impl MetroStation {
176    /// Construct a stop with no caller forks (the common linear case).
177    /// Pass-throughs are reported lit (they carry no emit).
178    pub fn new(id: impl Into<String>, label: impl Into<String>, kind: StationKind, lit: bool) -> Self {
179        Self {
180            id: id.into(),
181            label: label.into(),
182            kind,
183            lit: if kind == StationKind::PassThrough { true } else { lit },
184            branches: Vec::new(),
185        }
186    }
187
188    /// Construct a stop that **forks** to caller arms (the gRPC station with its
189    /// UI/CLI callers). The arms render as diverging branches off this stop.
190    pub fn with_branches(
191        id: impl Into<String>,
192        label: impl Into<String>,
193        kind: StationKind,
194        lit: bool,
195        branches: Vec<MetroBranch>,
196    ) -> Self {
197        let mut s = Self::new(id, label, kind, lit);
198        s.branches = branches;
199        s
200    }
201
202    /// Does this station fork to one or more caller arms?
203    pub fn has_branches(&self) -> bool {
204        !self.branches.is_empty()
205    }
206}
207
208/// One metro line — a single test chain, button → … → warehouse.
209#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
210pub struct MetroLine {
211    /// Stable id (the chain id, e.g. `bench_run→Bench.Submit`).
212    pub id: String,
213    /// Human label drawn at the head of the line.
214    pub label: String,
215    /// Stops in chain order: START first, TERMINUS last (by convention).
216    pub stations: Vec<MetroStation>,
217}
218
219impl MetroLine {
220    pub fn new(id: impl Into<String>, label: impl Into<String>, stations: Vec<MetroStation>) -> Self {
221        Self { id: id.into(), label: label.into(), stations }
222    }
223
224    /// **The green verdict.** A line is green only when every **gating** station
225    /// (Start / Emitter / Grpc / Terminus) is lit. Pass-throughs are ignored —
226    /// they are ridden through, not lit, and never make a line red. A line with
227    /// no gating stations is vacuously green.
228    pub fn is_green(&self) -> bool {
229        self.stations.iter().filter(|s| s.kind.gates()).all(|s| s.lit)
230    }
231
232    /// The gating stations that are **unlit** — the reason a line is red. Empty
233    /// when [`is_green`](Self::is_green).
234    pub fn unlit(&self) -> Vec<&MetroStation> {
235        self.stations.iter().filter(|s| s.kind.gates() && !s.lit).collect()
236    }
237
238    /// Count of full stations (everything but pass-through ticks).
239    pub fn station_count(&self) -> usize {
240        self.stations.iter().filter(|s| s.kind.is_station()).count()
241    }
242}
243
244/// The whole map — every chain as a line.
245#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
246pub struct MetroMap {
247    pub lines: Vec<MetroLine>,
248}
249
250impl MetroMap {
251    pub fn new(lines: Vec<MetroLine>) -> Self {
252        Self { lines }
253    }
254
255    /// Every line green ⇒ the whole map is green (the test-matrix is covered).
256    /// An empty map is vacuously green.
257    pub fn is_green(&self) -> bool {
258        self.lines.iter().all(|l| l.is_green())
259    }
260
261    /// Count of green lines.
262    pub fn green_count(&self) -> usize {
263        self.lines.iter().filter(|l| l.is_green()).count()
264    }
265}
266
267// ── the Facet component ──────────────────────────────────────────────────────
268
269/// Layout metrics for the drawn lines (all in points).
270const ROW_H: f32 = 64.0;
271const LINE_LEFT: f32 = 150.0;
272const STOP_GAP: f32 = 96.0;
273const STATION_R: f32 = 9.0;
274const TICK_R: f32 = 3.0;
275const LINE_W: f32 = 6.0;
276
277/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
278/// driver (or a host) drives the metro view through. Applied by
279/// [`MetroView::update`]; the same [`Msg`] a host's drill-in gesture would produce.
280#[derive(Clone, Debug, PartialEq)]
281pub enum Msg {
282    /// Drill into a line by its stable id (FC-5) — the host's line select.
283    SelectLine(String),
284    /// Clear the drill-in selection.
285    ClearSelection,
286}
287
288/// Side work as data (FC-8). The metro view does no I/O — every [`Msg`] mutates only
289/// the in-memory [`MetroState`] — so this is uninhabited on purpose: the type-checked
290/// statement that [`MetroView::update`] never asks the host to do anything.
291#[derive(Clone, Debug, PartialEq)]
292pub enum Effect {}
293
294/// **The complete observable state (FC-1 / FC-3)** of a [`MetroView`], in one
295/// serializable, round-trippable struct: the whole drawn [`MetroMap`] plus the
296/// drilled-in selection (keyed on the line **id**, FC-5). [`MetroView::state`] hands
297/// back a `&MetroState`; a headless driver ([`facett_core::harness`]) snapshots it
298/// after feeding a `Vec<Msg>`.
299#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
300pub struct MetroState {
301    /// Every chain as a line — the whole drawn map.
302    pub map: MetroMap,
303    /// Selected line id (for a host's drill-in), if any.
304    pub selected: Option<String>,
305}
306
307/// A subway-map view of test-coverage chains — a [`Facet`]. Renders each
308/// [`MetroLine`] as a horizontal coloured line with circular stops; green tint
309/// when the line is fully lit, red unlit stops when not.
310pub struct MetroView {
311    title: String,
312    /// All observable state (FC-3).
313    state: MetroState,
314}
315
316impl MetroView {
317    /// An empty view with a title.
318    pub fn new(title: impl Into<String>) -> Self {
319        Self { title: title.into(), state: MetroState::default() }
320    }
321
322    /// Replace the map (the push model — call each refresh).
323    pub fn set_map(&mut self, map: MetroMap) {
324        self.state.map = map;
325    }
326
327    /// Re-title the view (the deck keys facets off [`Facet::title`], so a host that
328    /// mounts the `local()`/`remote()` view under a specific tab key sets it here).
329    pub fn set_title(&mut self, title: impl Into<String>) {
330        self.title = title.into();
331    }
332
333    /// The current map.
334    pub fn map(&self) -> &MetroMap {
335        &self.state.map
336    }
337
338    /// **FC-3** — read the complete observable state at any frame boundary.
339    pub fn state(&self) -> &MetroState {
340        &self.state
341    }
342
343    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
344    /// (always empty) [`Effect`]s the host should run. A host's line drill-in routes
345    /// its [`Msg`] here too, so live == headless.
346    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
347        match msg {
348            Msg::SelectLine(id) => self.state.selected = Some(id),
349            Msg::ClearSelection => self.state.selected = None,
350        }
351        Vec::new()
352    }
353
354    /// Select a line by id (`None` clears). A thin wrapper over the FC-2
355    /// [`update`](Self::update) mutation path.
356    pub fn select(&mut self, id: Option<String>) {
357        let _ = match id {
358            Some(i) => self.update(Msg::SelectLine(i)),
359            None => self.update(Msg::ClearSelection),
360        };
361    }
362
363    /// **Discovery-contract `local()`** — the canonical no-arg ctor the test
364    /// matrix calls to enumerate this facet. Builds a synthetic 2-line demo map
365    /// (one fully-lit/green chain, one with an unlit emitter/red) so the view
366    /// renders real lines with no fixture on disk. Mirrors the rest of the
367    /// facett discovery law (`OsmView::local()` etc.).
368    pub fn local() -> Self {
369        let mut v = Self::new("Metro");
370        v.set_map(demo_map());
371        v
372    }
373
374    /// **Discovery-contract `remote()`** — same surface, the variant a host
375    /// builds from a server-loaded marker matrix. No transport in facett, so the
376    /// wiring is identical; kept so the matrix sees both ctors.
377    pub fn remote() -> Self {
378        let mut v = Self::local();
379        v.title = "Metro (remote)".into();
380        v
381    }
382
383    /// The fill colour for a stop, given the line's green verdict and the stop's
384    /// lit state, derived from the active [`Theme`] so it follows the palette.
385    fn stop_color(th: &Theme, line_green: bool, lit: bool) -> egui::Color32 {
386        if !lit {
387            // Unlit gating stop — the warm red used elsewhere in facett (no
388            // semantic red in the base palette).
389            egui::Color32::from_rgb(224, 90, 90)
390        } else if line_green {
391            th.point // the theme's green-ish "point" accent
392        } else {
393            th.accent // lit, but the line is red elsewhere — neutral accent
394        }
395    }
396
397    /// The line's stroke colour: green when fully lit, else a dim neutral.
398    fn line_color(th: &Theme, line_green: bool) -> egui::Color32 {
399        if line_green { th.point } else { th.text_dim }
400    }
401
402    /// The fill/stroke colour for a **caller arm** (a fork branch): UI arms use the
403    /// theme accent, CLI arms a warm amber, both dimmed when the arm is unlit (the
404    /// verb wasn't exercised through that client). Distinct from the trunk green so
405    /// the fork reads as "who calls this", not "is the chain covered".
406    fn arm_color(th: &Theme, kind: StationKind, lit: bool) -> egui::Color32 {
407        let base = match kind {
408            StationKind::CliClient => egui::Color32::from_rgb(214, 160, 70), // amber CLI
409            _ => th.accent,                                                  // UI viz
410        };
411        if lit {
412            base
413        } else {
414            // Dim an unlit arm toward the neutral dim text — present but unproven.
415            egui::Color32::from_rgba_unmultiplied(base.r(), base.g(), base.b(), 120)
416        }
417    }
418}
419
420/// Per-row vertical span (points) reserved ABOVE the trunk for the diverging
421/// caller arms, so a forked row doesn't overlap its neighbour.
422const ARM_RISE: f32 = 30.0;
423/// Horizontal offset (points) of an arm's endpoint from the fork station.
424const ARM_RUN: f32 = 34.0;
425/// Radius of a caller-arm endpoint stop.
426const ARM_R: f32 = 7.0;
427
428/// The synthetic demo map used by [`MetroView::local`] — also the shape a host
429/// produces. Line A is all-lit (green) and its gRPC verb **forks to BOTH** caller
430/// arms (UI viz + CLI, both lit); line B has one unlit emitter (red) and its verb
431/// is **called only by the CLI** (one CLI arm).
432pub fn demo_map() -> MetroMap {
433    let line_a = MetroLine::new(
434        "bench_run→Bench.Submit",
435        "Bench Run",
436        vec![
437            MetroStation::new("ui::bench_button", "Run", StationKind::Start, true),
438            MetroStation::new("bench::collect", "collect", StationKind::Emitter, true),
439            MetroStation::new("bench::normalize", "normalize", StationKind::PassThrough, true),
440            MetroStation::new("bench::submit", "submit", StationKind::Emitter, true),
441            // The gRPC verb FORKS back to its two callers — UI viz + CLI, both lit.
442            MetroStation::with_branches(
443                "grpc::Bench.Submit",
444                "Bench.Submit",
445                StationKind::Grpc,
446                true,
447                vec![
448                    MetroBranch::new("caller::ui::Bench.Submit", "viz", StationKind::UiClient, true),
449                    MetroBranch::new("caller::cli::Bench.Submit", "nornir", StationKind::CliClient, true),
450                ],
451            ),
452            MetroStation::new("warehouse::bench_runs", "bench_runs", StationKind::Terminus, true),
453        ],
454    );
455    let line_b = MetroLine::new(
456        "docs_export→Docs.Render",
457        "Docs Export",
458        vec![
459            MetroStation::new("ui::docs_button", "Export", StationKind::Start, true),
460            MetroStation::new("docs::gather", "gather", StationKind::Emitter, true),
461            MetroStation::new("docs::render_svg", "render_svg", StationKind::Emitter, false), // unlit!
462            // This verb is called ONLY by the CLI — a single CLI arm.
463            MetroStation::with_branches(
464                "grpc::Docs.Render",
465                "Docs.Render",
466                StationKind::Grpc,
467                true,
468                vec![MetroBranch::new("caller::cli::Docs.Render", "nornir-mcp", StationKind::CliClient, true)],
469            ),
470            MetroStation::new("warehouse::doc_exports", "doc_exports", StationKind::Terminus, true),
471        ],
472    );
473    MetroMap::new(vec![line_a, line_b])
474}
475
476impl MetroView {
477    /// The copyable text: the selected line's `label` when one is selected, else
478    /// every line label, one per line.
479    pub fn copy_text(&self) -> Option<String> {
480        if self.state.map.lines.is_empty() {
481            return None;
482        }
483        if let Some(id) = self.state.selected.as_deref() {
484            if let Some(l) = self.state.map.lines.iter().find(|l| l.id == id) {
485                return Some(l.label.clone());
486            }
487        }
488        Some(self.state.map.lines.iter().map(|l| l.label.clone()).collect::<Vec<_>>().join("\n"))
489    }
490}
491
492// ── typed copy (§16) — read-only: selected line label or the label list ───────
493impl facett_core::clip::CopySource for MetroView {
494    fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
495        use facett_core::clip::ClipKind;
496        &[ClipKind::Text]
497    }
498    fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
499        self.copy_text().map(facett_core::clip::ClipPayload::Text)
500    }
501}
502
503impl MetroView {
504    /// **FC-9 render** — a **pure** function of `&self`: it paints the header + the
505    /// subway lines and *returns* the [`Msg`]s the interactions produced (none today —
506    /// the trunk is `Sense::hover`, selection is host-driven via [`select`](Self::select)).
507    /// It MUST NOT mutate the [`MetroState`]; the
508    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies any
509    /// returned messages through [`update`](Self::update).
510    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
511        let msgs: Vec<Msg> = Vec::new();
512        let th = theme(ui);
513
514        // ── header: line + green counts ────────────────────────────────────
515        ui.horizontal_wrapped(|ui| {
516            let total = self.state.map.lines.len();
517            let green = self.state.map.green_count();
518            ui.label(egui::RichText::new(format!("{total} line(s)")).color(th.text).strong());
519            ui.separator();
520            ui.label(
521                egui::RichText::new(format!("✓ {green} green"))
522                    .color(if green == total && total > 0 { th.point } else { th.text_dim }),
523            );
524            ui.label(
525                egui::RichText::new(format!("✗ {} red", total - green))
526                    .color(if green < total { egui::Color32::from_rgb(224, 90, 90) } else { th.text_dim }),
527            );
528        });
529        ui.separator();
530
531        if self.state.map.lines.is_empty() {
532            ui.label(egui::RichText::new("no lines").color(th.text_dim));
533            #[cfg(feature = "testmatrix")]
534            facett_core::testmatrix::emit(
535                "facett-graphview::MetroView::view",
536                "ui_render",
537                true,
538                "lines=0 drew=empty_hint",
539            );
540            return msgs;
541        }
542
543        // ── each line gets a row, painted as a horizontal track ────────────
544        let n = self.state.map.lines.len();
545        let (rect, _resp) = ui.allocate_exact_size(
546            // Reserve ARM_RISE of headroom so the diverging caller arms (which rise
547            // ABOVE the trunk on a forked row) never overlap the row above.
548            egui::vec2(ui.available_width().max(640.0), (ROW_H + ARM_RISE) * n as f32 + 16.0),
549            egui::Sense::hover(),
550        );
551        let painter = ui.painter_at(rect);
552
553        for (li, line) in self.state.map.lines.iter().enumerate() {
554            let green = line.is_green();
555            // Bias the trunk toward the lower part of the row band so the arms have
556            // room to rise above it.
557            let y = rect.top() + (ROW_H + ARM_RISE) * li as f32 + ARM_RISE + ROW_H * 0.5;
558            let line_col = MetroView::line_color(&th, green);
559
560            // Line label at the head.
561            painter.text(
562                egui::pos2(rect.left() + 8.0, y),
563                egui::Align2::LEFT_CENTER,
564                &line.label,
565                egui::FontId::proportional(14.0),
566                if green { th.point } else { th.text },
567            );
568
569            // The track: a thick stroke from the first to the last stop.
570            let x0 = rect.left() + LINE_LEFT;
571            let stops = &line.stations;
572            if !stops.is_empty() {
573                let x_last = x0 + STOP_GAP * (stops.len() as f32 - 1.0);
574                painter.line_segment(
575                    [egui::pos2(x0, y), egui::pos2(x_last.max(x0), y)],
576                    egui::Stroke::new(LINE_W, line_col),
577                );
578            }
579
580            // Each stop.
581            for (si, st) in stops.iter().enumerate() {
582                let x = x0 + STOP_GAP * si as f32;
583                let center = egui::pos2(x, y);
584                if st.kind == StationKind::PassThrough {
585                    // Thin minor tick — ridden through, not a station.
586                    painter.circle_filled(center, TICK_R, th.text_dim);
587                } else {
588                    let fill = MetroView::stop_color(&th, green, st.lit);
589                    if st.lit {
590                        painter.circle_filled(center, STATION_R, fill);
591                    } else {
592                        // Hollow red ring for an unlit gating stop.
593                        painter.circle_stroke(center, STATION_R, egui::Stroke::new(2.5, fill));
594                    }
595                    // Distinct glyph for the chain endpoints.
596                    let glyph = st.kind.glyph();
597                    if matches!(st.kind, StationKind::Start | StationKind::Grpc | StationKind::Terminus) {
598                        painter.text(
599                            egui::pos2(x, y - STATION_R - 11.0),
600                            egui::Align2::CENTER_CENTER,
601                            glyph,
602                            egui::FontId::proportional(12.0),
603                            th.text,
604                        );
605                    }
606                }
607                // Station label under the stop.
608                painter.text(
609                    egui::pos2(x, y + STATION_R + 9.0),
610                    egui::Align2::CENTER_CENTER,
611                    &st.label,
612                    egui::FontId::proportional(10.0),
613                    th.text_dim,
614                );
615
616                // ── CALLER-SIDE FORKS: diverging arms off this station ───────
617                // Each caller arm (UI viz / CLI) rises as a short diagonal branch
618                // from the fork point, fanned left→right, marked UI/CLI. NOT a
619                // duplicate trunk row — a real divergence at the gRPC stop.
620                if st.has_branches() {
621                    let m = st.branches.len();
622                    for (bi, br) in st.branches.iter().enumerate() {
623                        // Fan the arms across a small horizontal spread above the stop.
624                        let frac = if m > 1 { bi as f32 / (m as f32 - 1.0) - 0.5 } else { 0.0 };
625                        let ex = x + frac * (ARM_RUN * (m as f32).min(2.0));
626                        let ey = y - ARM_RISE;
627                        let col = MetroView::arm_color(&th, br.kind, br.lit);
628                        // The arm stroke from the fork point up to the endpoint.
629                        painter.line_segment(
630                            [center, egui::pos2(ex, ey)],
631                            egui::Stroke::new(3.0, col),
632                        );
633                        // Endpoint stop: filled when lit, hollow ring when unlit.
634                        let ep = egui::pos2(ex, ey);
635                        if br.lit {
636                            painter.circle_filled(ep, ARM_R, col);
637                        } else {
638                            painter.circle_stroke(ep, ARM_R, egui::Stroke::new(2.0, col));
639                        }
640                        // The arm's UI/CLI marker glyph + tag above the endpoint.
641                        painter.text(
642                            egui::pos2(ex, ey - ARM_R - 8.0),
643                            egui::Align2::CENTER_CENTER,
644                            format!("{} {}", br.kind.glyph(), br.marker().unwrap_or("")),
645                            egui::FontId::proportional(10.0),
646                            col,
647                        );
648                    }
649                }
650            }
651        }
652
653        #[cfg(feature = "testmatrix")]
654        facett_core::testmatrix::emit(
655            "facett-graphview::MetroView::view",
656            "ui_render",
657            !self.state.map.lines.is_empty(),
658            &format!("lines={} green={}", self.state.map.lines.len(), self.state.map.green_count()),
659        );
660
661        msgs
662    }
663}
664
665// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
666impl facett_core::Elm for MetroView {
667    type Model = MetroState;
668    type Msg = Msg;
669    type Effect = Effect;
670
671    fn title(&self) -> &str {
672        &self.title
673    }
674    fn state(&self) -> &MetroState {
675        &self.state
676    }
677    fn update(&mut self, msg: Msg) -> Vec<Effect> {
678        MetroView::update(self, msg)
679    }
680    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
681        MetroView::view(self, ui)
682    }
683}
684
685// The bridge macro writes `impl Facet for MetroView` from the `Elm` impl: `title`,
686// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
687// below. **Form 3** (`custom_state_json`) because the view publishes a RICHER
688// `state_json` than a plain `serde(state())`: the derived green verdict + per-line
689// station/unlit/branch topology (the keys facett-demo's mega_matrix + the metro_view
690// test read) come from the [`MetroMap`] verdict helpers, not the raw serde dump — so
691// the default `state_json` is suppressed and this byte-for-byte one supplied.
692facett_core::impl_facet_via_elm!(MetroView, custom_state_json, {
693    fn state_json(&self) -> serde_json::Value {
694        serde_json::json!({
695            "title": self.title,
696            "lines": self.state.map.lines.len(),
697            "green_lines": self.state.map.green_count(),
698            "green": self.state.map.is_green(),
699            "selected": self.state.selected,
700            "line": self.state.map.lines.iter().map(|l| serde_json::json!({
701                "id": l.id,
702                "label": l.label,
703                "green": l.is_green(),
704                "station_count": l.station_count(),
705                "unlit": l.unlit().iter().map(|s| s.label.clone()).collect::<Vec<_>>(),
706                "stations": l.stations.iter().map(|s| serde_json::json!({
707                    "id": s.id,
708                    "label": s.label,
709                    "kind": s.kind.as_str(),
710                    "lit": s.lit,
711                    // Caller-side FORKS (LAW 6): the marked UI/CLI arms off this
712                    // station. Empty array for an ordinary linear stop.
713                    "branches": s.branches.iter().map(|b| serde_json::json!({
714                        "id": b.id,
715                        "label": b.label,
716                        "kind": b.kind.as_str(),
717                        "marker": b.marker(),
718                        "lit": b.lit,
719                    })).collect::<Vec<_>>(),
720                })).collect::<Vec<_>>(),
721            })).collect::<Vec<_>>(),
722        })
723    }
724
725    fn copy(&mut self) -> Option<String> {
726        use facett_core::clip::CopySource as _;
727        self.copy_payload().map(|p| p.as_text())
728    }
729
730    fn selection_json(&self) -> serde_json::Value {
731        match &self.state.selected {
732            Some(id) => serde_json::json!({ "line": id }),
733            None => serde_json::Value::Null,
734        }
735    }
736
737    /// Reads the active [`Theme`] (stops/line colours follow the palette) and
738    /// refits to any host size; a line is selectable for drill-in.
739    fn caps(&self) -> FacetCaps {
740        FacetCaps::NONE.themeable().resizable().selectable().copyable()
741    }
742
743    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
744        Some(self)
745    }
746});
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    // `Facet` for `state_json`/`selection_json`/`title`/`caps`; `state()`/`select()`
752    // are inherent.
753    use facett_core::Facet;
754
755    #[test]
756    fn typed_copy_is_selected_line_or_the_line_list() {
757        use facett_core::clip::{ClipKind, CopySource};
758        let mut v = MetroView::new("metro");
759        v.set_map(demo_map());
760        let p = v.copy_payload().expect("populated map copies");
761        assert_eq!(p.kind(), ClipKind::Text);
762        assert_eq!(p.as_text(), "Bench Run\nDocs Export");
763        // Select a line -> the copy narrows to that line's label.
764        v.select(Some("bench_run\u{2192}Bench.Submit".into()));
765        assert_eq!(v.copy_payload().unwrap().as_text(), "Bench Run");
766    }
767
768    #[test]
769    fn green_only_when_every_gating_station_lit() {
770        let line = MetroLine::new(
771            "l",
772            "L",
773            vec![
774                MetroStation::new("s", "start", StationKind::Start, true),
775                MetroStation::new("e", "emit", StationKind::Emitter, true),
776                MetroStation::new("p", "pass", StationKind::PassThrough, false), // pass-through "unlit" ignored
777                MetroStation::new("g", "grpc", StationKind::Grpc, true),
778                MetroStation::new("t", "wh", StationKind::Terminus, true),
779            ],
780        );
781        assert!(line.is_green(), "all gating stops lit + a pass-through doesn't gate");
782        assert!(line.unlit().is_empty());
783    }
784
785    #[test]
786    fn one_unlit_emitter_makes_line_red() {
787        let line = MetroLine::new(
788            "l",
789            "L",
790            vec![
791                MetroStation::new("s", "start", StationKind::Start, true),
792                MetroStation::new("e", "emit", StationKind::Emitter, false),
793            ],
794        );
795        assert!(!line.is_green());
796        assert_eq!(line.unlit().len(), 1);
797        assert_eq!(line.unlit()[0].label, "emit");
798    }
799
800    #[test]
801    fn passthrough_constructed_lit_and_never_gates() {
802        let p = MetroStation::new("p", "p", StationKind::PassThrough, false);
803        assert!(p.lit, "pass-through is forced lit (nothing to light)");
804        assert!(!p.kind.gates());
805        assert!(!p.kind.is_station());
806    }
807
808    #[test]
809    fn kind_parse_roundtrip() {
810        for k in [
811            StationKind::UiClient,
812            StationKind::CliClient,
813            StationKind::Start,
814            StationKind::Emitter,
815            StationKind::PassThrough,
816            StationKind::Grpc,
817            StationKind::Terminus,
818        ] {
819            assert_eq!(StationKind::parse(k.as_str()), k);
820        }
821        assert_eq!(StationKind::parse("bogus"), StationKind::PassThrough);
822    }
823
824    #[test]
825    fn map_green_iff_all_lines_green() {
826        let m = demo_map();
827        assert_eq!(m.lines.len(), 2);
828        assert!(!m.is_green(), "line B has an unlit emitter");
829        assert_eq!(m.green_count(), 1);
830    }
831
832    #[test]
833    fn local_builds_two_line_demo() {
834        let v = MetroView::local();
835        assert_eq!(v.map().lines.len(), 2);
836        assert!(v.map().lines[0].is_green());
837        assert!(!v.map().lines[1].is_green());
838    }
839
840    /// `set_title` re-keys the view (the deck addresses facets by `title()`) while
841    /// leaving the map intact — the seam a host (facett-demo) mounts `local()` under
842    /// a specific tab key with. Inject a title, assert it lands in `title()` AND the
843    /// folded `state_json["title"]`, and the two-line map still renders.
844    #[test]
845    fn set_title_rekeys_view_and_keeps_the_map() {
846        let mut v = MetroView::local();
847        v.set_title("metro");
848        assert_eq!(Facet::title(&v), "metro", "title() reflects the new key");
849        let sj = v.state_json();
850        assert_eq!(sj["title"], "metro", "state_json carries the new title");
851        assert_eq!(sj["lines"].as_u64(), Some(2), "the demo map survived the re-title");
852        assert_eq!(sj["green_lines"].as_u64(), Some(1), "one green / one red line still");
853    }
854
855    // ── caller-side FORKS ───────────────────────────────────────────────────
856
857    /// Caller arms are NON-gating coverage forks: an UNLIT arm must NOT turn the
858    /// trunk red. A line that's green on its trunk stays green regardless of arm
859    /// state — the fork is "who calls it", not "is the chain covered".
860    #[test]
861    fn caller_arms_never_gate_the_trunk() {
862        assert!(!StationKind::UiClient.gates());
863        assert!(!StationKind::CliClient.gates());
864        assert!(StationKind::UiClient.is_client_arm() && StationKind::CliClient.is_client_arm());
865
866        let line = MetroLine::new(
867            "l",
868            "L",
869            vec![
870                MetroStation::new("s", "start", StationKind::Start, true),
871                MetroStation::with_branches(
872                    "g",
873                    "grpc",
874                    StationKind::Grpc,
875                    true,
876                    vec![
877                        MetroBranch::new("ui", "viz", StationKind::UiClient, false), // UNLIT arm
878                        MetroBranch::new("cli", "nornir", StationKind::CliClient, true),
879                    ],
880                ),
881                MetroStation::new("t", "wh", StationKind::Terminus, true),
882            ],
883        );
884        assert!(line.is_green(), "an unlit caller arm does not gate the green trunk");
885        assert!(line.unlit().is_empty(), "arms are not counted as unlit gating stops");
886    }
887
888    /// The markers: `UiClient` → "UI", `CliClient` → "CLI", trunk kinds → None.
889    #[test]
890    fn client_kinds_carry_the_right_marker() {
891        assert_eq!(StationKind::UiClient.marker(), Some("UI"));
892        assert_eq!(StationKind::CliClient.marker(), Some("CLI"));
893        assert_eq!(StationKind::Grpc.marker(), None);
894        let b = MetroBranch::new("x", "viz", StationKind::UiClient, true);
895        assert_eq!(b.marker(), Some("UI"));
896    }
897
898    /// LAW 6: a verb called by BOTH ui+cli serialises TWO marked arms; a verb
899    /// called by only the CLI serialises ONE CLI arm — asserted on `state_json`
900    /// DATA (the branch topology + per-arm marker), no pixels.
901    #[test]
902    fn state_json_carries_the_branch_topology_and_markers() {
903        let v = MetroView::local(); // demo: line A forks UI+CLI, line B forks CLI-only
904        let sj = v.state_json();
905        let lines = sj["line"].as_array().unwrap();
906
907        // Line A's gRPC stop → two arms, marked UI + CLI, both lit.
908        let line_a = &lines[0];
909        let grpc_a = line_a["stations"]
910            .as_array()
911            .unwrap()
912            .iter()
913            .find(|s| s["kind"] == "grpc")
914            .expect("line A has a grpc station");
915        let arms_a = grpc_a["branches"].as_array().unwrap();
916        assert_eq!(arms_a.len(), 2, "the verb called by BOTH renders two arms");
917        let markers_a: Vec<&str> = arms_a.iter().map(|b| b["marker"].as_str().unwrap()).collect();
918        assert!(markers_a.contains(&"UI") && markers_a.contains(&"CLI"), "UI+CLI markers: {markers_a:?}");
919        assert!(arms_a.iter().all(|b| b["lit"].as_bool().unwrap()), "both arms lit");
920        assert_eq!(arms_a.iter().find(|b| b["marker"] == "UI").unwrap()["kind"], "ui_client");
921        assert_eq!(arms_a.iter().find(|b| b["marker"] == "CLI").unwrap()["kind"], "cli_client");
922
923        // Line B's gRPC stop → exactly ONE CLI arm.
924        let line_b = &lines[1];
925        let grpc_b = line_b["stations"]
926            .as_array()
927            .unwrap()
928            .iter()
929            .find(|s| s["kind"] == "grpc")
930            .expect("line B has a grpc station");
931        let arms_b = grpc_b["branches"].as_array().unwrap();
932        assert_eq!(arms_b.len(), 1, "the CLI-only verb renders a single arm");
933        assert_eq!(arms_b[0]["marker"], "CLI");
934        assert_eq!(arms_b[0]["kind"], "cli_client");
935
936        // A non-forking stop (the Start) carries an empty branch array.
937        let start_a = line_a["stations"].as_array().unwrap().iter().find(|s| s["kind"] == "start").unwrap();
938        assert_eq!(start_a["branches"].as_array().unwrap().len(), 0, "linear stop has no forks");
939    }
940
941    /// RAGNARÖK (LAW 2): an EMPTY map carries no lines — the empty-state, never a
942    /// vacuous green-with-data. (the host paints the red empty hint.)
943    #[test]
944    fn empty_map_is_the_empty_state() {
945        let v = MetroView::new("Metro");
946        let sj = v.state_json();
947        assert_eq!(sj["lines"].as_u64(), Some(0), "no lines on an empty map");
948        assert_eq!(sj["line"].as_array().unwrap().len(), 0);
949        assert!(v.map().lines.is_empty());
950    }
951
952    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
953    //    harness and assert the resulting `MetroState` — no egui, no GPU. ─────────
954    #[test]
955    fn harness_snapshot_drives_select_and_clear() {
956        use facett_core::harness;
957        let mut v = MetroView::local();
958        // Selecting an existing line id is observable in the snapshot.
959        let snap = harness::snapshot(&mut v, [Msg::SelectLine("bench_run\u{2192}Bench.Submit".into())]);
960        assert_eq!(snap.selected.as_deref(), Some("bench_run\u{2192}Bench.Submit"), "selection is observable headlessly");
961        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
962        // Clearing wipes it.
963        let snap = harness::snapshot(&mut v, [Msg::SelectLine("docs_export\u{2192}Docs.Render".into()), Msg::ClearSelection]);
964        assert_eq!(snap.selected, None, "ClearSelection empties the selection");
965    }
966
967    #[test]
968    fn drive_reports_no_effects_and_state_round_trips() {
969        use facett_core::harness;
970        let mut v = MetroView::local();
971        // FC-8: the metro view does no I/O, so the effect stream is always empty.
972        let effects = harness::drive(&mut v, [Msg::SelectLine("bench_run\u{2192}Bench.Submit".into())]);
973        assert!(effects.is_empty(), "FC-8: metro emits no Effects");
974        // FC-3: the observable Model (map + selection) round-trips through serde.
975        let json = serde_json::to_value(v.state()).unwrap();
976        assert_eq!(json["selected"], "bench_run\u{2192}Bench.Submit");
977        let back: MetroState = serde_json::from_value(json).unwrap();
978        assert_eq!(&back, v.state(), "serde(state) -> state round-trips (the whole map survives)");
979    }
980
981    /// FC-5: the selection is keyed on the **stable line id**, so it survives a full
982    /// map replacement that reorders the lines (a "re-layout" of the trunk). The
983    /// selected id still resolves to its line even though its index changed.
984    #[test]
985    fn selection_survives_line_reorder_on_stable_id() {
986        use facett_core::harness;
987        let mut v = MetroView::local();
988        let _ = harness::drive(&mut v, [Msg::SelectLine("docs_export\u{2192}Docs.Render".into())]);
989        // Rebuild the map with the two lines in the OPPOSITE order (same stable ids).
990        let mut reordered = demo_map();
991        reordered.lines.reverse();
992        v.set_map(reordered);
993        // The selection is unchanged (keyed on id, not position) and still resolves.
994        assert_eq!(v.state().selected.as_deref(), Some("docs_export\u{2192}Docs.Render"));
995        assert!(
996            v.map().lines.iter().any(|l| Some(l.id.as_str()) == v.state().selected.as_deref()),
997            "the selected id still names a real line after the reorder",
998        );
999        // And the drill-in copy still narrows to that line's label.
1000        assert_eq!(v.copy_text().as_deref(), Some("Docs Export"));
1001    }
1002}