Skip to main content

facett_syschart/
lib.rs

1//! **facett-syschart** — a **system-map chart**: a handful of *free-positioned*
2//! peer nodes (not a layered DAG) joined by edges, each carrying a **badge**
3//! (a count / status number) and, when selected, an **inline expandable detail
4//! panel**. Click a node to select it; its detail renders in a panel under the
5//! canvas. The host owns *what* a node's detail is (a string here; richer hosts
6//! draw their own widgets after `ui()` keyed off [`SystemChart::selected`]).
7//!
8//! This is the piece [`facett_core::draw`]/[`facett_graph::GraphView`] don't
9//! cover — those paint a *non-interactive* `Scene`; `DepGraphView` is a
10//! *layered* DAG with a fixed deps/dependents drill-down. A "monitoring map" of
11//! a few peer systems (a PKI/OIDC/Nexus triangle, a service mesh) wants
12//! deterministic positions, a per-node badge, click-select, and a free-form
13//! detail panel — that's [`SystemChart`].
14//!
15//! ## FC-2 / FC-9 contract
16//! Built on [`facett_core::Elm`]: the complete observable state lives in one
17//! serializable [`SysChartModel`] ([`state`](facett_core::Elm::state)); every
18//! input is a [`Msg`] applied through the single mutation path
19//! [`update`](SystemChart::update) (FC-2); and [`view`](SystemChart::view) is a
20//! **pure** function of `&self` that paints and *returns* the [`Msg`]s the frame
21//! produced (FC-9). [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
22//! writes the `impl Facet` bridge (`for m in view(ui) { update(m) }`). A headless
23//! driver ([`facett_core::harness`]) feeds a `Vec<Msg>` and snapshots
24//! [`state`](facett_core::Elm::state) with no egui / no GPU.
25
26use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
27use facett_core::clip::{ClipKind, ClipPayload, CopySource};
28use facett_core::{FacetCaps, Semantics, a11y_node, theme};
29use serde::{Deserialize, Serialize};
30
31/// One system in the map. Positions are **normalized** (`0.0..=1.0` of the
32/// canvas), so the layout is resolution-independent and deterministic — ideal
33/// for headless rendering and screenshots.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct SysNode {
36    /// Stable id (used for selection + edges). Unique within a chart.
37    pub id: String,
38    /// Human label drawn next to the node.
39    pub label: String,
40    /// Node colour (the host picks the policy — by status, by hash, …).
41    pub color: Color32,
42    /// A small count/status badge drawn on the node (e.g. an event count).
43    pub badge: u64,
44    /// Normalized position in the canvas (`0,0` = top-left, `1,1` = bottom-right).
45    pub pos: (f32, f32),
46    /// Free-form detail shown in the inline panel when this node is selected.
47    /// Hosts that draw richer detail can leave this empty and render their own
48    /// widgets after `ui()` keyed off [`SystemChart::selected`].
49    pub detail: String,
50}
51
52impl SysNode {
53    pub fn new(id: impl Into<String>, label: impl Into<String>, color: Color32, pos: (f32, f32)) -> Self {
54        Self { id: id.into(), label: label.into(), color, badge: 0, pos, detail: String::new() }
55    }
56    pub fn badge(mut self, n: u64) -> Self {
57        self.badge = n;
58        self
59    }
60    pub fn detail(mut self, d: impl Into<String>) -> Self {
61        self.detail = d.into();
62        self
63    }
64}
65
66/// An undirected link between two node ids.
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct SysEdge {
69    pub a: String,
70    pub b: String,
71}
72
73impl SysEdge {
74    pub fn new(a: impl Into<String>, b: impl Into<String>) -> Self {
75        Self { a: a.into(), b: b.into() }
76    }
77}
78
79/// A robot-/CLI-addressable control message — the named boundary a headless
80/// driver (or a host toolbar) drives the chart through, the same effect the
81/// canvas gestures produce. Applied by [`SystemChart::update`]; the pure
82/// [`view`](SystemChart::view) *returns* these (it never mutates `self`).
83#[derive(Clone, Debug, PartialEq)]
84pub enum Msg {
85    /// **Toggle**-select the node with this stable id: selecting the already-
86    /// selected node clears it, an unknown id is a no-op (parity with the click).
87    Select(String),
88    /// Clear any selection.
89    ClearSelection,
90    /// Set a node's badge count by id (e.g. a live event count). No-op if unknown.
91    SetBadge(String, u64),
92    /// Set a node's detail text by id. No-op if unknown.
93    SetDetail(String, String),
94}
95
96/// Side work as data (FC-8). The syschart does no I/O — every [`Msg`] mutates
97/// only the in-memory [`SysChartModel`] — so this is uninhabited on purpose: the
98/// type-checked statement that [`SystemChart::update`] never asks the host to do
99/// anything.
100#[derive(Clone, Debug, PartialEq)]
101pub enum Effect {}
102
103/// **The complete observable state (FC-1 / FC-3)** of a [`SystemChart`], in one
104/// serializable, round-trippable struct: every node + edge, the selection (keyed
105/// on the stable [`SysNode::id`], FC-5, so reorder/insert/delete preserves it),
106/// and the canvas height. [`SystemChart::state`](facett_core::Elm::state) hands
107/// back a `&SysChartModel`; a headless driver ([`facett_core::harness`]) snapshots
108/// it after feeding a `Vec<Msg>`.
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110pub struct SysChartModel {
111    /// Free-positioned peer nodes.
112    pub nodes: Vec<SysNode>,
113    /// Undirected links between node ids.
114    pub edges: Vec<SysEdge>,
115    /// Stable id of the selected node (drives the detail panel), keyed on the
116    /// domain id ([`SysNode::id`]) not an enumeration index (FC-5).
117    pub selected: Option<String>,
118    /// Height (px) the node canvas takes before the detail panel.
119    pub canvas_h: f32,
120}
121
122/// The system-map chart [`Facet`](facett_core::Facet): a tab title plus the
123/// complete observable [`SysChartModel`]. The FC-9 pure [`view`](Self::view) reads
124/// it and the FC-2 [`update`](Self::update) is the sole path that mutates it.
125pub struct SystemChart {
126    /// Tab/panel title.
127    pub title: String,
128    /// All observable state (FC-3).
129    pub state: SysChartModel,
130}
131
132const NODE_R: f32 = 16.0;
133
134impl SystemChart {
135    pub fn new(title: impl Into<String>, nodes: Vec<SysNode>, edges: Vec<SysEdge>) -> Self {
136        Self {
137            title: title.into(),
138            state: SysChartModel { nodes, edges, selected: None, canvas_h: 280.0 },
139        }
140    }
141    pub fn with_canvas_height(mut self, h: f32) -> Self {
142        self.state.canvas_h = h;
143        self
144    }
145
146    /// Build a **layered** system map — the architecture/topology "grouped by
147    /// layer" shape. `layers` are top-to-bottom bands (e.g. ingress → services →
148    /// storage); each band's nodes are spread evenly across the width and pinned to
149    /// that band's vertical position, so the map reads as a constellation diagram
150    /// organised by layer. Node `pos` fields are overwritten by the layout; `edges`
151    /// (the dependencies) are added as given. This is [`SystemChart::new`] with the
152    /// free positions computed from the layering instead of supplied.
153    pub fn from_layers(title: impl Into<String>, layers: Vec<Vec<SysNode>>, edges: Vec<SysEdge>) -> Self {
154        let n_layers = layers.len().max(1) as f32;
155        let mut nodes = Vec::new();
156        for (li, layer) in layers.into_iter().enumerate() {
157            // Band y: single layer sits mid-canvas; otherwise 0.12..=0.88 evenly.
158            let y = if n_layers <= 1.0 { 0.5 } else { 0.12 + (li as f32) * (0.76 / (n_layers - 1.0)) };
159            let count = layer.len().max(1) as f32;
160            for (ni, mut node) in layer.into_iter().enumerate() {
161                let x = if count <= 1.0 { 0.5 } else { 0.1 + (ni as f32) * (0.8 / (count - 1.0)) };
162                node.pos = (x, y);
163                nodes.push(node);
164            }
165        }
166        Self::new(title, nodes, edges)
167    }
168
169    /// A self-contained **demo constellation map** — a small nordisk architecture in
170    /// three layers (ingress `gateway` → the `korp`/`nornir`/`holger` services →
171    /// the `skade`/`iceberg` storage), with dependency edges and per-service badges.
172    /// The fixture the `syschart_panel` demo (and the Pages site) mounts.
173    #[must_use]
174    pub fn demo() -> Self {
175        let c = Color32::from_rgb;
176        let layers = vec![
177            vec![SysNode::new("gateway", "Gateway", c(120, 200, 255), (0.0, 0.0)).badge(2).detail("ingress · TLS terminate")],
178            vec![
179                SysNode::new("korp", "korp", c(200, 160, 255), (0.0, 0.0)).badge(5).detail("case + analysis warehouse"),
180                SysNode::new("nornir", "nornir", c(160, 255, 180), (0.0, 0.0)).badge(3).detail("orchestrator + test matrix"),
181                SysNode::new("holger", "holger", c(255, 200, 140), (0.0, 0.0)).badge(1).detail("registry browser"),
182            ],
183            vec![
184                SysNode::new("skade", "skade", c(140, 190, 255), (0.0, 0.0)).badge(8).detail("lakehouse warehouse"),
185                SysNode::new("iceberg", "Iceberg", c(180, 220, 160), (0.0, 0.0)).badge(0).detail("table format"),
186            ],
187        ];
188        let edges = vec![
189            SysEdge::new("gateway", "korp"),
190            SysEdge::new("gateway", "nornir"),
191            SysEdge::new("korp", "holger"),
192            SysEdge::new("korp", "skade"),
193            SysEdge::new("nornir", "skade"),
194            SysEdge::new("skade", "iceberg"),
195        ];
196        Self::from_layers("System Map", layers, edges)
197    }
198
199    /// The nodes (read-only view of the model).
200    pub fn nodes(&self) -> &[SysNode] {
201        &self.state.nodes
202    }
203    /// The edges (read-only view of the model).
204    pub fn edges(&self) -> &[SysEdge] {
205        &self.state.edges
206    }
207
208    fn index_of(&self, id: &str) -> Option<usize> {
209        self.state.nodes.iter().position(|n| n.id == id)
210    }
211
212    /// Index of the currently-selected node, resolved from its stable id.
213    fn selected_idx(&self) -> Option<usize> {
214        self.state.selected.as_deref().and_then(|id| self.index_of(id))
215    }
216
217    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
218    /// (always empty) [`Effect`]s the host should run. The canvas click routes its
219    /// [`Msg`] here too, so live == headless.
220    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
221        match msg {
222            Msg::Select(id) => {
223                if self.index_of(&id).is_some() {
224                    self.state.selected = if self.state.selected.as_deref() == Some(id.as_str()) {
225                        None
226                    } else {
227                        Some(id)
228                    };
229                }
230            }
231            Msg::ClearSelection => self.state.selected = None,
232            Msg::SetBadge(id, badge) => {
233                if let Some(i) = self.index_of(&id) {
234                    self.state.nodes[i].badge = badge;
235                }
236            }
237            Msg::SetDetail(id, detail) => {
238                if let Some(i) = self.index_of(&id) {
239                    self.state.nodes[i].detail = detail;
240                }
241            }
242        }
243        Vec::new()
244    }
245
246    /// Select a node by id (headless-test + host entry point). Selecting the
247    /// already-selected node deselects it. Unknown id is a no-op. Thin wrapper
248    /// over [`update`](Self::update)`(`[`Msg::Select`]`)` — the single mutation path.
249    pub fn select(&mut self, id: &str) {
250        let _ = self.update(Msg::Select(id.to_string()));
251    }
252    /// Clear the selection ([`Msg::ClearSelection`]).
253    pub fn clear_selection(&mut self) {
254        let _ = self.update(Msg::ClearSelection);
255    }
256    /// The selected node's id, if any.
257    pub fn selected(&self) -> Option<&str> {
258        self.state.selected.as_deref()
259    }
260
261    /// Update a node's badge by id (e.g. a live event count). No-op if unknown.
262    /// Thin wrapper over [`update`](Self::update)`(`[`Msg::SetBadge`]`)`.
263    pub fn set_badge(&mut self, id: &str, badge: u64) {
264        let _ = self.update(Msg::SetBadge(id.to_string(), badge));
265    }
266    /// Update a node's detail text by id. No-op if unknown. Thin wrapper over
267    /// [`update`](Self::update)`(`[`Msg::SetDetail`]`)`.
268    pub fn set_detail(&mut self, id: &str, detail: impl Into<String>) {
269        let _ = self.update(Msg::SetDetail(id.to_string(), detail.into()));
270    }
271
272    /// **Test/host hook (additive).** The public, return-asserted view of the
273    /// private [`center`](Self::center) layout node — the absolute pixel centre
274    /// node `i`'s circle is painted at inside `rect`. Exposed so the syschart
275    /// call-chain matrix can assert the *layout/place* stage (finite, confined to
276    /// the padded inner rect, monotone in the normalized pos) without a painter.
277    /// Calls the **same** private fn `view` paints with — additive, no behaviour
278    /// change. The node radius the layout pads for is [`SystemChart::NODE_R`].
279    pub fn node_center(&self, i: usize, rect: Rect) -> Pos2 {
280        self.center(i, rect)
281    }
282    /// The node radius (px) the free-position layout pads the canvas by, exposed so
283    /// the matrix can assert nodes land inside `rect` by exactly this inset.
284    pub const NODE_R: f32 = NODE_R;
285
286    /// Absolute pixel centre of node `i` inside `rect`.
287    fn center(&self, i: usize, rect: Rect) -> Pos2 {
288        let (nx, ny) = self.state.nodes[i].pos;
289        let pad = NODE_R + 6.0;
290        let inner = Rect::from_min_max(
291            rect.min + vec2(pad, pad),
292            rect.max - vec2(pad, pad),
293        );
294        Pos2::new(
295            inner.min.x + nx.clamp(0.0, 1.0) * inner.width().max(1.0),
296            inner.min.y + ny.clamp(0.0, 1.0) * inner.height().max(1.0),
297        )
298    }
299}
300
301impl SystemChart {
302    /// The copyable text: the selected node's `label · detail` (badge in a suffix)
303    /// when a node is selected, else the whole node list as a TSV rectangle
304    /// (`id \t label \t badge`).
305    pub fn copy_text(&self) -> Option<String> {
306        if self.state.nodes.is_empty() {
307            return None;
308        }
309        if let Some(n) = self.selected().and_then(|id| self.state.nodes.iter().find(|n| n.id == id)) {
310            let mut s = format!("{} (badge {})", n.label, n.badge);
311            if !n.detail.is_empty() {
312                s.push('\n');
313                s.push_str(&n.detail);
314            }
315            return Some(s);
316        }
317        let mut out = String::from("id\tlabel\tbadge");
318        for n in &self.state.nodes {
319            out.push('\n');
320            out.push_str(&format!("{}\t{}\t{}", n.id, n.label, n.badge));
321        }
322        Some(out)
323    }
324
325    /// **FC-9 pure view** — a function of `&self`: it paints the chart and *returns*
326    /// the [`Msg`]s the frame produced (a node click → [`Msg::Select`]). It does
327    /// **not** mutate the model; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
328    /// bridge applies the returned messages through [`update`](Self::update), so a
329    /// headless driver feeding the same [`Msg`]s reproduces the live behaviour.
330    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
331        let mut msgs: Vec<Msg> = Vec::new();
332        let th = theme(ui);
333        // ── node canvas ──────────────────────────────────────────────────────
334        let canvas = vec2(
335            ui.available_width(),
336            self.state.canvas_h.min(ui.available_height().max(self.state.canvas_h)),
337        );
338        let (rect, resp) = ui.allocate_exact_size(canvas, Sense::hover());
339        let base = ui.id().with("syschart-node");
340        let painter = ui.painter_at(rect);
341
342        // The empty-data state carries the stable UI error code facet-syschart-1 (see
343        // facett_core::errcode) so tests + consumers react to the CODE, not a string;
344        // when empty the canvas atom also carries the Error severity + the code, and
345        // the pink code is painted beneath the human hint.
346        if self.state.nodes.is_empty() {
347            resp.widget_info(|| {
348                Semantics::image(format!("{} — no peers", self.title))
349                    .severity(facett_core::Severity::Error)
350                    .error_code("facet-syschart-1")
351                    .widget_info()
352            });
353            painter.text(rect.center(), Align2::CENTER_CENTER, "no peers to show", FontId::proportional(13.0), th.text_dim);
354            facett_core::errcode::paint_code(&painter, rect.center() + vec2(0.0, 16.0), "facet-syschart-1");
355            #[cfg(feature = "testmatrix")]
356            facett_core::testmatrix::emit(
357                "facett-syschart::SystemChart::view",
358                "ui_render",
359                false,
360                "nodes=0 drew=empty_hint code=facet-syschart-1",
361            );
362            return msgs;
363        }
364
365        let centers: Vec<Pos2> = (0..self.state.nodes.len()).map(|i| self.center(i, rect)).collect();
366
367        // edges first (under the nodes)
368        for e in &self.state.edges {
369            if let (Some(ai), Some(bi)) = (self.index_of(&e.a), self.index_of(&e.b)) {
370                painter.line_segment([centers[ai], centers[bi]], Stroke::new(1.5_f32, th.edge));
371            }
372        }
373
374        // nodes — paint, plus an AccessKit node per element (FC-4) keyed on the
375        // stable domain id (FC-5). A click is collected as a `Msg::Select` and
376        // RETURNED; the `update` bridge applies the (toggle) selection — the view
377        // itself stays pure (no `self` mutation).
378        let sel_id = self.state.selected.clone();
379        for (i, node) in self.state.nodes.iter().enumerate() {
380            let c = centers[i];
381            let selected = sel_id.as_deref() == Some(node.id.as_str());
382            let r = if selected { NODE_R + 3.0 } else { NODE_R };
383            // Elevation shadow under the raised node chip (Windows-pronounced /
384            // macOS-restrained, per the active NativeFeel) — painted BEFORE the fill
385            // so it sits behind the node. No-op under Device (effects-off).
386            facett_core::look::elevation_shadow(ui, Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), r);
387            painter.circle_filled(c, r, node.color);
388            let ring = if selected { th.accent } else { th.node_stroke };
389            painter.circle_stroke(c, r, Stroke::new(if selected { 2.5_f32 } else { 1.0_f32 }, ring));
390            // badge inside the node
391            painter.text(c, Align2::CENTER_CENTER, node.badge.to_string(), FontId::proportional(11.0), th.text);
392            // label under the node
393            painter.text(
394                c + vec2(0.0, r + 2.0),
395                Align2::CENTER_TOP,
396                &node.label,
397                FontId::proportional(11.0),
398                th.text,
399            );
400
401            // AccessKit hit-test node over the node's circle (FC-4): role=Button,
402            // label = "{label} · {badge} events", numeric_value = badge, selected.
403            let node_rect = Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r));
404            // ── native-feel cues (mac/windows parity): the Windows reveal glow on
405            // hover + the platform focus ring/rect on the selected node. Both read
406            // the active Theme's NativeFeel through the shared `look::feel` helpers,
407            // so this node follows the platform preset (no-op on Neutral/Device).
408            facett_core::look::reveal_on_hover(ui, node_rect, r);
409            facett_core::look::apply_focus_ring(ui, node_rect, selected, r);
410            let label = format!("{} · {} events", node.label, node.badge);
411            let hit = a11y_node(
412                ui,
413                base,
414                &node.id,
415                Sense::click(),
416                node_rect,
417                Semantics::button(label).value(node.badge as f64).selected(selected),
418            );
419            if hit.clicked() {
420                msgs.push(Msg::Select(node.id.clone()));
421            }
422        }
423
424        // ── inline detail panel ──────────────────────────────────────────────
425        ui.separator();
426        match self.selected_idx() {
427            None => {
428                ui.weak("Click a node to expand its detail.");
429            }
430            Some(i) => {
431                let node = &self.state.nodes[i];
432                ui.horizontal(|ui| {
433                    ui.strong(&node.label);
434                    ui.weak(format!("· {} events", node.badge));
435                });
436                if node.detail.is_empty() {
437                    ui.weak("(no detail)");
438                } else {
439                    for line in node.detail.lines() {
440                        ui.monospace(line);
441                    }
442                }
443            }
444        }
445
446        // ── render-lane emit: this view path RAN ──────────────────────────────
447        #[cfg(feature = "testmatrix")]
448        facett_core::testmatrix::emit(
449            "facett-syschart::SystemChart::view",
450            "ui_render",
451            !self.state.nodes.is_empty(),
452            &format!("nodes={} edges={}", self.state.nodes.len(), self.state.edges.len()),
453        );
454
455        msgs
456    }
457}
458
459// ── typed copy (§16) — read-only: selected node text or node-list rows ─────────
460impl CopySource for SystemChart {
461    fn copy_kinds(&self) -> &[ClipKind] {
462        &[ClipKind::Text]
463    }
464
465    fn copy_payload(&self) -> Option<ClipPayload> {
466        self.copy_text().map(ClipPayload::Text)
467    }
468}
469
470// ── the Elm contract (FC-2 / FC-9) ─────────────────────────────────────────────
471impl facett_core::Elm for SystemChart {
472    type Model = SysChartModel;
473    type Msg = Msg;
474    type Effect = Effect;
475
476    fn title(&self) -> &str {
477        &self.title
478    }
479    fn state(&self) -> &SysChartModel {
480        &self.state
481    }
482    fn update(&mut self, msg: Msg) -> Vec<Effect> {
483        SystemChart::update(self, msg)
484    }
485    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
486        SystemChart::view(self, ui)
487    }
488}
489
490// The bridge macro writes `impl Facet for SystemChart` from the `Elm` impl:
491// `title`, the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra
492// overrides below. **Form 3** (`custom_state_json`) because the chart publishes a
493// RICHER `state_json` than plain `serde(state())` — it flattens each node to
494// `id/label/badge/pos/has_detail` (colour omitted, detail as a bool) and each edge
495// to an `[a,b]` pair, exactly the observable shape the demo's mega-matrix asserts.
496facett_core::impl_facet_via_elm!(SystemChart, custom_state_json, {
497    fn copy(&mut self) -> Option<String> {
498        self.copy_payload().map(|p| p.as_text())
499    }
500
501    fn state_json(&self) -> serde_json::Value {
502        let s = &self.state;
503        serde_json::json!({
504            "nodes": s.nodes.iter().map(|n| serde_json::json!({
505                "id": n.id,
506                "label": n.label,
507                "badge": n.badge,
508                "pos": [n.pos.0, n.pos.1],
509                "has_detail": !n.detail.is_empty(),
510            })).collect::<Vec<_>>(),
511            "edges": s.edges.iter().map(|e| serde_json::json!([e.a, e.b])).collect::<Vec<_>>(),
512            "selected": self.selected(),
513            // The stable UI ERROR CODE for the empty-data state (facett_core::errcode):
514            // present so tests + consumers react to the CODE, not a matched string.
515            "error_code": if s.nodes.is_empty() {
516                serde_json::json!("facet-syschart-1")
517            } else {
518                serde_json::Value::Null
519            },
520        })
521    }
522
523    /// STRUCTURAL severity (the Robot-UI gate signal): RED when the chart has no
524    /// peers to show (the facet-syschart-1 empty state), the Info floor otherwise.
525    fn severity(&self) -> facett_core::Severity {
526        if self.state.nodes.is_empty() {
527            facett_core::Severity::Error
528        } else {
529            facett_core::Severity::Info
530        }
531    }
532
533    fn selection_json(&self) -> serde_json::Value {
534        match self.selected() {
535            Some(id) => serde_json::json!(id),
536            None => serde_json::Value::Null,
537        }
538    }
539
540    /// Painted with the active `Theme` (nodes/edges/text/accent ring), and its
541    /// canvas takes the host's available width — themeable + resizable.
542    fn caps(&self) -> FacetCaps {
543        FacetCaps::NONE.themeable().resizable().selectable().copyable()
544    }
545
546    /// Opt into typed downcast so a host (e.g. the demo's robot-UI node-select
547    /// toolbar) can forward a selection to [`SystemChart::select`] when this chart
548    /// lives boxed inside a `FacetDeck`.
549    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
550        Some(self)
551    }
552});
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    // `Elm` for `state()`; `Facet` for `state_json`/`selection_json`.
558    use facett_core::{Elm, Facet, harness};
559
560    #[test]
561    fn typed_copy_is_selected_node_or_node_list_rows() {
562        use facett_core::clip::{ClipKind, CopySource};
563        let mut c = SystemChart::new(
564            "sys",
565            vec![
566                SysNode::new("pki", "PKI", Color32::WHITE, (0.1, 0.1)).badge(3),
567                SysNode::new("oidc", "OIDC", Color32::WHITE, (0.9, 0.1)).badge(7),
568            ],
569            vec![SysEdge::new("pki", "oidc")],
570        );
571        // No selection -> the node list as a TSV rectangle.
572        let p = c.copy_payload().unwrap();
573        assert_eq!(p.kind(), ClipKind::Text);
574        assert!(p.as_text().starts_with("id\tlabel\tbadge"), "{}", p.as_text());
575        assert!(p.as_text().contains("\npki\tPKI\t3"));
576        // Selecting narrows to that node's label + badge.
577        c.select("oidc");
578        assert_eq!(c.copy_payload().unwrap().as_text(), "OIDC (badge 7)");
579        assert!(SystemChart::new("empty", vec![], vec![]).copy_payload().is_none());
580    }
581
582    fn sample() -> SystemChart {
583        let nodes = vec![
584            SysNode::new("pki", "PKI", Color32::from_rgb(120, 200, 255), (0.1, 0.1)).badge(3).detail("issued: a\nissued: b"),
585            SysNode::new("oidc", "OIDC", Color32::from_rgb(200, 160, 255), (0.9, 0.1)).badge(7),
586            SysNode::new("nexus", "Nexus", Color32::from_rgb(160, 255, 180), (0.5, 0.9)).badge(0),
587        ];
588        let edges = vec![
589            SysEdge::new("pki", "oidc"),
590            SysEdge::new("pki", "nexus"),
591            SysEdge::new("oidc", "nexus"),
592        ];
593        SystemChart::new("System Map", nodes, edges)
594    }
595
596    #[test]
597    fn select_toggles_and_reports() {
598        let mut c = sample();
599        assert_eq!(c.selected(), None);
600        c.select("oidc");
601        assert_eq!(c.selected(), Some("oidc"));
602        c.select("oidc"); // toggle off
603        assert_eq!(c.selected(), None);
604        c.select("nope"); // unknown → no-op
605        assert_eq!(c.selected(), None);
606    }
607
608    #[test]
609    fn set_badge_and_detail_mutate_named_node() {
610        let mut c = sample();
611        c.set_badge("nexus", 42);
612        c.set_detail("nexus", "repo: maven-releases");
613        let nexus = c.nodes().iter().find(|n| n.id == "nexus").unwrap();
614        assert_eq!(nexus.badge, 42);
615        assert!(nexus.detail.contains("maven-releases"));
616    }
617
618    #[test]
619    fn state_json_carries_every_node_edge_and_selection() {
620        let mut c = sample();
621        c.select("pki");
622        let j = c.state_json();
623        assert_eq!(j["nodes"].as_array().unwrap().len(), 3);
624        assert_eq!(j["edges"].as_array().unwrap().len(), 3);
625        assert_eq!(j["selected"], "pki");
626        // badge + detail flags surfaced for robot assertions
627        let pki = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "pki").unwrap();
628        assert_eq!(pki["badge"], 3);
629        assert_eq!(pki["has_detail"], true);
630    }
631
632    #[test]
633    fn headless_render_draws_and_selection_shows_detail() {
634        // Inject a real chart + a real selection, render offscreen, assert it
635        // both DREW pixels and reported the selected node in its state — the
636        // inject-input/assert-output law, no display.
637        let mut c = sample();
638        c.select("pki");
639        let r = harness::headless_render(&mut c);
640        assert_eq!(r.title, "System Map");
641        assert!(r.drew(), "a 3-node chart should tessellate to vertices");
642        assert_eq!(r.state["selected"], "pki");
643        assert_eq!(r.state["nodes"].as_array().unwrap().len(), 3);
644    }
645
646    #[test]
647    fn caps_advertise_selectable_themeable_resizable() {
648        let caps = sample().caps();
649        assert!(caps.selectable);
650        assert!(caps.themeable);
651        assert!(caps.resizable);
652        assert!(!caps.scalable, "syschart has no zoom yet");
653    }
654
655    // ── FC-2 / FC-9 harness properties: drive `Msg`s, snapshot `state()`, no GPU ──
656
657    #[test]
658    fn drive_select_toggles_via_msg_no_effects() {
659        let mut c = sample();
660        // Select → the id lands; select again → toggles off; unknown id → no-op.
661        let fx = harness::drive(&mut c, [Msg::Select("oidc".into())]);
662        assert!(fx.is_empty(), "syschart issues no Effects (uninhabited)");
663        assert_eq!(c.state().selected.as_deref(), Some("oidc"));
664        harness::drive(&mut c, [Msg::Select("oidc".into())]);
665        assert_eq!(c.state().selected, None, "re-select toggles off");
666        harness::drive(&mut c, [Msg::Select("ghost".into())]);
667        assert_eq!(c.state().selected, None, "unknown id is a no-op");
668    }
669
670    #[test]
671    fn drive_clear_selection_via_msg() {
672        let mut c = sample();
673        let snap = harness::snapshot(&mut c, [Msg::Select("pki".into()), Msg::ClearSelection]);
674        assert_eq!(snap.selected, None);
675    }
676
677    #[test]
678    fn drive_set_badge_and_detail_via_msg() {
679        let mut c = sample();
680        let snap = harness::snapshot(
681            &mut c,
682            [
683                Msg::SetBadge("nexus".into(), 42),
684                Msg::SetDetail("nexus".into(), "repo: maven-releases".into()),
685                // unknown ids are inert
686                Msg::SetBadge("ghost".into(), 99),
687            ],
688        );
689        let nexus = snap.nodes.iter().find(|n| n.id == "nexus").unwrap();
690        assert_eq!(nexus.badge, 42);
691        assert!(nexus.detail.contains("maven-releases"));
692        assert!(snap.nodes.iter().all(|n| n.id != "ghost"));
693    }
694
695    #[test]
696    fn drive_produces_no_effects_for_any_msg() {
697        let mut c = sample();
698        let fx = harness::drive(
699            &mut c,
700            [
701                Msg::Select("pki".into()),
702                Msg::ClearSelection,
703                Msg::SetBadge("oidc".into(), 5),
704                Msg::SetDetail("oidc".into(), "x".into()),
705            ],
706        );
707        assert!(fx.is_empty(), "the syschart never asks the host to do side work");
708    }
709
710    #[test]
711    fn from_layers_groups_nodes_into_vertical_bands() {
712        let c = SystemChart::from_layers(
713            "layered",
714            vec![
715                vec![SysNode::new("a", "A", Color32::WHITE, (0.0, 0.0))],
716                vec![
717                    SysNode::new("b", "B", Color32::WHITE, (0.0, 0.0)),
718                    SysNode::new("c", "C", Color32::WHITE, (0.0, 0.0)),
719                ],
720            ],
721            vec![SysEdge::new("a", "b")],
722        );
723        // Every node survived the flatten, layout overwrote positions.
724        assert_eq!(c.nodes().len(), 3);
725        let y = |id: &str| c.nodes().iter().find(|n| n.id == id).unwrap().pos.1;
726        // Band 0 (A) sits above band 1 (B, C), which share a y.
727        assert!(y("a") < y("b"), "the first layer is above the second");
728        assert!((y("b") - y("c")).abs() < f32::EPSILON, "same-layer nodes share a band");
729        // A single node in a band is centred; two are spread apart horizontally.
730        let x = |id: &str| c.nodes().iter().find(|n| n.id == id).unwrap().pos.0;
731        assert!((x("a") - 0.5).abs() < f32::EPSILON, "a lone node centres");
732        assert!(x("b") < x("c"), "peers spread across the band");
733    }
734
735    #[test]
736    fn demo_is_a_layered_constellation_that_renders() {
737        let mut c = SystemChart::demo();
738        assert_eq!(c.nodes().len(), 6, "gateway + 3 services + 2 storage");
739        assert_eq!(c.edges().len(), 6);
740        // Three distinct vertical bands (gateway / services / storage).
741        use std::collections::BTreeSet;
742        let bands: BTreeSet<i32> = c.nodes().iter().map(|n| (n.pos.1 * 1000.0) as i32).collect();
743        assert_eq!(bands.len(), 3, "three layers ⇒ three vertical bands");
744        // The demo fixture paints headlessly (the same panel the host mounts).
745        let r = harness::headless_render(&mut c);
746        assert_eq!(r.title, "System Map");
747        assert!(r.drew(), "a 6-node constellation tessellates to vertices");
748        assert_eq!(r.state["nodes"].as_array().unwrap().len(), 6);
749    }
750
751    #[test]
752    fn model_serde_round_trips() {
753        let mut c = sample();
754        c.select("oidc");
755        let json = serde_json::to_value(c.state()).unwrap();
756        let back: SysChartModel = serde_json::from_value(json).unwrap();
757        assert_eq!(&back, c.state(), "the Model round-trips through serde (FC-3)");
758    }
759}