Skip to main content

adam_web_ui/graph/
data.rs

1//! Serialization bridge from [`adam_rs::Sheet`] to D3-ready JSON, for `GraphView`.
2
3use crate::labels::Labels;
4use adam_rs::{CellId, ConditionalId, RelationshipId, Sheet};
5use serde::Serialize;
6use slotmap::Key;
7
8/// Node kind tag used in the D3 graph.
9#[derive(Serialize, Clone, PartialEq, Eq)]
10pub enum NodeKind {
11    /// A value cell — rendered as a `<rect>`.
12    Cell,
13    /// A multi-way constraint — rendered as a `<circle>`.
14    Relationship,
15    /// A conditional switch — rendered as a diamond (rotated `<rect>`).
16    Conditional,
17    /// An invisible junction node grouping a branch's relationships when a
18    /// branch (or the default) holds more than one; rendered as a zero-size point.
19    Branch,
20}
21
22/// A single node in the D3 graph.
23#[derive(Serialize, Clone, PartialEq)]
24pub struct NodeData {
25    /// Stable string ID: `"c{ffi}"` for cells, `"r{ffi}"` for relationships, `"cond{ffi}"` for
26    /// conditionals, `"br{ffi}_{branch}"` for a named branch's junction node, `"br{ffi}_def"`
27    /// for the default's junction node.
28    pub id: String,
29    /// The kind of node, determining its visual rendering.
30    pub kind: NodeKind,
31    /// Cell label (e.g. `"a"`); empty string for relationships, conditionals, and branch junction nodes.
32    pub label: String,
33    /// Current cell value as a display string; empty string for relationships, conditionals, and
34    /// branch junction nodes.
35    pub value: String,
36}
37
38/// Link kind tag used in the D3 graph.
39#[derive(Serialize, Clone, PartialEq, Eq)]
40pub enum LinkKind {
41    /// A regular constraint edge (cell ↔ relationship, or match cell → conditional node).
42    Constraint,
43    /// A control edge from a conditional node toward a branch's relationship(s): a direct edge
44    /// to the relationship when the branch has at most one, or, when it has more than one, a
45    /// two-hop path through an intermediate `Branch` junction node (see [`LinkData`]'s doc for
46    /// the full two-hop description).
47    Control,
48}
49
50/// A single edge in the D3 graph.
51///
52/// `directed` is `true` when this specific link's direction is known and should render
53/// with an arrowhead from `source` to `target`; `false` renders it as a plain undirected
54/// line. This is a per-link fact, not a sheet-wide one: a cell ↔ relationship link is
55/// directed only once *that relationship* has a selected method, so one relationship
56/// having a cached plan says nothing about another relationship (or a currently-inactive
57/// conditional branch's relationship) that doesn't. A match cell → conditional link is
58/// always directed — evaluating a conditional's match expression doesn't depend on any
59/// relationship ever being planned. Control edges are always directed too — from a
60/// conditional node to a relationship, or, when a branch has more than one relationship, from
61/// the conditional to an intermediate `Branch` node and from that node to each relationship —
62/// and styled by `branch_index` and `branch_active`.
63#[derive(Serialize, Clone, PartialEq)]
64pub struct LinkData {
65    /// Stable string ID of the source node.
66    pub source: String,
67    /// Stable string ID of the target node.
68    pub target: String,
69    /// The kind of link, determining its visual rendering.
70    pub kind: LinkKind,
71    /// `true` if this link's direction is known and should render with an arrowhead.
72    pub directed: bool,
73    /// Branch index for `Control` links; `None` for `Constraint` links and default-branch control links.
74    pub branch_index: Option<usize>,
75    /// `true` if this branch is currently active; `None` for `Constraint` links.
76    pub branch_active: Option<bool>,
77}
78
79/// Complete graph snapshot ready for JSON serialization and delivery to D3.
80#[derive(Serialize, Clone, PartialEq)]
81pub struct GraphData {
82    /// All nodes in the graph snapshot.
83    pub nodes: Vec<NodeData>,
84    /// All links (constraint and control) in the graph snapshot.
85    pub links: Vec<LinkData>,
86    /// Stable IDs of cells that changed during the last `propagate()` call.
87    pub changed: Vec<String>,
88    /// Stable IDs of cells forced by an active relationship (see
89    /// [`adam_rs::Sheet::is_forced`]); consumers should disable input for these
90    /// cells and may render them distinctly.
91    pub forced: Vec<String>,
92    /// Stable IDs of relationships forced by the planner (see
93    /// [`adam_rs::Sheet::is_relationship_forced`]); consumers may render them
94    /// distinctly, along with their constraint edges.
95    pub forced_relationships: Vec<String>,
96}
97
98fn cell_node_id(id: CellId) -> String {
99    format!("c{}", id.data().as_ffi())
100}
101
102fn rel_node_id(id: RelationshipId) -> String {
103    format!("r{}", id.data().as_ffi())
104}
105
106fn cond_node_id(id: ConditionalId) -> String {
107    format!("cond{}", id.data().as_ffi())
108}
109
110/// Returns the stable node ID for the junction node of one branch (or the default) of a
111/// conditional: `"br{ffi}_{branch}"` for a named branch, `"br{ffi}_def"` for the default.
112fn branch_node_id(id: ConditionalId, branch: Option<usize>) -> String {
113    match branch {
114        Some(b) => format!("br{}_{}", id.data().as_ffi(), b),
115        None => format!("br{}_def", id.data().as_ffi()),
116    }
117}
118
119/// Pushes control links (and, when needed, a junction node) for one branch — named or
120/// default — of a conditional.
121///
122/// - Postcondition: when `rels.len() >= 2`, pushes one `Branch` node, one
123///   `conditional → branch` control link, and one `branch → relationship` control link per
124///   entry in `rels`, all sharing `branch_index`/`branch_active`. When `rels.len() <= 1`,
125///   pushes at most one direct `conditional → relationship` control link (none if `rels` is
126///   empty), matching the pre-junction-node behavior.
127/// - Complexity: O(k) where k = `rels.len()` (the number of relationships in this branch or default).
128fn push_branch_links(
129    nodes: &mut Vec<NodeData>,
130    links: &mut Vec<LinkData>,
131    cond_id_str: &str,
132    cond_id: ConditionalId,
133    branch_index: Option<usize>,
134    branch_active: bool,
135    rels: &[RelationshipId],
136) {
137    if rels.len() >= 2 {
138        let bnode_id = branch_node_id(cond_id, branch_index);
139        nodes.push(NodeData {
140            id: bnode_id.clone(),
141            kind: NodeKind::Branch,
142            label: String::new(),
143            value: String::new(),
144        });
145        links.push(LinkData {
146            source: cond_id_str.to_string(),
147            target: bnode_id.clone(),
148            kind: LinkKind::Control,
149            directed: true,
150            branch_index,
151            branch_active: Some(branch_active),
152        });
153        for &rel_id in rels {
154            links.push(LinkData {
155                source: bnode_id.clone(),
156                target: rel_node_id(rel_id),
157                kind: LinkKind::Control,
158                directed: true,
159                branch_index,
160                branch_active: Some(branch_active),
161            });
162        }
163    } else {
164        for &rel_id in rels {
165            links.push(LinkData {
166                source: cond_id_str.to_string(),
167                target: rel_node_id(rel_id),
168                kind: LinkKind::Control,
169                directed: true,
170                branch_index,
171                branch_active: Some(branch_active),
172            });
173        }
174    }
175}
176
177/// Serializes `sheet` and `labels` into a [`GraphData`] snapshot for D3.
178///
179/// Constraint links: when a relationship has a cached plan (`sheet.selected_method` returns
180/// `Some`) its links are directed (inputs → relationship → outputs, [`LinkData::directed`]
181/// `true`). Otherwise all cells adjacent to that relationship are emitted as undirected
182/// source→relationship edges (`directed` `false`) — this is decided per relationship, so one
183/// relationship having a plan never marks another relationship's links as directed.
184///
185/// Conditional nodes: for each conditional, emits one `Conditional` node, one always-directed
186/// `Constraint` link from the match cell to the conditional node, and one `Control` link per
187/// relationship in each branch/default. When a branch (or the default) holds more than one
188/// relationship, its control links route through an intermediate `Branch` junction node
189/// (`conditional → branch →
190/// relationship`) instead of a direct edge, so the branch's relationships visually group
191/// together; branches with 0 or 1 relationships keep a direct edge. Control links carry
192/// `branch_index` and `branch_active` for rendering, shared identically across both hops of a
193/// junction-routed branch.
194///
195/// - Complexity: O(c + r + e + cond·b·k) where c = cells, r = relationships, e = adjacency pairs,
196///   cond = conditionals, b = branches per conditional, k = keys per branch.
197pub fn to_graph_data(sheet: &Sheet, labels: &Labels) -> GraphData {
198    let mut nodes = Vec::new();
199    let mut links = Vec::new();
200
201    // Cell nodes
202    for id in sheet.cells() {
203        let (label, value) = labels
204            .cells
205            .get(&id)
206            .map(|m| (m.label.clone(), (m.display)(sheet)))
207            .unwrap_or_default();
208        nodes.push(NodeData {
209            id: cell_node_id(id),
210            kind: NodeKind::Cell,
211            label,
212            value,
213        });
214    }
215
216    // Relationship nodes and constraint links
217    for id in sheet.relationships() {
218        nodes.push(NodeData {
219            id: rel_node_id(id),
220            kind: NodeKind::Relationship,
221            label: String::new(),
222            value: String::new(),
223        });
224
225        if let Some(method_idx) = sheet.selected_method(id) {
226            if let Some(inputs) = sheet.method_inputs(id, method_idx) {
227                for &cell_id in inputs {
228                    links.push(LinkData {
229                        source: cell_node_id(cell_id),
230                        target: rel_node_id(id),
231                        kind: LinkKind::Constraint,
232                        directed: true,
233                        branch_index: None,
234                        branch_active: None,
235                    });
236                }
237            }
238            if let Some(outputs) = sheet.method_outputs(id, method_idx) {
239                for &cell_id in outputs {
240                    links.push(LinkData {
241                        source: rel_node_id(id),
242                        target: cell_node_id(cell_id),
243                        kind: LinkKind::Constraint,
244                        directed: true,
245                        branch_index: None,
246                        branch_active: None,
247                    });
248                }
249            }
250        } else if let Some(adj) = sheet.relationship_adj(id) {
251            for &cell_id in adj {
252                links.push(LinkData {
253                    source: cell_node_id(cell_id),
254                    target: rel_node_id(id),
255                    kind: LinkKind::Constraint,
256                    directed: false,
257                    branch_index: None,
258                    branch_active: None,
259                });
260            }
261        }
262    }
263
264    // Conditional nodes and control links
265    for cond_id in sheet.conditionals() {
266        let node_id = cond_node_id(cond_id);
267        nodes.push(NodeData {
268            id: node_id.clone(),
269            kind: NodeKind::Conditional,
270            label: String::new(),
271            value: String::new(),
272        });
273
274        // Constraint links: every match cell → conditional node. Always directed --
275        // evaluating a conditional's match expression doesn't depend on any relationship
276        // (in this branch or any other) ever having a selected method, so this must not
277        // be gated on relationship planning the way cell ↔ relationship links are.
278        if let Some(match_cells) = sheet.conditional_match_cells(cond_id) {
279            for &match_cell in match_cells {
280                links.push(LinkData {
281                    source: cell_node_id(match_cell),
282                    target: node_id.clone(),
283                    kind: LinkKind::Constraint,
284                    directed: true,
285                    branch_index: None,
286                    branch_active: None,
287                });
288            }
289        }
290
291        // `to_graph_data` is read-only display code, not the `propagate()` path: by the
292        // time it runs, `propagate()` has already evaluated this same expression
293        // successfully, so a fresh failure here would itself be a precondition violation.
294        // Treat it as "no active branch" for rendering rather than threading Result through
295        // graph construction.
296        let active_branch = sheet.conditional_active_branch(cond_id).ok().flatten();
297
298        // Control links for named branches
299        let branch_count = sheet.conditional_branch_count(cond_id).unwrap_or(0);
300        for branch in 0..branch_count {
301            let is_active = active_branch == Some(branch);
302            if let Some(rels) = sheet.conditional_branch_relationships(cond_id, branch) {
303                push_branch_links(
304                    &mut nodes,
305                    &mut links,
306                    &node_id,
307                    cond_id,
308                    Some(branch),
309                    is_active,
310                    rels,
311                );
312            }
313        }
314
315        // Control links for default relationships
316        let default_active = active_branch.is_none();
317        if let Some(default_rels) = sheet.conditional_default_relationships(cond_id) {
318            push_branch_links(
319                &mut nodes,
320                &mut links,
321                &node_id,
322                cond_id,
323                None,
324                default_active,
325                default_rels,
326            );
327        }
328    }
329
330    let changed = sheet.changed().map(cell_node_id).collect();
331    let forced = sheet.forced_cells().map(cell_node_id).collect();
332    let forced_relationships = sheet.forced_relationships().map(rel_node_id).collect();
333
334    GraphData {
335        nodes,
336        links,
337        changed,
338        forced,
339        forced_relationships,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use adam_rs::{MatchExpr, Method};
347
348    fn demo_sheet() -> (Sheet, Labels) {
349        let mut sheet = Sheet::new();
350        let mut labels = Labels::new();
351
352        let a = sheet.add_cell(2.0_f64);
353        labels.add_cell::<f64>(a, "a");
354        let b = sheet.add_cell(3.0_f64);
355        labels.add_cell::<f64>(b, "b");
356        let c = sheet.add_cell(0.0_f64);
357        labels.add_cell::<f64>(c, "c");
358
359        sheet
360            .add_relationship(vec![Method::from_fn_2_1([a, b], c, |x: &f64, y: &f64| {
361                Ok(x * y)
362            })])
363            .unwrap();
364
365        (sheet, labels)
366    }
367
368    // Separate helper that adds the output cell first so propagation succeeds.
369    fn demo_sheet_with_plan() -> (Sheet, Labels) {
370        let mut sheet = Sheet::new();
371        let mut labels = Labels::new();
372
373        // c added first → lowest strength (output by default).
374        let c = sheet.add_cell(0.0_f64);
375        labels.add_cell::<f64>(c, "c");
376        let a = sheet.add_cell(2.0_f64);
377        labels.add_cell::<f64>(a, "a");
378        let b = sheet.add_cell(3.0_f64);
379        labels.add_cell::<f64>(b, "b");
380
381        sheet
382            .add_relationship(vec![Method::from_fn_2_1([a, b], c, |x: &f64, y: &f64| {
383                Ok(x * y)
384            })])
385            .unwrap();
386
387        (sheet, labels)
388    }
389
390    fn sheet_with_conditional() -> (Sheet, Labels) {
391        let mut sheet = Sheet::new();
392        let mut labels = Labels::new();
393
394        let a = sheet.add_cell(2.0_f64);
395        labels.add_cell::<f64>(a, "a");
396        let b = sheet.add_cell(0.0_f64);
397        labels.add_cell::<f64>(b, "b");
398        let p = sheet.add_cell(0_i32);
399        labels.add_cell::<i32>(p, "p");
400
401        let rel = sheet
402            .add_relationship(vec![
403                Method::from_fn_1_1(a, b, |v: &f64| Ok(*v)),
404                Method::from_fn_1_1(b, a, |v: &f64| Ok(*v)),
405            ])
406            .unwrap();
407
408        sheet
409            .add_conditional(MatchExpr::cell(p), vec![(vec![0_i32], vec![rel])], vec![])
410            .unwrap();
411
412        (sheet, labels)
413    }
414
415    fn sheet_with_forced_conditional() -> (Sheet, Labels) {
416        let mut sheet = Sheet::new();
417        let mut labels = Labels::new();
418
419        let a = sheet.add_cell(2.0_f64);
420        labels.add_cell::<f64>(a, "a");
421        let b = sheet.add_cell(0.0_f64);
422        labels.add_cell::<f64>(b, "b");
423        let p = sheet.add_cell(0_i32);
424        labels.add_cell::<i32>(p, "p");
425
426        let rel = sheet
427            .add_relationship(vec![Method::from_fn_1_1(a, b, |v: &f64| Ok(*v))])
428            .unwrap();
429
430        sheet
431            .add_conditional(MatchExpr::cell(p), vec![(vec![0_i32], vec![rel])], vec![])
432            .unwrap();
433
434        (sheet, labels)
435    }
436
437    fn sheet_with_multi_relationship_branch() -> (Sheet, Labels) {
438        let mut sheet = Sheet::new();
439        let mut labels = Labels::new();
440
441        let a = sheet.add_cell(2.0_f64);
442        labels.add_cell::<f64>(a, "a");
443        let b = sheet.add_cell(0.0_f64);
444        labels.add_cell::<f64>(b, "b");
445        let c = sheet.add_cell(0.0_f64);
446        labels.add_cell::<f64>(c, "c");
447        let p = sheet.add_cell(0_i32);
448        labels.add_cell::<i32>(p, "p");
449
450        let rel1 = sheet
451            .add_relationship(vec![Method::from_fn_1_1(a, b, |v: &f64| Ok(*v))])
452            .unwrap();
453        let rel2 = sheet
454            .add_relationship(vec![Method::from_fn_1_1(a, c, |v: &f64| Ok(*v))])
455            .unwrap();
456
457        sheet
458            .add_conditional(
459                MatchExpr::cell(p),
460                vec![(vec![0_i32], vec![rel1, rel2])],
461                vec![],
462            )
463            .unwrap();
464
465        (sheet, labels)
466    }
467
468    fn sheet_with_multi_relationship_default() -> (Sheet, Labels) {
469        let mut sheet = Sheet::new();
470        let mut labels = Labels::new();
471
472        let a = sheet.add_cell(2.0_f64);
473        labels.add_cell::<f64>(a, "a");
474        let b = sheet.add_cell(0.0_f64);
475        labels.add_cell::<f64>(b, "b");
476        let c = sheet.add_cell(0.0_f64);
477        labels.add_cell::<f64>(c, "c");
478        let p = sheet.add_cell(0_i32);
479        labels.add_cell::<i32>(p, "p");
480
481        let rel1 = sheet
482            .add_relationship(vec![Method::from_fn_1_1(a, b, |v: &f64| Ok(*v))])
483            .unwrap();
484        let rel2 = sheet
485            .add_relationship(vec![Method::from_fn_1_1(a, c, |v: &f64| Ok(*v))])
486            .unwrap();
487
488        sheet
489            .add_conditional::<i32>(MatchExpr::cell(p), vec![], vec![rel1, rel2])
490            .unwrap();
491
492        (sheet, labels)
493    }
494
495    #[test]
496    fn to_graph_data_omits_branch_node_for_single_relationship_branch() {
497        let (sheet, labels) = sheet_with_conditional();
498        let data = to_graph_data(&sheet, &labels);
499        assert!(
500            !data.nodes.iter().any(|n| n.kind == NodeKind::Branch),
501            "expected no Branch node when every branch has at most one relationship"
502        );
503    }
504
505    #[test]
506    fn to_graph_data_routes_multi_relationship_branch_through_branch_node() {
507        let (sheet, labels) = sheet_with_multi_relationship_branch();
508        let data = to_graph_data(&sheet, &labels);
509
510        let cond_id = data
511            .nodes
512            .iter()
513            .find(|n| n.kind == NodeKind::Conditional)
514            .map(|n| n.id.clone())
515            .unwrap();
516        let branch_id = data
517            .nodes
518            .iter()
519            .find(|n| n.kind == NodeKind::Branch)
520            .map(|n| n.id.clone())
521            .expect("expected a Branch node");
522
523        assert!(
524            data.links
525                .iter()
526                .any(|l| matches!(l.kind, LinkKind::Control)
527                    && l.source == cond_id
528                    && l.target == branch_id
529                    && l.branch_index == Some(0)
530                    && l.branch_active == Some(true)),
531            "expected a Control link from the conditional to the branch node"
532        );
533
534        let rel_ids: Vec<_> = data
535            .nodes
536            .iter()
537            .filter(|n| n.kind == NodeKind::Relationship)
538            .map(|n| n.id.clone())
539            .collect();
540        assert_eq!(rel_ids.len(), 2);
541        for rel_id in rel_ids {
542            assert!(
543                data.links
544                    .iter()
545                    .any(|l| matches!(l.kind, LinkKind::Control)
546                        && l.source == branch_id
547                        && l.target == rel_id
548                        && l.branch_index == Some(0)
549                        && l.branch_active == Some(true)),
550                "expected a Control link from the branch node to relationship {rel_id}"
551            );
552        }
553    }
554
555    #[test]
556    fn to_graph_data_routes_multi_relationship_default_through_branch_node() {
557        let (sheet, labels) = sheet_with_multi_relationship_default();
558        let data = to_graph_data(&sheet, &labels);
559
560        let cond_id = data
561            .nodes
562            .iter()
563            .find(|n| n.kind == NodeKind::Conditional)
564            .map(|n| n.id.clone())
565            .unwrap();
566        let branch_id = data
567            .nodes
568            .iter()
569            .find(|n| n.kind == NodeKind::Branch)
570            .map(|n| n.id.clone())
571            .expect("expected a Branch node for the default relationships");
572
573        assert!(
574            data.links
575                .iter()
576                .any(|l| matches!(l.kind, LinkKind::Control)
577                    && l.source == cond_id
578                    && l.target == branch_id
579                    && l.branch_index.is_none()
580                    && l.branch_active == Some(true)),
581            "expected a Control link from the conditional to the default branch node"
582        );
583
584        let rel_ids: Vec<_> = data
585            .nodes
586            .iter()
587            .filter(|n| n.kind == NodeKind::Relationship)
588            .map(|n| n.id.clone())
589            .collect();
590        assert_eq!(rel_ids.len(), 2);
591        for rel_id in rel_ids {
592            assert!(
593                data.links
594                    .iter()
595                    .any(|l| matches!(l.kind, LinkKind::Control)
596                        && l.source == branch_id
597                        && l.target == rel_id
598                        && l.branch_index.is_none()
599                        && l.branch_active == Some(true)),
600                "expected a Control link from the default branch node to relationship {rel_id}"
601            );
602        }
603    }
604
605    #[test]
606    fn to_graph_data_produces_correct_node_counts() {
607        let (sheet, labels) = demo_sheet();
608        let data = to_graph_data(&sheet, &labels);
609        assert_eq!(
610            data.nodes
611                .iter()
612                .filter(|n| n.kind == NodeKind::Cell)
613                .count(),
614            3
615        );
616        assert_eq!(
617            data.nodes
618                .iter()
619                .filter(|n| n.kind == NodeKind::Relationship)
620                .count(),
621            1
622        );
623    }
624
625    #[test]
626    fn to_graph_data_produces_correct_link_count() {
627        let (sheet, labels) = demo_sheet();
628        let data = to_graph_data(&sheet, &labels);
629        assert_eq!(data.links.len(), 3);
630    }
631
632    #[test]
633    fn to_graph_data_cell_nodes_have_labels() {
634        let (sheet, labels) = demo_sheet();
635        let data = to_graph_data(&sheet, &labels);
636        let cell_labels: Vec<_> = data
637            .nodes
638            .iter()
639            .filter(|n| n.kind == NodeKind::Cell)
640            .map(|n| n.label.as_str())
641            .collect();
642        assert!(cell_labels.contains(&"a"));
643        assert!(cell_labels.contains(&"b"));
644        assert!(cell_labels.contains(&"c"));
645    }
646
647    #[test]
648    fn to_graph_data_relationship_nodes_have_empty_labels() {
649        let (sheet, labels) = demo_sheet();
650        let data = to_graph_data(&sheet, &labels);
651        for node in data
652            .nodes
653            .iter()
654            .filter(|n| n.kind == NodeKind::Relationship)
655        {
656            assert!(node.label.is_empty());
657        }
658    }
659
660    #[test]
661    fn to_graph_data_changed_contains_changed_cell_ids() {
662        let (mut sheet, labels) = demo_sheet();
663        let a_id = sheet
664            .cells()
665            .find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("a"))
666            .unwrap();
667        let b_id = sheet
668            .cells()
669            .find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("b"))
670            .unwrap();
671        sheet.write(a_id, 2.0_f64).unwrap();
672        sheet.write(b_id, 3.0_f64).unwrap();
673        sheet.propagate().unwrap();
674
675        let data = to_graph_data(&sheet, &labels);
676        assert!(!data.changed.is_empty());
677    }
678
679    #[test]
680    fn to_graph_data_constraint_links_undirected_before_propagate() {
681        let (sheet, labels) = demo_sheet_with_plan();
682        let data = to_graph_data(&sheet, &labels);
683        assert!(
684            data.links
685                .iter()
686                .filter(|l| matches!(l.kind, LinkKind::Constraint))
687                .all(|l| !l.directed),
688            "no relationship has a cached plan yet, so every constraint link must be undirected"
689        );
690    }
691
692    #[test]
693    fn to_graph_data_constraint_links_directed_after_propagate() {
694        let (mut sheet, labels) = demo_sheet_with_plan();
695        sheet.propagate().unwrap();
696        let data = to_graph_data(&sheet, &labels);
697        assert!(
698            data.links
699                .iter()
700                .filter(|l| matches!(l.kind, LinkKind::Constraint))
701                .any(|l| l.directed),
702            "the relationship's cached plan should make its constraint links directed"
703        );
704    }
705
706    #[test]
707    fn to_graph_data_match_cell_link_is_directed_even_when_conditional_branch_is_inactive() {
708        // Reproduces adam-lang-book's constrain.adm2: a sheet whose only relationship
709        // lives inside a conditional, currently not matching any branch. No relationship
710        // anywhere in the sheet has a cached plan, but the match cell -> conditional link
711        // must still render directed -- evaluating the conditional's match expression
712        // doesn't depend on any relationship ever being planned.
713        let (mut sheet, labels) = sheet_with_conditional();
714        sheet.propagate().unwrap();
715        let data = to_graph_data(&sheet, &labels);
716
717        let cond_id = data
718            .nodes
719            .iter()
720            .find(|n| n.kind == NodeKind::Conditional)
721            .map(|n| n.id.clone())
722            .unwrap();
723        let match_link = data
724            .links
725            .iter()
726            .find(|l| matches!(l.kind, LinkKind::Constraint) && l.target == cond_id)
727            .expect("expected a Constraint link targeting the conditional node");
728        assert!(
729            match_link.directed,
730            "the match cell -> conditional link must be directed regardless of whether \
731             the conditional's own relationship currently has a plan"
732        );
733    }
734
735    #[test]
736    fn to_graph_data_undirected_relationship_link_is_independent_of_other_relationships() {
737        // R1 (a -> b) is unconditional and always planned; R2 (x <-> y, two methods) lives
738        // in a conditional that currently doesn't match any branch, so R2 has no plan. R1
739        // having a plan must not make R2's fallback adjacency links directed too -- each
740        // relationship's `directed` flag is its own fact, not a sheet-wide one.
741        let mut sheet = Sheet::new();
742        let mut labels = Labels::new();
743        let a = sheet.add_cell(2.0_f64);
744        labels.add_cell::<f64>(a, "a");
745        let b = sheet.add_cell(0.0_f64);
746        labels.add_cell::<f64>(b, "b");
747        sheet
748            .add_relationship(vec![Method::from_fn_1_1(a, b, |v: &f64| Ok(*v))])
749            .unwrap();
750
751        let x = sheet.add_cell(1.0_f64);
752        labels.add_cell::<f64>(x, "x");
753        let y = sheet.add_cell(2.0_f64);
754        labels.add_cell::<f64>(y, "y");
755        let p = sheet.add_cell(0_i32);
756        labels.add_cell::<i32>(p, "p");
757        let rel_xy = sheet
758            .add_relationship(vec![
759                Method::from_fn_1_1(x, y, |v: &f64| Ok(*v)),
760                Method::from_fn_1_1(y, x, |v: &f64| Ok(*v)),
761            ])
762            .unwrap();
763        sheet
764            .add_conditional(
765                MatchExpr::cell(p),
766                vec![(vec![1_i32], vec![rel_xy])],
767                vec![],
768            )
769            .unwrap();
770
771        sheet.propagate().unwrap();
772        let data = to_graph_data(&sheet, &labels);
773
774        let rel_xy_id = rel_node_id(rel_xy);
775        assert!(
776            data.links
777                .iter()
778                .filter(|l| matches!(l.kind, LinkKind::Constraint)
779                    && (l.source == rel_xy_id || l.target == rel_xy_id))
780                .all(|l| !l.directed),
781            "R2's links must stay undirected even though R1 elsewhere has a cached plan"
782        );
783    }
784
785    #[test]
786    fn to_graph_data_directed_input_links_target_relationship() {
787        let (mut sheet, labels) = demo_sheet_with_plan();
788        sheet.propagate().unwrap();
789        let data = to_graph_data(&sheet, &labels);
790
791        let rel_id = data
792            .nodes
793            .iter()
794            .find(|n| n.kind == NodeKind::Relationship)
795            .map(|n| n.id.clone())
796            .unwrap();
797
798        let to_rel: Vec<_> = data
799            .links
800            .iter()
801            .filter(|l| matches!(l.kind, LinkKind::Constraint) && l.target == rel_id)
802            .collect();
803        assert_eq!(to_rel.len(), 2);
804    }
805
806    #[test]
807    fn to_graph_data_directed_output_links_source_relationship() {
808        let (mut sheet, labels) = demo_sheet_with_plan();
809        sheet.propagate().unwrap();
810        let data = to_graph_data(&sheet, &labels);
811
812        let rel_id = data
813            .nodes
814            .iter()
815            .find(|n| n.kind == NodeKind::Relationship)
816            .map(|n| n.id.clone())
817            .unwrap();
818
819        let from_rel: Vec<_> = data
820            .links
821            .iter()
822            .filter(|l| matches!(l.kind, LinkKind::Constraint) && l.source == rel_id)
823            .collect();
824        assert_eq!(from_rel.len(), 1);
825    }
826
827    #[test]
828    fn to_graph_data_emits_conditional_node() {
829        let (sheet, labels) = sheet_with_conditional();
830        let data = to_graph_data(&sheet, &labels);
831        assert!(
832            data.nodes.iter().any(|n| n.kind == NodeKind::Conditional),
833            "expected a Conditional node"
834        );
835    }
836
837    #[test]
838    fn to_graph_data_emits_constraint_link_from_match_cell_to_conditional() {
839        let (sheet, labels) = sheet_with_conditional();
840        let data = to_graph_data(&sheet, &labels);
841        let cond_id = data
842            .nodes
843            .iter()
844            .find(|n| n.kind == NodeKind::Conditional)
845            .map(|n| n.id.clone())
846            .unwrap();
847        assert!(
848            data.links
849                .iter()
850                .any(|l| matches!(l.kind, LinkKind::Constraint) && l.target == cond_id),
851            "expected a Constraint link targeting the conditional node"
852        );
853    }
854
855    #[test]
856    fn to_graph_data_emits_control_link_for_branch_relationship() {
857        let (sheet, labels) = sheet_with_conditional();
858        let data = to_graph_data(&sheet, &labels);
859        assert!(
860            data.links
861                .iter()
862                .any(|l| matches!(l.kind, LinkKind::Control)),
863            "expected at least one Control link"
864        );
865    }
866
867    #[test]
868    fn to_graph_data_active_branch_control_link_is_active() {
869        let (sheet, labels) = sheet_with_conditional();
870        let data = to_graph_data(&sheet, &labels);
871        let active_control = data
872            .links
873            .iter()
874            .find(|l| matches!(l.kind, LinkKind::Control) && l.branch_index == Some(0));
875        assert!(
876            active_control.is_some(),
877            "expected a Control link for branch 0"
878        );
879        assert_eq!(active_control.unwrap().branch_active, Some(true));
880    }
881
882    #[test]
883    fn to_graph_data_no_groups_field() {
884        let (sheet, labels) = sheet_with_conditional();
885        let data = to_graph_data(&sheet, &labels);
886        let json = serde_json::to_string(&data).unwrap();
887        assert!(
888            !json.contains("\"groups\""),
889            "GraphData must not contain groups"
890        );
891    }
892
893    #[test]
894    fn to_graph_data_forced_field_contains_forced_cell() {
895        let (mut sheet, labels) = sheet_with_forced_conditional();
896        sheet.propagate().unwrap();
897
898        let b_id = sheet
899            .cells()
900            .find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("b"))
901            .unwrap();
902
903        let data = to_graph_data(&sheet, &labels);
904        assert!(data.forced.contains(&cell_node_id(b_id)));
905    }
906
907    #[test]
908    fn to_graph_data_forced_field_excludes_cell_when_branch_inactive() {
909        let (mut sheet, labels) = sheet_with_forced_conditional();
910        let p_id = sheet
911            .cells()
912            .find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("p"))
913            .unwrap();
914        sheet.write(p_id, 1_i32).unwrap();
915        sheet.propagate().unwrap();
916
917        let b_id = sheet
918            .cells()
919            .find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("b"))
920            .unwrap();
921
922        let data = to_graph_data(&sheet, &labels);
923        assert!(!data.forced.contains(&cell_node_id(b_id)));
924    }
925
926    #[test]
927    fn to_graph_data_forced_relationships_field_contains_forced_relationship() {
928        let (mut sheet, labels) = sheet_with_forced_conditional();
929        let rel_id = sheet.relationships().next().unwrap();
930        sheet.propagate().unwrap();
931
932        let data = to_graph_data(&sheet, &labels);
933        assert!(data.forced_relationships.contains(&rel_node_id(rel_id)));
934    }
935
936    #[test]
937    fn to_graph_data_forced_relationships_field_excludes_relationship_when_branch_inactive() {
938        let (mut sheet, labels) = sheet_with_forced_conditional();
939        let rel_id = sheet.relationships().next().unwrap();
940        let p_id = sheet
941            .cells()
942            .find(|&id| labels.cells.get(&id).map(|m| m.label.as_str()) == Some("p"))
943            .unwrap();
944        sheet.write(p_id, 1_i32).unwrap();
945        sheet.propagate().unwrap();
946
947        let data = to_graph_data(&sheet, &labels);
948        assert!(!data.forced_relationships.contains(&rel_node_id(rel_id)));
949    }
950}