Skip to main content

facett_graphview/
decorated_view.rs

1//! **DecoratedGraphView** — the egui Facet that paints a [`GraphModel`] under a
2//! caller-supplied [`Decorations`] overlay (per-node status **rings** + corner
3//! **badges**, plus emphasis **edges** drawn on top), with pan/drag + scroll-zoom
4//! and click-to-select (lighting the clicked node's downstream subtree). It is
5//! the egui twin of nornir's `src/viz/graph_render.rs::draw_graph` — the same
6//! decorations layer the existing `facett-graphview` render model lacked a *view*
7//! for (the CPU/GPU rasterizer renders to RGBA; this is the interactive egui
8//! pane). Domain-agnostic: facett never learns what a "release gate" is — rings,
9//! badges, and cut edges are opaque [`Decorations`].
10//!
11//! Canonical [`Facet`]: `local()`/`remote()`, `state_json()` (the painted nodes +
12//! their decorations + the lit downstream set), `selection_json()`, `caps()`.
13
14use std::collections::{BTreeSet, HashMap, VecDeque};
15
16use facett_core::clip::{ClipKind, ClipPayload, CopySource};
17use facett_core::{FacetCaps, Semantics};
18use serde::{Deserialize, Serialize};
19
20use crate::model::{Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos, BOX_H, BOX_W};
21
22/// The downstream-reachable set (BFS over forward edges) from `seed`, in node-id
23/// space — the visual twin of an `arch trace`, used to dim everything off the lit
24/// path when a node is selected.
25pub fn downstream_of(model: &GraphModel, seed: &str) -> BTreeSet<String> {
26    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
27    for e in &model.edges {
28        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
29    }
30    let mut lit: BTreeSet<String> = BTreeSet::new();
31    let mut q: VecDeque<&str> = VecDeque::new();
32    lit.insert(seed.to_string());
33    q.push_back(seed);
34    while let Some(cur) = q.pop_front() {
35        if let Some(outs) = adj.get(cur) {
36            for &nxt in outs {
37                if lit.insert(nxt.to_string()) {
38                    q.push_back(nxt);
39                }
40            }
41        }
42    }
43    lit
44}
45
46/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
47/// driver (or a host) drives the pane through, the same effect a canvas
48/// click/drag/scroll produces. Applied by [`DecoratedGraphView::update`].
49#[derive(Clone, Debug, PartialEq)]
50pub enum Msg {
51    /// Select a node by stable id (lights its downstream subtree), or `None` to clear.
52    Select(Option<String>),
53    /// Pan the camera by a screen-space delta (the drag gesture).
54    PanBy(f32, f32),
55    /// Zoom by a scroll amount (multiplicative, clamped) — the scroll-wheel gesture.
56    ZoomBy(f32),
57    /// Reset pan + zoom (the ⊙ fit affordance).
58    Fit,
59}
60
61/// Side work as data (FC-8). The pane does no I/O — every [`Msg`] mutates only the
62/// in-memory [`GraphViewState`] — so this is uninhabited on purpose: the type-checked
63/// statement that [`DecoratedGraphView::update`] never asks the host to do anything.
64#[derive(Clone, Debug, PartialEq)]
65pub enum Effect {}
66
67/// **The complete observable state (FC-1 / FC-3)** of a [`DecoratedGraphView`], in one
68/// serializable, round-trippable struct: the laid-out [`GraphModel`], the
69/// caller-supplied [`Decorations`] overlay, the selected node (keyed on its stable
70/// id, FC-5), and the camera (pan + zoom). [`DecoratedGraphView::state`] hands back a
71/// `&GraphViewState`; a headless driver ([`facett_core::harness`]) snapshots it after
72/// feeding a `Vec<Msg>`.
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct GraphViewState {
75    /// The graph to paint (nodes laid out in world space + edges).
76    pub model: GraphModel,
77    /// The caller-supplied ring/badge + emphasis-edge overlay.
78    pub decorations: Decorations,
79    /// The selected node id, if any (lights its downstream subtree).
80    pub selected: Option<String>,
81    /// Camera pan (screen-space) — the x component.
82    pub pan_x: f32,
83    /// Camera pan (screen-space) — the y component.
84    pub pan_y: f32,
85    /// Camera zoom (world→screen scale), clamped to `[0.25, 4.0]` by interaction.
86    pub zoom: f32,
87}
88
89impl Default for GraphViewState {
90    fn default() -> Self {
91        Self {
92            model: GraphModel::default(),
93            decorations: Decorations::default(),
94            selected: None,
95            pan_x: 0.0,
96            pan_y: 0.0,
97            zoom: 1.0,
98        }
99    }
100}
101
102/// An interactive, decorated pan/zoom graph pane Facet.
103pub struct DecoratedGraphView {
104    title: String,
105    /// All observable state (FC-3).
106    state: GraphViewState,
107}
108
109impl DecoratedGraphView {
110    /// An empty pane titled `title`.
111    pub fn new(title: impl Into<String>) -> Self {
112        Self { title: title.into(), state: GraphViewState::default() }
113    }
114
115    /// Set the graph model (nodes already laid out in world space + edges).
116    pub fn with_model(mut self, model: GraphModel) -> Self {
117        self.state.model = model;
118        self
119    }
120
121    /// Set the decorations overlay (rings/badges + emphasis edges).
122    pub fn with_decorations(mut self, decorations: Decorations) -> Self {
123        self.state.decorations = decorations;
124        self
125    }
126
127    /// Re-title (the deck keys facets off [`Facet::title`]).
128    pub fn set_title(&mut self, title: impl Into<String>) {
129        self.title = title.into();
130    }
131
132    /// **FC-3** — read the complete observable state at any frame boundary.
133    pub fn state(&self) -> &GraphViewState {
134        &self.state
135    }
136
137    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
138    /// (always empty) [`Effect`]s the host should run. The canvas click/drag/scroll
139    /// and the fit button all route their [`Msg`] here too, so live == headless.
140    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
141        match msg {
142            Msg::Select(id) => self.state.selected = id,
143            Msg::PanBy(dx, dy) => {
144                self.state.pan_x += dx;
145                self.state.pan_y += dy;
146            }
147            Msg::ZoomBy(scroll) => {
148                self.state.zoom = (self.state.zoom * (1.0 + scroll * 0.001)).clamp(0.25, 4.0);
149            }
150            Msg::Fit => {
151                self.state.pan_x = 0.0;
152                self.state.pan_y = 0.0;
153                self.state.zoom = 1.0;
154            }
155        }
156        Vec::new()
157    }
158
159    /// Reset pan + zoom (the ⊙ fit affordance). A thin wrapper over the FC-2
160    /// [`update`](Self::update) mutation path.
161    pub fn fit(&mut self) {
162        let _ = self.update(Msg::Fit);
163    }
164
165    /// Select a node by id (lights its downstream subtree), or `None` to clear. A thin
166    /// wrapper over the FC-2 [`update`](Self::update) mutation path.
167    pub fn select(&mut self, id: Option<String>) {
168        let _ = self.update(Msg::Select(id));
169    }
170
171    /// The downstream-lit set for the current selection (empty when none).
172    pub fn lit_set(&self) -> BTreeSet<String> {
173        self.state.selected.as_deref().map(|s| downstream_of(&self.state.model, s)).unwrap_or_default()
174    }
175
176    /// **Discovery-contract `local()`** — a realistic 4-node DAG (a → b, a → c,
177    /// b → d, c → d) decorated with a green coverage ring on `a`, an amber badge
178    /// on `b`, and an emphasis "cut" edge `d → a` (dashed, labelled), so the pane
179    /// paints real chips, rings, a badge, and an overlay edge with no host.
180    pub fn local() -> Self {
181        let n = |id: &str, label: &str, x: f32, y: f32, fill: Color| GraphNode {
182            id: id.into(),
183            label: label.into(),
184            fill,
185            stroke: Color::WHITE,
186            pos: Pos::new(x, y),
187        };
188        let e = |from: &str, to: &str, dashed: bool| GraphEdge {
189            from: from.into(),
190            to: to.into(),
191            color: Color::rgb(200, 200, 220),
192            dashed,
193            label: None,
194        };
195        let model = GraphModel {
196            nodes: vec![
197                n("a", "alpha", 0.0, 0.0, Color::rgb(60, 90, 160)),
198                n("b", "beta", 260.0, -90.0, Color::rgb(60, 140, 90)),
199                n("c", "gamma", 260.0, 90.0, Color::rgb(160, 90, 60)),
200                n("d", "delta", 520.0, 0.0, Color::rgb(120, 90, 160)),
201            ],
202            edges: vec![e("a", "b", false), e("a", "c", false), e("b", "d", false), e("c", "d", false)],
203        };
204        let mut nodes = HashMap::new();
205        nodes.insert(
206            "a".to_string(),
207            NodeDecoration { ring: Some(Color::rgb(90, 200, 140)), ..Default::default() },
208        );
209        nodes.insert(
210            "b".to_string(),
211            NodeDecoration {
212                badge: Some("⚠".into()),
213                badge_color: Some(Color::rgb(230, 180, 60)),
214                ..Default::default()
215            },
216        );
217        let decorations = Decorations {
218            nodes,
219            edges: vec![GraphEdge {
220                from: "d".into(),
221                to: "a".into(),
222                color: Color::rgb(230, 90, 90),
223                dashed: true,
224                label: Some("cut".into()),
225            }],
226        };
227        Self::new("DecoratedGraph").with_model(model).with_decorations(decorations)
228    }
229
230    /// **Discovery-contract `remote()`** — same surface, re-titled; the variant a
231    /// host builds from a server-streamed model. Kept so the matrix sees both ctors.
232    pub fn remote() -> Self {
233        let mut v = Self::local();
234        v.title = "DecoratedGraph (remote)".into();
235        v
236    }
237
238    fn col(c: Color) -> egui::Color32 {
239        c.into()
240    }
241}
242
243impl DecoratedGraphView {
244    /// The copyable text: the selected node's `label` when one is selected, else
245    /// every node label, one per line.
246    pub fn copy_text(&self) -> Option<String> {
247        if self.state.model.nodes.is_empty() {
248            return None;
249        }
250        if let Some(id) = self.state.selected.as_deref() {
251            if let Some(n) = self.state.model.nodes.iter().find(|n| n.id == id) {
252                return Some(n.label.clone());
253            }
254        }
255        Some(self.state.model.nodes.iter().map(|n| n.label.clone()).collect::<Vec<_>>().join("\n"))
256    }
257}
258
259// ── typed copy (§16) — read-only: selected node label or the label list ───────
260impl CopySource for DecoratedGraphView {
261    fn copy_kinds(&self) -> &[ClipKind] {
262        &[ClipKind::Text]
263    }
264    fn copy_payload(&self) -> Option<ClipPayload> {
265        self.copy_text().map(ClipPayload::Text)
266    }
267}
268
269impl DecoratedGraphView {
270    /// **FC-9 render** — a **pure** function of `&self`: it paints the header, the
271    /// edges + decorated chips, and *returns* the [`Msg`]s the interactions produced
272    /// (fit / pan / zoom / node-select). It MUST NOT mutate the [`GraphViewState`];
273    /// the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
274    /// returned messages through [`update`](Self::update). One-frame latency on the
275    /// camera + selection is the standard immediate-mode Elm trade (a repaint follows).
276    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
277        let mut msgs: Vec<Msg> = Vec::new();
278        let th = facett_core::theme(ui);
279        // Effective zoom for this frame's projection: never paint at a non-positive
280        // scale. Read-only — the stored zoom is kept valid by `update`'s clamp.
281        let zoom = if self.state.zoom > 0.0 { self.state.zoom } else { 1.0 };
282
283        // ── header: counts + a fit button ─────────────────────────────────
284        ui.horizontal_wrapped(|ui| {
285            ui.label(
286                egui::RichText::new(format!("{} nodes · {} edges", self.state.model.nodes.len(), self.state.model.edges.len()))
287                    .color(th.text)
288                    .strong(),
289            );
290            ui.separator();
291            ui.label(
292                egui::RichText::new(format!("{} ring(s) · {} badge(s)",
293                    self.state.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
294                    self.state.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
295                ))
296                .color(th.text_dim),
297            );
298            if ui.button("⊙ fit").clicked() {
299                msgs.push(Msg::Fit);
300            }
301        });
302        ui.separator();
303
304        let (resp, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
305        painter.rect_filled(resp.rect, 4.0, th.bg);
306        if resp.dragged() {
307            let d = resp.drag_delta();
308            msgs.push(Msg::PanBy(d.x, d.y));
309        }
310        if resp.hovered() {
311            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
312            if scroll != 0.0 {
313                msgs.push(Msg::ZoomBy(scroll));
314            }
315        }
316        let origin = resp.rect.center() + egui::vec2(self.state.pan_x, self.state.pan_y);
317        let project = |p: Pos| origin + egui::vec2(p.x, p.y) * zoom;
318
319        // click-to-select.
320        if let Some(click) = resp.clicked().then(|| resp.interact_pointer_pos()).flatten() {
321            let hit = self.state.model.nodes.iter().find_map(|nd| {
322                let c = project(nd.pos);
323                let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
324                rect.contains(click).then(|| nd.id.clone())
325            });
326            msgs.push(Msg::Select(hit));
327        }
328
329        let lit = self.lit_set();
330        let highlighting = self.state.selected.is_some();
331        let dim = |c: egui::Color32| c.linear_multiply(0.22);
332
333        let idx: HashMap<&str, &GraphNode> =
334            self.state.model.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
335
336        // edges first.
337        let draw_edge = |e: &GraphEdge, emphasise: bool| {
338            let (Some(fa), Some(fb)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
339                return;
340            };
341            let on_trace = highlighting && lit.contains(&e.from) && lit.contains(&e.to);
342            let a = project(fa.pos) + egui::vec2(BOX_W * 0.5 * zoom, 0.0);
343            let b = project(fb.pos) - egui::vec2(BOX_W * 0.5 * zoom, 0.0);
344            let mut color = Self::col(e.color);
345            if !emphasise && highlighting && !on_trace {
346                color = dim(color);
347            }
348            let w = if emphasise { 2.6 } else if on_trace { 2.4 } else { 1.4 };
349            if e.dashed {
350                // sampled dashed line.
351                let n = 16;
352                for i in 0..n {
353                    if i % 2 == 0 {
354                        let t0 = i as f32 / n as f32;
355                        let t1 = (i + 1) as f32 / n as f32;
356                        painter.line_segment([a.lerp(b, t0), a.lerp(b, t1)], egui::Stroke::new(w, color));
357                    }
358                }
359            } else {
360                painter.line_segment([a, b], egui::Stroke::new(w, color));
361            }
362            // arrowhead.
363            let dir = (b - a).normalized();
364            let perp = egui::vec2(-dir.y, dir.x);
365            let head = 6.0 * zoom.clamp(0.6, 1.6);
366            painter.line_segment([b, b - dir * head + perp * head * 0.5], egui::Stroke::new(w, color));
367            painter.line_segment([b, b - dir * head - perp * head * 0.5], egui::Stroke::new(w, color));
368            if let Some(lbl) = &e.label {
369                if zoom > 0.45 && !lbl.is_empty() {
370                    painter.text(
371                        a.lerp(b, 0.5) - egui::vec2(0.0, 6.0 * zoom),
372                        egui::Align2::CENTER_BOTTOM,
373                        lbl,
374                        egui::FontId::proportional(10.0 * zoom.clamp(0.7, 1.3)),
375                        if emphasise { color } else { th.text_dim },
376                    );
377                }
378            }
379        };
380        for e in &self.state.model.edges {
381            draw_edge(e, false);
382        }
383        for e in &self.state.decorations.edges {
384            draw_edge(e, true);
385        }
386
387        // chips.
388        for nd in &self.state.model.nodes {
389            let c = project(nd.pos);
390            let on_trace = highlighting && lit.contains(&nd.id);
391            let mut fill = Self::col(nd.fill);
392            let mut stroke = Self::col(nd.stroke);
393            if highlighting && !on_trace {
394                fill = dim(fill);
395                stroke = dim(stroke);
396            }
397            let rect = egui::Rect::from_center_size(c, egui::vec2(BOX_W, BOX_H) * zoom);
398            painter.rect_filled(rect, 5.0 * zoom, fill);
399
400            let deco = self.state.decorations.nodes.get(&nd.id);
401            let ring = if self.state.selected.as_deref() == Some(nd.id.as_str()) {
402                egui::Stroke::new(3.0, th.accent)
403            } else if let Some(rc) = deco.and_then(|d| d.ring) {
404                let rc = Self::col(rc);
405                egui::Stroke::new(2.4, if highlighting && !on_trace { dim(rc) } else { rc })
406            } else {
407                egui::Stroke::new(1.4, stroke)
408            };
409            painter.rect_stroke(rect, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
410
411            if zoom > 0.45 {
412                painter.text(
413                    c,
414                    egui::Align2::CENTER_CENTER,
415                    &nd.label,
416                    egui::FontId::proportional(11.0 * zoom.clamp(0.7, 1.4)),
417                    th.text,
418                );
419                if let Some(badge) = deco.and_then(|d| d.badge.as_deref()) {
420                    let bc = deco.and_then(|d| d.badge_color).map(Self::col).unwrap_or(th.text);
421                    painter.text(
422                        rect.right_top() + egui::vec2(-2.0, 1.0),
423                        egui::Align2::RIGHT_TOP,
424                        badge,
425                        egui::FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
426                        if highlighting && !on_trace { dim(bc) } else { bc },
427                    );
428                }
429            }
430        }
431
432        // pane-level a11y summary.
433        resp.widget_info(|| {
434            Semantics::image(format!(
435                "decorated graph — {} nodes, {} edges, selected {}",
436                self.state.model.nodes.len(),
437                self.state.model.edges.len(),
438                self.state.selected.as_deref().unwrap_or("none"),
439            ))
440            .widget_info()
441        });
442
443        #[cfg(feature = "testmatrix")]
444        facett_core::testmatrix::emit(
445            "facett-graphview::DecoratedGraphView::view",
446            "ui_render",
447            !self.state.model.nodes.is_empty(),
448            &format!("nodes={} edges={} lit={}", self.state.model.nodes.len(), self.state.model.edges.len(), lit.len()),
449        );
450
451        msgs
452    }
453}
454
455// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
456impl facett_core::Elm for DecoratedGraphView {
457    type Model = GraphViewState;
458    type Msg = Msg;
459    type Effect = Effect;
460
461    fn title(&self) -> &str {
462        &self.title
463    }
464    fn state(&self) -> &GraphViewState {
465        &self.state
466    }
467    fn update(&mut self, msg: Msg) -> Vec<Effect> {
468        DecoratedGraphView::update(self, msg)
469    }
470    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
471        DecoratedGraphView::view(self, ui)
472    }
473}
474
475// The bridge macro writes `impl Facet for DecoratedGraphView` from the `Elm` impl:
476// `title`, the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra
477// overrides below. **Form 3** (`custom_state_json`) because the pane publishes a
478// RICHER `state_json` than a plain `serde(state())`: the derived counts (rings/badges),
479// the lit downstream set, and the flattened per-node/per-edge introspection keys that
480// facett-demo's mega_matrix reads are computed from the model + decorations, not the
481// raw serde dump — so the default `state_json` is suppressed and this byte-for-byte
482// one supplied.
483facett_core::impl_facet_via_elm!(DecoratedGraphView, custom_state_json, {
484    fn state_json(&self) -> serde_json::Value {
485        let lit = self.lit_set();
486        serde_json::json!({
487            "title": self.title,
488            "node_count": self.state.model.nodes.len(),
489            "edge_count": self.state.model.edges.len(),
490            "decoration_edges": self.state.decorations.edges.len(),
491            "rings": self.state.decorations.nodes.values().filter(|d| d.ring.is_some()).count(),
492            "badges": self.state.decorations.nodes.values().filter(|d| d.badge.is_some()).count(),
493            "selected": self.state.selected,
494            "lit": lit.iter().cloned().collect::<Vec<_>>(),
495            "zoom": self.state.zoom,
496            "nodes": self.state.model.nodes.iter().map(|n| {
497                let d = self.state.decorations.nodes.get(&n.id);
498                serde_json::json!({
499                    "id": n.id,
500                    "label": n.label,
501                    "x": n.pos.x,
502                    "y": n.pos.y,
503                    "ring": d.and_then(|d| d.ring).is_some(),
504                    "badge": d.and_then(|d| d.badge.clone()),
505                })
506            }).collect::<Vec<_>>(),
507            "edges": self.state.model.edges.iter().map(|e| serde_json::json!({
508                "from": e.from, "to": e.to, "dashed": e.dashed,
509            })).collect::<Vec<_>>(),
510        })
511    }
512
513    fn copy(&mut self) -> Option<String> {
514        self.copy_payload().map(|p| p.as_text())
515    }
516
517    fn selection_json(&self) -> serde_json::Value {
518        match &self.state.selected {
519            Some(id) => serde_json::json!({ "node": id, "downstream": self.lit_set().iter().cloned().collect::<Vec<_>>() }),
520            None => serde_json::Value::Null,
521        }
522    }
523
524    /// Themed (chips/rings follow the palette) + resizable + selectable (a node
525    /// drill-in) + navigable (pan/drag + scroll-zoom + fit).
526    fn caps(&self) -> FacetCaps {
527        FacetCaps::NONE.themeable().resizable().selectable().navigable().copyable()
528    }
529
530    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
531        Some(self)
532    }
533});
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    // `Facet` for `state_json`/`selection_json`/`title`/`caps`; `state()`/`select()`/
539    // `update()`/`fit()` are inherent.
540    use facett_core::Facet;
541
542    #[test]
543    fn typed_copy_is_selected_label_or_the_label_list() {
544        use facett_core::clip::{ClipKind, CopySource};
545        let mut v = DecoratedGraphView::local();
546        // No selection -> a multi-line label list.
547        let p = v.copy_payload().expect("populated graph copies");
548        assert_eq!(p.kind(), ClipKind::Text);
549        assert!(p.as_text().contains('\n'), "label list is multi-line: {}", p.as_text());
550        // Select node "a" -> the copy narrows to that node's single label.
551        v.select(Some("a".into()));
552        let sel = v.copy_payload().unwrap().as_text();
553        assert!(!sel.contains('\n'), "selected copy is one label: {sel}");
554        assert_eq!(sel, v.state().model.nodes.iter().find(|n| n.id == "a").unwrap().label);
555    }
556
557    #[test]
558    fn downstream_is_bfs_closure_from_seed() {
559        let v = DecoratedGraphView::local();
560        // a → b, a → c, b → d, c → d. Downstream of a = all; of b = {b, d}.
561        let from_a = downstream_of(&v.state().model, "a");
562        assert!(from_a.contains("a") && from_a.contains("b") && from_a.contains("c") && from_a.contains("d"));
563        let from_b = downstream_of(&v.state().model, "b");
564        assert!(from_b.contains("b") && from_b.contains("d"));
565        assert!(!from_b.contains("a"), "BFS is forward-only");
566        assert!(!from_b.contains("c"), "c is not downstream of b");
567    }
568
569    /// inject-assert (LAW 6): the local view seeds a real decorated graph; assert
570    /// its `state_json` reports the nodes, the ring + badge decorations, the
571    /// emphasis edge, and — after a selection — the lit downstream set.
572    #[test]
573    fn local_view_reports_decorations_and_selection() {
574        let mut v = DecoratedGraphView::local();
575        let j = v.state_json();
576        assert_eq!(<DecoratedGraphView as Facet>::title(&v), "DecoratedGraph");
577        assert_eq!(j["node_count"], 4);
578        assert_eq!(j["edge_count"], 4);
579        assert_eq!(j["decoration_edges"], 1, "the dashed cut edge");
580        assert_eq!(j["rings"], 1, "a green coverage ring on `a`");
581        assert_eq!(j["badges"], 1, "a ⚠ badge on `b`");
582        // node `a` carries a ring, `b` carries the badge text.
583        let nodes = j["nodes"].as_array().unwrap();
584        let a = nodes.iter().find(|n| n["id"] == "a").unwrap();
585        assert_eq!(a["ring"], true);
586        let b = nodes.iter().find(|n| n["id"] == "b").unwrap();
587        assert_eq!(b["badge"], "⚠");
588        // no selection → null + empty lit set.
589        assert_eq!(j["selected"], serde_json::Value::Null);
590        assert!(j["lit"].as_array().unwrap().is_empty());
591
592        // select `b` → the lit set is its downstream {b, d}.
593        v.select(Some("b".into()));
594        let j2 = v.state_json();
595        assert_eq!(j2["selected"], "b");
596        let lit: BTreeSet<String> =
597            j2["lit"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
598        assert_eq!(lit, ["b", "d"].iter().map(|s| s.to_string()).collect::<BTreeSet<_>>());
599        let sel = v.selection_json();
600        assert_eq!(sel["node"], "b");
601    }
602
603    #[test]
604    fn fit_resets_pan_and_zoom() {
605        let mut v = DecoratedGraphView::local();
606        v.state.pan_x = 20.0;
607        v.state.pan_y = 10.0;
608        v.state.zoom = 2.5;
609        v.fit();
610        assert_eq!((v.state().pan_x, v.state().pan_y), (0.0, 0.0));
611        assert_eq!(v.state().zoom, 1.0);
612        assert_eq!(<DecoratedGraphView as Facet>::title(&DecoratedGraphView::remote()), "DecoratedGraph (remote)");
613    }
614
615    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
616    //    harness and assert the resulting `GraphViewState` — no egui, no GPU. ──────
617    #[test]
618    fn harness_snapshot_drives_camera_and_selection() {
619        use facett_core::harness;
620        let mut v = DecoratedGraphView::local();
621        // Pan + zoom + select are all observable in the headless snapshot.
622        let snap = harness::snapshot(
623            &mut v,
624            [Msg::PanBy(12.0, -4.0), Msg::ZoomBy(500.0), Msg::Select(Some("b".into()))],
625        );
626        assert_eq!((snap.pan_x, snap.pan_y), (12.0, -4.0), "pan accumulates");
627        assert!(snap.zoom > 1.0 && snap.zoom <= 4.0, "zoom grows but stays clamped: {}", snap.zoom);
628        assert_eq!(snap.selected.as_deref(), Some("b"), "selection is observable headlessly");
629        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
630        // Fit resets the camera; ZoomBy is clamped at the low end too.
631        let snap = harness::snapshot(&mut v, [Msg::ZoomBy(-100000.0), Msg::Fit]);
632        assert_eq!((snap.pan_x, snap.pan_y, snap.zoom), (0.0, 0.0, 1.0), "Fit re-centers + unit-zooms");
633    }
634
635    #[test]
636    fn drive_reports_no_effects_and_state_round_trips() {
637        use facett_core::harness;
638        let mut v = DecoratedGraphView::local();
639        // FC-8: the pane does no I/O, so the effect stream is always empty.
640        let effects = harness::drive(&mut v, [Msg::Select(Some("a".into())), Msg::PanBy(3.0, 3.0)]);
641        assert!(effects.is_empty(), "FC-8: the pane emits no Effects");
642        // FC-3: the observable Model (graph + decorations + camera + selection)
643        // round-trips through serde.
644        let json = serde_json::to_value(v.state()).unwrap();
645        assert_eq!(json["selected"], "a");
646        let back: GraphViewState = serde_json::from_value(json).unwrap();
647        assert_eq!(&back, v.state(), "serde(state) -> state round-trips (the whole graph survives)");
648    }
649
650    /// FC-5: node selection is keyed on the **stable node id**, so it survives a full
651    /// re-layout — moving every node to new world coordinates leaves the selection and
652    /// its lit downstream set intact (they resolve by id, never by position).
653    #[test]
654    fn selection_survives_relayout_on_stable_id() {
655        use facett_core::harness;
656        let mut v = DecoratedGraphView::local();
657        let _ = harness::drive(&mut v, [Msg::Select(Some("b".into()))]);
658        let lit_before = v.lit_set();
659        // Re-layout: shove every node to a fresh position (same ids + edges).
660        let mut relaid = v.state().model.clone();
661        for (i, n) in relaid.nodes.iter_mut().enumerate() {
662            n.pos = Pos::new(1000.0 + i as f32 * 37.0, -500.0 - i as f32 * 19.0);
663        }
664        v = v.with_model(relaid);
665        assert_eq!(v.state().selected.as_deref(), Some("b"), "selection keyed on id survives the move");
666        assert_eq!(v.lit_set(), lit_before, "the lit downstream set is identical after re-layout");
667        assert_eq!(v.selection_json()["node"], "b");
668        // And the id still resolves to a (now-relocated) real node.
669        assert!(v.state().model.nodes.iter().any(|n| n.id == "b"));
670    }
671}