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