From c3d310f1124711b184add28a70ac4273c2570ecb Mon Sep 17 00:00:00 2001 From: Bob Van Hove <1587584+bobvh@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:49:44 -0400 Subject: [PATCH 1/5] Implement cycle removal by replacing backedges with 3 new edges that effect a reversal. The additional nodes are called 'loopback nodes' and they serve to make the layout engine exit the original source node on the right and enter the original target node on the left. --- gen-tui/src/cycle_removal.rs | 398 ++++++++++ .../src/edge_router/layout_graph_process.rs | 13 +- gen-tui/src/graph_controller.rs | 86 ++- gen-tui/src/graph_widget.rs | 2 +- gen-tui/src/layout.rs | 60 +- gen-tui/src/lib.rs | 1 + gen-tui/src/partition.rs | 1 + gen-tui/src/partition_controller.rs | 69 +- gen-tui/src/partition_table.rs | 725 ++++++++++++------ gen-tui/src/plotter.rs | 215 +++++- gen-tui/src/testing/layout_tests.rs | 323 +++++++- ...out_tests__arrow_fallback_short_cycle.snap | 19 + ...ow_forced_marker_short_edge_large_gap.snap | 24 + ...sting__layout_tests__cycle_with_chord.snap | 24 + ...ng__layout_tests__pinned_source_cycle.snap | 29 + ...ests__pinned_source_cycle_partitioned.snap | 29 + ...tests__pinned_source_cycle_with_chord.snap | 29 + ...tui__testing__layout_tests__self_loop.snap | 24 + ...__testing__layout_tests__simple_cycle.snap | 24 + gen-tui/src/viewport_graph.rs | 52 ++ src/views/annotation_track.rs | 2 +- src/views/block_group.rs | 4 +- src/views/block_group_inline.rs | 2 +- 23 files changed, 1795 insertions(+), 360 deletions(-) create mode 100644 gen-tui/src/cycle_removal.rs create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap diff --git a/gen-tui/src/cycle_removal.rs b/gen-tui/src/cycle_removal.rs new file mode 100644 index 00000000..31d81850 --- /dev/null +++ b/gen-tui/src/cycle_removal.rs @@ -0,0 +1,398 @@ +use std::{ + collections::{HashSet, VecDeque}, + hash::Hash, +}; + +use log::{info, trace}; +use petgraph::{ + algo::toposort, + visit::{ + EdgeRef, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, IntoNodeIdentifiers, + NodeCount, NodeIndexable, Visitable, + }, +}; + +/// Result of cycle removal: a linear ordering of nodes and the set of backward edges. +pub struct CycleRemovalResult { + /// Nodes in topological-like order (sources first, sinks last). + pub ordering: Vec, + /// Edges that point backward relative to the ordering. + /// For self-loops (u == v), both endpoints are the same. + pub backward_edges: HashSet<(NodeId, NodeId)>, +} + +/// Compute a linear ordering of nodes and identify backward edges. +/// +/// For acyclic graphs, uses petgraph's `toposort` (fast path). +/// For cyclic graphs, uses the Eades–Lin–Smyth heuristic to find a +/// feedback arc set with minimal backward edges. +/// +/// Optional `pin_source` / `pin_sink` force specific nodes to the +/// beginning / end of the ordering. +pub fn remove_cycles( + graph: &G, + pin_source: Option, + pin_sink: Option, +) -> CycleRemovalResult +where + G: GraphBase + NodeIndexable + NodeCount + Visitable, + for<'a> &'a G: IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, + G::NodeId: Copy + Eq + Hash + Ord, +{ + // Fast path: try toposort first (works for acyclic graphs) + if let Ok(sorted) = toposort(graph, None) { + // Collect self-loops (toposort succeeds even with self-loops in some petgraph versions, + // but we still need to detect them) + let mut backward_edges = HashSet::new(); + for e in graph.edge_references() { + if e.source() == e.target() { + backward_edges.insert((e.source(), e.target())); + } + } + if backward_edges.is_empty() { + trace!(target: "cycle_removal", "Graph is acyclic, using toposort ordering"); + return CycleRemovalResult { + ordering: sorted, + backward_edges, + }; + } + } + + info!( + target: "cycle_removal", + "Graph contains cycles, computing Eades ordering (pin_source={}, pin_sink={})", + pin_source.is_some(), + pin_sink.is_some() + ); + + let ordering = eades_ordering(graph, pin_source, pin_sink); + + // Build rank map: node -> position in ordering + let mut rank = vec![0usize; graph.node_bound()]; + for (i, &node) in ordering.iter().enumerate() { + rank[graph.to_index(node)] = i; + } + + // Identify backward edges + let mut backward_edges = HashSet::new(); + for e in graph.edge_references() { + let u = e.source(); + let v = e.target(); + if u == v || rank[graph.to_index(u)] > rank[graph.to_index(v)] { + backward_edges.insert((u, v)); + } + } + + info!( + target: "cycle_removal", + "Found {} backward edges", + backward_edges.len() + ); + + CycleRemovalResult { + ordering, + backward_edges, + } +} + +/// Eades–Lin–Smyth heuristic ordering. +/// +/// Iteratively removes sources (in-degree 0) to the front of the ordering, +/// sinks (out-degree 0) to the back, and breaks ties by picking the node +/// with maximum (out_degree - in_degree). +fn eades_ordering( + graph: &G, + pinned_source: Option, + pinned_sink: Option, +) -> Vec +where + G: GraphBase + NodeIndexable + NodeCount, + for<'a> &'a G: IntoNodeIdentifiers + + IntoEdgeReferences>, + G::NodeId: Copy + Eq + Hash + Ord, +{ + let bound = graph.node_bound(); + let mut active = vec![false; bound]; + let mut active_count = 0usize; + let mut outs: Vec> = vec![Vec::new(); bound]; + let mut ins: Vec> = vec![Vec::new(); bound]; + let mut in_deg = vec![0i32; bound]; + let mut out_deg = vec![0i32; bound]; + + // Map index back to NodeId + let mut index_to_node: Vec> = vec![None; bound]; + + for n in graph.node_identifiers() { + let ni = graph.to_index(n); + active[ni] = true; + active_count += 1; + index_to_node[ni] = Some(n); + } + + for e in graph.edge_references() { + let u = graph.to_index(e.source()); + let v = graph.to_index(e.target()); + // Skip self-loops for degree computation + if u == v { + continue; + } + outs[u].push(v); + ins[v].push(u); + out_deg[u] += 1; + in_deg[v] += 1; + } + + let mut sources: VecDeque = VecDeque::new(); + let mut sinks: VecDeque = VecDeque::new(); + let mut prefix: Vec = Vec::with_capacity(active_count); + let mut suffix: VecDeque = VecDeque::with_capacity(active_count); + + let is_active = |i: usize, active: &[bool]| i < active.len() && active[i]; + + // Helper: remove a node and update neighbors + let remove_node = |vi: usize, + active: &mut Vec, + active_count: &mut usize, + in_deg: &mut Vec, + out_deg: &mut Vec, + outs: &Vec>, + ins: &Vec>, + sources: &mut VecDeque, + sinks: &mut VecDeque| { + if !active[vi] { + return; + } + active[vi] = false; + *active_count -= 1; + + for &w in &outs[vi] { + if active[w] { + in_deg[w] -= 1; + if in_deg[w] == 0 { + sources.push_back(w); + } + } + } + for &u in &ins[vi] { + if active[u] { + out_deg[u] -= 1; + if out_deg[u] == 0 { + sinks.push_back(u); + } + } + } + }; + + // Pin source + if let Some(s) = pinned_source { + let si = graph.to_index(s); + if is_active(si, &active) { + prefix.push(si); + remove_node( + si, + &mut active, + &mut active_count, + &mut in_deg, + &mut out_deg, + &outs, + &ins, + &mut sources, + &mut sinks, + ); + } + } + + // Pin sink + if let Some(t) = pinned_sink { + let ti = graph.to_index(t); + if is_active(ti, &active) && Some(t) != pinned_source { + suffix.push_back(ti); + remove_node( + ti, + &mut active, + &mut active_count, + &mut in_deg, + &mut out_deg, + &outs, + &ins, + &mut sources, + &mut sinks, + ); + } + } + + // Seed sources and sinks + for i in 0..bound { + if active[i] { + if in_deg[i] == 0 { + sources.push_back(i); + } + if out_deg[i] == 0 { + sinks.push_back(i); + } + } + } + + // Main loop + while active_count > 0 { + let mut progressed = false; + + while let Some(v) = sources.pop_front() { + if is_active(v, &active) { + progressed = true; + prefix.push(v); + remove_node( + v, + &mut active, + &mut active_count, + &mut in_deg, + &mut out_deg, + &outs, + &ins, + &mut sources, + &mut sinks, + ); + } + } + + while let Some(v) = sinks.pop_front() { + if is_active(v, &active) { + progressed = true; + suffix.push_front(v); + remove_node( + v, + &mut active, + &mut active_count, + &mut in_deg, + &mut out_deg, + &outs, + &ins, + &mut sources, + &mut sinks, + ); + } + } + + if progressed { + continue; + } + + // Pick node with max (out - in), tie-break by lower index + let mut best: Option = None; + let mut best_score = i32::MIN; + for i in 0..bound { + if active[i] { + let score = out_deg[i] - in_deg[i]; + if score > best_score || (score == best_score && best.is_none_or(|b| i < b)) { + best_score = score; + best = Some(i); + } + } + } + + if let Some(b) = best { + prefix.push(b); + remove_node( + b, + &mut active, + &mut active_count, + &mut in_deg, + &mut out_deg, + &outs, + &ins, + &mut sources, + &mut sinks, + ); + } else { + break; + } + } + + // Construct final ordering + let mut order = Vec::with_capacity(prefix.len() + suffix.len()); + order.extend(prefix); + order.extend(suffix); + + order.into_iter().filter_map(|i| index_to_node[i]).collect() +} + +#[cfg(test)] +mod tests { + use petgraph::stable_graph::StableDiGraph; + + use super::*; + + #[test] + fn test_acyclic_graph_no_backward_edges() { + let mut g = StableDiGraph::<&str, ()>::new(); + let node_a = g.add_node("a"); + let node_b = g.add_node("b"); + let node_c = g.add_node("c"); + g.add_edge(node_a, node_b, ()); + g.add_edge(node_b, node_c, ()); + + let result = remove_cycles(&g, None, None); + assert!(result.backward_edges.is_empty()); + assert_eq!(result.ordering.len(), 3); + } + + #[test] + fn test_simple_cycle() { + let mut g = StableDiGraph::<&str, ()>::new(); + let node_a = g.add_node("a"); + let node_b = g.add_node("b"); + let node_c = g.add_node("c"); + g.add_edge(node_a, node_b, ()); + g.add_edge(node_b, node_c, ()); + g.add_edge(node_c, node_a, ()); + + let result = remove_cycles(&g, None, None); + assert_eq!(result.backward_edges.len(), 1); + assert_eq!(result.ordering.len(), 3); + } + + #[test] + fn test_self_loop() { + let mut g = StableDiGraph::<&str, ()>::new(); + let node_a = g.add_node("a"); + g.add_edge(node_a, node_a, ()); + + let result = remove_cycles(&g, None, None); + assert_eq!(result.backward_edges.len(), 1); + assert!(result.backward_edges.contains(&(node_a, node_a))); + } + + #[test] + fn test_pinned_source_determines_backward_edge() { + let mut g = StableDiGraph::<&str, ()>::new(); + let node_a = g.add_node("a"); + let node_b = g.add_node("b"); + let node_c = g.add_node("c"); + g.add_edge(node_a, node_b, ()); + g.add_edge(node_b, node_c, ()); + g.add_edge(node_c, node_a, ()); + + // Pin node_a as source => ordering [node_a, node_b, node_c], back-edge is node_c->node_a + let result = remove_cycles(&g, Some(node_a), None); + assert_eq!(result.backward_edges.len(), 1); + assert!(result.backward_edges.contains(&(node_c, node_a))); + } + + #[test] + fn test_pinned_source_and_sink() { + let mut g = StableDiGraph::<&str, ()>::new(); + let node_a = g.add_node("a"); + let node_b = g.add_node("b"); + let node_c = g.add_node("c"); + g.add_edge(node_a, node_b, ()); + g.add_edge(node_b, node_c, ()); + g.add_edge(node_c, node_a, ()); + + // Pin node_a as source, node_c as sink => ordering [node_a, node_b, node_c], back-edge node_c->node_a + let result = remove_cycles(&g, Some(node_a), Some(node_c)); + assert_eq!(result.backward_edges.len(), 1); + assert!(result.backward_edges.contains(&(node_c, node_a))); + } +} diff --git a/gen-tui/src/edge_router/layout_graph_process.rs b/gen-tui/src/edge_router/layout_graph_process.rs index 87ba094a..227fd59a 100644 --- a/gen-tui/src/edge_router/layout_graph_process.rs +++ b/gen-tui/src/edge_router/layout_graph_process.rs @@ -50,7 +50,6 @@ pub fn assign_ports( /// Simplifies a graph by identifying and contracting segments with collinear edges. /// Preserves LayoutEdge bundle information from the edges being contracted. -/// Asserts that all edges in a straight segment have identical bundles. pub fn simplify_graph( graph: &mut StableGraph, ) -> Result<(), LayoutError> { @@ -145,14 +144,14 @@ pub fn simplify_graph( neighbors_of_current[1] }; - // Verify that bundle is identical along the segment + // If bundles diverge, two logical edges share routing nodes here; + // treat this node as a segment boundary. You see this when plotting + // a cycle. if let Some(edge_id) = graph.find_edge(current_id, next_node_id) { let next_bundle = &graph.edge_weight(edge_id).unwrap().bundle; - assert_eq!( - &segment_bundle, next_bundle, - "Bundle mismatch in straight segment: expected {:?}, got {:?}", - segment_bundle, next_bundle - ); + if &segment_bundle != next_bundle { + break Some(current_id); + } } previous_id = current_id; diff --git a/gen-tui/src/graph_controller.rs b/gen-tui/src/graph_controller.rs index 569d2c71..95d50c3b 100644 --- a/gen-tui/src/graph_controller.rs +++ b/gen-tui/src/graph_controller.rs @@ -6,8 +6,8 @@ use log::trace; use petgraph::{ graph::NodeIndex, visit::{ - EdgeIndexable, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, IntoNodeIdentifiers, - NodeCount, NodeIndexable, Visitable, + EdgeIndexable, EdgeRef, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, + IntoNodeIdentifiers, NodeCount, NodeIndexable, Visitable, }, }; use ratatui::style::Color; @@ -40,12 +40,9 @@ pub struct GraphConfig { /// graph loading and layout computation, then used by widgets during rendering. pub struct GraphController where - G: GraphBase + Clone, + G: GraphBase, S: NodeSizer, { - /// The original graph used for node lookups and rendering - pub graph: G, - /// Viewport state managing camera, animations, and viewport bounds pub viewport_state: ViewportState, @@ -85,20 +82,9 @@ pub enum HighlightKind { impl GraphController where - G: GraphBase - + Clone - + EdgeIndexable - + NodeIndexable - + NodeCount - + Visitable - + IntoNodeIdentifiers - + IntoEdgeReferences - + IntoNeighborsDirected, + G: GraphBase + NodeIndexable, G::NodeId: Copy + Eq + Hash + Ord, - G::EdgeId: Clone, - for<'b> &'b G: IntoNodeIdentifiers + IntoEdgeReferences + IntoNeighborsDirected, - for<'b> &'b G::NodeId: Hash + Ord, - for<'b> &'b G::EdgeId: Clone, + for<'b> &'b G: IntoNeighborsDirected, S: NodeSizer, { /// Get the default theme (Catppuccin Mocha colors) @@ -119,7 +105,13 @@ where /// - node_sizer: Function object to determine node sizes at different levels of detail pub fn new(graph: G, node_sizer: S) -> Self where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, ::NodeId: std::fmt::Debug, + for<'c> &'c G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, { Self::new_with_config(graph, node_sizer, GraphConfig::default()) } @@ -132,7 +124,13 @@ where /// - config: Configuration for partitioning, memory management, and layout pub fn new_with_config(graph: G, node_sizer: S, config: GraphConfig) -> Self where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, ::NodeId: std::fmt::Debug, + for<'c> &'c G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, { let partition_controller = PartitionController::new_with_config( graph, @@ -142,7 +140,6 @@ where ); let mut controller = Self { - graph, viewport_state: ViewportState::new(), cursor: Cursor::default(), detail_level: VisualDetail::Truncated, // Default detail level @@ -161,6 +158,11 @@ where controller } + /// Get a reference to the underlying graph + pub fn graph(&self) -> &G { + &self.partition_controller.graph + } + pub fn get_layout(&self, partition_idx: usize) -> Option<&PartitionLayout> { let detail_level = self.get_detail_level(); self.partition_controller @@ -297,14 +299,25 @@ where /// Set a node highlight pub fn set_node_highlight(&mut self, node_id: G::NodeId, style: PathStyle) { - Self::apply_node_highlight(&mut self.viewport_graph, &self.graph, node_id, style); + Self::apply_node_highlight( + &mut self.viewport_graph, + &self.partition_controller.graph, + node_id, + style, + ); let kind = HighlightKind::Node(node_id); self.highlights.push((kind, style)); } /// Set an edge highlight pub fn set_edge_highlight(&mut self, edge: (G::NodeId, G::NodeId), style: PathStyle) { - Self::apply_edge_highlight(&mut self.viewport_graph, &self.graph, edge.0, edge.1, style); + Self::apply_edge_highlight( + &mut self.viewport_graph, + &self.partition_controller.graph, + edge.0, + edge.1, + style, + ); let kind = HighlightKind::Edge(edge.0, edge.1); self.highlights.push((kind, style)); } @@ -315,7 +328,12 @@ where /// - style: PathStyle for highlighting the path /// - path_nodes: Sequence of nodes that form the path pub fn set_path_highlight(&mut self, style: PathStyle, path_nodes: Vec) { - Self::apply_path_highlight(&mut self.viewport_graph, &self.graph, &path_nodes, style); + Self::apply_path_highlight( + &mut self.viewport_graph, + &self.partition_controller.graph, + &path_nodes, + style, + ); let kind = HighlightKind::Path(path_nodes); self.highlights.push((kind, style)); } @@ -663,7 +681,16 @@ where /// /// This ensures the cursor stays at its viewport position while the world coordinates /// align correctly, preventing coordinate drift during rebuilds. - pub fn rebuild_viewport_graph(&mut self) -> Result<(), String> { + pub fn rebuild_viewport_graph(&mut self) -> Result<(), String> + where + G: EdgeIndexable + + NodeCount + + Visitable + + IntoNodeIdentifiers + + IntoEdgeReferences + + IntoNeighborsDirected, + G::EdgeId: Clone, + { let detail_level = self.detail_level; // Capture viewport bounds at start to ensure consistency throughout rebuild @@ -692,7 +719,10 @@ where // Step 2: Find which partition the cursor's node belongs to let cursor_partition = if let Some(node_idx) = self.cursor.node_idx() { - let node_id = ::from_index(&self.graph, node_idx.index()); + let node_id = ::from_index( + &self.partition_controller.graph, + node_idx.index(), + ); self.partition_controller .partition_table .node_map @@ -808,7 +838,7 @@ where HighlightKind::Node(node_id) => { Self::apply_node_highlight( &mut self.viewport_graph, - &self.graph, + &self.partition_controller.graph, *node_id, *style, ); @@ -816,7 +846,7 @@ where HighlightKind::Edge(src, tgt) => { Self::apply_edge_highlight( &mut self.viewport_graph, - &self.graph, + &self.partition_controller.graph, *src, *tgt, *style, @@ -825,7 +855,7 @@ where HighlightKind::Path(nodes) => { Self::apply_path_highlight( &mut self.viewport_graph, - &self.graph, + &self.partition_controller.graph, nodes, *style, ); diff --git a/gen-tui/src/graph_widget.rs b/gen-tui/src/graph_widget.rs index b5ba5438..5d3c76b7 100644 --- a/gen-tui/src/graph_widget.rs +++ b/gen-tui/src/graph_widget.rs @@ -261,7 +261,7 @@ where viewport_graph, &mut world_buffer, &mut self.renderer, - &controller.graph, + controller.graph(), detail_level, node_highlights, edge_highlights, diff --git a/gen-tui/src/layout.rs b/gen-tui/src/layout.rs index 02f3c9ee..25c91e3f 100644 --- a/gen-tui/src/layout.rs +++ b/gen-tui/src/layout.rs @@ -587,7 +587,7 @@ impl<'a> LayoutEngine<'a> { let d: i32 = i32::try_from(domain_idx.index()).unwrap_or(i32::MAX); i32::MAX.saturating_sub(d) } - PartitionNode::Stitch(_) => 0, + PartitionNode::Stitch(_) | PartitionNode::Loopback => 0, }; vertex.set_sort_bias(sort_bias); let new_vertex_idx = vertex_graph.add_node(vertex); @@ -669,12 +669,9 @@ impl<'a> LayoutEngine<'a> { let partition_node = &self.partition_graph[node_idx]; // Calculate size - let size = if let PartitionNode::Data(_domain_idx) = partition_node { - // For Data nodes, use the node sizer - node_sizer.get_node_size(&node_idx, detail_level) - } else { - // For Stitch nodes, use dummy size - node_sizer.get_dummy_size() + let size = match partition_node { + PartitionNode::Data(_) => node_sizer.get_node_size(&node_idx, detail_level), + PartitionNode::Stitch(_) | PartitionNode::Loopback => node_sizer.get_dummy_size(), }; let (width, height) = ( @@ -687,6 +684,7 @@ impl<'a> LayoutEngine<'a> { let role = match partition_node { PartitionNode::Data(d) => NodeRole::Data(*d), PartitionNode::Stitch(s) => NodeRole::Stitch(*s), + PartitionNode::Loopback => NodeRole::Routing, }; let layout_node = LayoutNode::new(role, pos, (width, height), Some(0)); @@ -804,6 +802,7 @@ impl<'a> LayoutEngine<'a> { match &self.partition_graph[pidx] { PartitionNode::Data(domain_idx) => NodeRole::Data(*domain_idx), PartitionNode::Stitch(side) => NodeRole::Stitch(*side), + PartitionNode::Loopback => NodeRole::Routing, } } else { NodeRole::Routing @@ -877,19 +876,6 @@ fn count_connected_components(graph: &StableDiGraph) -> usize { components } -fn mean_y_for_x( - layout_graph: &StableGraph, - x: i64, -) -> i64 { - let layer_ys: Vec = layout_graph - .node_weights() - .filter(|node| node.pos.x == x) - .map(|node| node.pos.y) - .collect::>(); - - (layer_ys.iter().sum::() as f64 / layer_ys.len() as f64).round() as i64 -} - /// Take the contents of a layout graph and translate the node coordinates /// so that the centers of all data nodes in the first layer are aligned to /// each other and the origin (they all share x=0). In the Y-direction the @@ -899,6 +885,37 @@ fn mean_y_for_x( fn align_partition_to_origin( layout_graph: &mut StableGraph, ) -> (i64, i64) { + let (min_layer, max_layer) = layout_graph + .node_weights() + .filter_map(|node| node.layer) + .minmax() + .into_option() + .unwrap_or((0, 0)); + + let left_ys: Vec = layout_graph + .node_weights() + .filter(|node| node.layer == Some(min_layer)) + .map(|node| node.pos.y) + .collect(); + + let right_ys: Vec = layout_graph + .node_weights() + .filter(|node| node.layer == Some(max_layer)) + .map(|node| node.pos.y) + .collect(); + + let mean_y_left = if !left_ys.is_empty() { + (left_ys.iter().sum::() as f64 / left_ys.len() as f64).round() as i64 + } else { + 0 + }; + + let mean_y_right = if !right_ys.is_empty() { + (right_ys.iter().sum::() as f64 / right_ys.len() as f64).round() as i64 + } else { + mean_y_left + }; + let (min_x, max_x) = layout_graph .node_weights() .filter(|node| !matches!(node.role, NodeRole::Stitch(_))) @@ -907,9 +924,6 @@ fn align_partition_to_origin( .into_option() .unwrap_or((0, 0)); - let mean_y_left = mean_y_for_x(layout_graph, min_x); - let mean_y_right = mean_y_for_x(layout_graph, max_x); - // Apply normalization offsets for node in layout_graph.node_weights_mut() { node.pos.x -= min_x; diff --git a/gen-tui/src/lib.rs b/gen-tui/src/lib.rs index d6b37849..f9efd1ce 100644 --- a/gen-tui/src/lib.rs +++ b/gen-tui/src/lib.rs @@ -4,6 +4,7 @@ pub mod animation; pub mod color_utils; pub mod cursor; +pub mod cycle_removal; pub mod distribute_nodes; pub mod dot_export; pub mod edge_router; // Rust port of edge routing diff --git a/gen-tui/src/partition.rs b/gen-tui/src/partition.rs index a66a3e5f..1501d1f6 100644 --- a/gen-tui/src/partition.rs +++ b/gen-tui/src/partition.rs @@ -14,6 +14,7 @@ use crate::layout::{PartitionLayout, VisualDetail}; pub enum PartitionNode { Data(NodeIndex), Stitch(StitchSide), + Loopback, } #[derive(Clone, Debug, Serialize, Deserialize, Copy, PartialEq, Eq)] diff --git a/gen-tui/src/partition_controller.rs b/gen-tui/src/partition_controller.rs index 9b48e74d..c6571784 100644 --- a/gen-tui/src/partition_controller.rs +++ b/gen-tui/src/partition_controller.rs @@ -6,8 +6,8 @@ use std::{ use gen_sugiyama::VERTEX_SPACING_DEFAULT; use petgraph::visit::{ - EdgeIndexable, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, IntoNodeIdentifiers, - NodeCount, NodeIndexable, Visitable, + EdgeIndexable, EdgeRef, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, + IntoNodeIdentifiers, NodeCount, NodeIndexable, Visitable, }; use crate::{ @@ -45,17 +45,19 @@ impl Default for ControllerConfig { /// - Handles scale (level of detail) changes and layout computation pub struct PartitionController where - G: GraphBase + Clone, + G: GraphBase, S: NodeSizer, { pub partition_table: PartitionTable, pub current_detail_level: VisualDetail, pub node_sizer: S, + // The original graph, used for loading partition layouts on demand + pub graph: G, + // Dynamic partition layout management loaded_partition_indices: HashSet, max_loaded_partitions: usize, - original_graph: G, // Vertex spacing for layout computation vertex_spacing: f64, @@ -63,20 +65,9 @@ where impl PartitionController where - G: GraphBase - + Clone - + EdgeIndexable - + NodeIndexable - + NodeCount - + Visitable - + IntoNodeIdentifiers - + IntoEdgeReferences - + IntoNeighborsDirected, + G: GraphBase + NodeIndexable, G::NodeId: Copy + Eq + Hash + Ord, - G::EdgeId: Clone, - for<'b> &'b G: IntoNodeIdentifiers + IntoEdgeReferences + IntoNeighborsDirected, - for<'b> &'b G::NodeId: Hash + Ord, - for<'b> &'b G::EdgeId: Clone, + for<'b> &'b G: IntoNeighborsDirected, S: NodeSizer, { // TODO: a builder pattern may be nice here to support sensible defaults and multiple graph types @@ -85,7 +76,13 @@ where /// - node_sizer: Function object to determine node sizes at different scales pub fn new(graph: G, node_sizer: S) -> Self where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, ::NodeId: std::fmt::Debug, + for<'c> &'c G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, { Self::new_with_config( graph, @@ -107,19 +104,22 @@ where controller_config: ControllerConfig, ) -> Self where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, ::NodeId: std::fmt::Debug, + for<'c> &'c G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, { + let partition_table = PartitionTable::new_with_full_config(&graph, &partition_config); Self { - partition_table: PartitionTable::new_with_config( - graph, - partition_config.layer_count, - partition_config.node_count, - ), + partition_table, current_detail_level: VisualDetail::Minimal, node_sizer, + graph, loaded_partition_indices: HashSet::new(), max_loaded_partitions: controller_config.max_loaded_partitions, - original_graph: graph, //scale_change_needs_viewport_reset: false, vertex_spacing: VERTEX_SPACING_DEFAULT, } @@ -147,7 +147,7 @@ where self.partition_table.load_partition( partition_idx, &self.node_sizer, - &self.original_graph, + &self.graph, self.vertex_spacing, )?; @@ -358,20 +358,12 @@ where let height = heights[partition_idx]; let y_offset = if partition_idx == 0 { - self.partition_table - .get_scale_data(self.current_detail_level) - .rise - .prefix_sum(0, 0) + 0 } else { self.partition_table .get_scale_data(self.current_detail_level) .rise - .prefix_sum(partition_idx, 0) - - self - .partition_table - .get_scale_data(self.current_detail_level) - .rise - .prefix_sum(partition_idx - 1, 0) + .prefix_sum(partition_idx - 1, 0) }; min_y = min(min_y, y_offset); @@ -640,7 +632,7 @@ mod tests { }; // Create partition controller - let mut controller = PartitionController::new(&domain_graph, node_sizer); + let mut controller = PartitionController::new(domain_graph, node_sizer); // Test basic coverage - small rectangle within anchor partition let small_rect = BigRect::from_coords(-5, -10, 5, 10); @@ -683,7 +675,7 @@ mod tests { }; // Create partition controller - let mut controller = PartitionController::new(&domain_graph, node_sizer); + let mut controller = PartitionController::new(domain_graph, node_sizer); // Test coverage that should require loading multiple partitions // Use a large rectangle that extends beyond the anchor partition @@ -725,7 +717,7 @@ mod tests { height: 8, }; - let mut controller = PartitionController::new(&domain_graph, node_sizer); + let mut controller = PartitionController::new(domain_graph, node_sizer); // Set anchor to a partition that's not at index 0 to enable left expansion if controller.partition_table.partitions.len() > 2 { @@ -772,13 +764,14 @@ mod tests { let partition_config = PartitionConfig { layer_count: 1, // Small layer count node_count: 3, // Small node count to force multiple partitions + ..Default::default() }; let config = ControllerConfig { max_loaded_partitions: usize::MAX, }; let mut controller = PartitionController::new_with_config( - &domain_graph, + domain_graph, node_sizer, partition_config, config, diff --git a/gen-tui/src/partition_table.rs b/gen-tui/src/partition_table.rs index 306925a5..3fe47e15 100644 --- a/gen-tui/src/partition_table.rs +++ b/gen-tui/src/partition_table.rs @@ -5,7 +5,6 @@ use ftree::FenwickTree; use gen_sugiyama::VERTEX_SPACING_DEFAULT; use petgraph::{ Direction, Undirected, - algo::toposort, graph::{EdgeIndex, NodeIndex}, stable_graph::{StableDiGraph, StableGraph}, visit::{ @@ -15,6 +14,7 @@ use petgraph::{ }; use crate::{ + cycle_removal::{self, CycleRemovalResult}, find_articulation_points, geometry::{BigRect, LocalPos, PartitionIndex, WorldPos}, layout::{LayoutEdge, LayoutEngine, LayoutNode, NodeRole, PartitionLayout, VisualDetail}, @@ -30,6 +30,10 @@ pub struct PartitionConfig { /// Number of nodes after which a partition is forcibly closed. /// The layer is still finished so the final count could be higher than this. pub node_count: usize, + /// Optional node to pin as the first node in the ordering (for cycle removal). + pub pin_source: Option>, + /// Optional node to pin as the last node in the ordering (for cycle removal). + pub pin_sink: Option>, } impl Default for PartitionConfig { @@ -37,6 +41,8 @@ impl Default for PartitionConfig { Self { layer_count: 100, node_count: usize::MAX, + pin_source: None, + pin_sink: None, } } } @@ -61,6 +67,9 @@ where (PartitionIndex, PartitionIndex), Vec<(NodeIndex, NodeIndex, EdgeIndex)>, >, + /// Domain edges that were reversed during cycle removal (source, target as NodeIndex). + /// These are the "backward" edges that form loopbacks in the visual layout. + pub backward_edges: std::collections::HashSet<(NodeIndex, NodeIndex)>, metrics: Vec, anchor_partition_idx: PartitionIndex, } @@ -171,7 +180,9 @@ where self.original_sizer .get_node_size(&original_node_id, detail_level) } - PartitionNode::Stitch(_) => self.original_sizer.get_dummy_size(), + PartitionNode::Stitch(_) | PartitionNode::Loopback => { + self.original_sizer.get_dummy_size() + } } } @@ -180,6 +191,170 @@ where } } +/// Intermediate representation for ranked entries that may be domain nodes or loopback waypoints. +#[derive(Debug, Clone)] +enum RankedNode { + Domain(NodeId), + LoopbackLeft { back_edge: (NodeId, NodeId) }, + LoopbackRight { back_edge: (NodeId, NodeId) }, +} + +/// Compute ranks from a pre-computed ordering, skipping backward edges when +/// determining predecessor ranks. This replaces `compute_all_ranks` which +/// relied on `toposort` (and thus failed on cycles). +fn compute_ranks_from_ordering( + graph: &G, + ordering: &[G::NodeId], + excluded_edges: &std::collections::HashSet<(G::NodeId, G::NodeId)>, +) -> Vec<(G::NodeId, usize)> +where + G: GraphBase + NodeIndexable, + for<'a> &'a G: IntoNeighborsDirected, + G::NodeId: Copy + Eq + Hash + Ord, +{ + let mut node_ranks: HashMap = HashMap::new(); + + for &node in ordering { + let max_pred_rank = graph + .neighbors_directed(node, Direction::Incoming) + .filter(|&pred| !excluded_edges.contains(&(pred, node))) + .filter_map(|pred| node_ranks.get(&pred)) + .max(); + + let rank = match max_pred_rank { + Some(pred_rank) => pred_rank + 1, + None => 0, + }; + + node_ranks.insert(node, rank); + } + + let mut ranked_nodes: Vec<(G::NodeId, usize)> = ordering + .iter() + .map(|&node| (node, node_ranks[&node])) + .collect(); + + ranked_nodes.sort_by_key(|&(_, rank)| rank); + ranked_nodes +} + +/// Take domain ranks and backward edges, produce a merged ranked list +/// with loopback nodes injected at the appropriate ranks. +/// +/// For each backward edge (u, v) where rank(u) > rank(v): +/// - LoopbackLeft is placed at rank(v) - 1 (shifts all ranks up by 1 if needed) +/// - LoopbackRight is placed at rank(u) + 1 +/// +/// For self-loops (u == u): +/// - LoopbackLeft at rank(u) - 1 +/// - LoopbackRight at rank(u) + 1 +fn inject_loopback_entries( + domain_ranks: Vec<(NodeId, usize)>, + backward_edges: &std::collections::HashSet<(NodeId, NodeId)>, +) -> Vec<(RankedNode, usize)> { + if backward_edges.is_empty() { + return domain_ranks + .into_iter() + .map(|(id, rank)| (RankedNode::Domain(id), rank)) + .collect(); + } + + // Build rank lookup + let rank_map: HashMap = domain_ranks.iter().copied().collect(); + + // Collect loopback insertions with their desired ranks + let mut loopback_entries: Vec<(RankedNode, usize)> = Vec::new(); + for &(u, v) in backward_edges { + let v_rank = rank_map[&v]; + let u_rank = rank_map[&u]; + + // LoopbackLeft goes before v (lower rank endpoint) + // LoopbackRight goes after u (higher rank endpoint) + // For self-loops u == v, same logic applies + let left_rank = v_rank; // will be shifted to make room + let right_rank = u_rank + 1; + + loopback_entries.push((RankedNode::LoopbackLeft { back_edge: (u, v) }, left_rank)); + loopback_entries.push((RankedNode::LoopbackRight { back_edge: (u, v) }, right_rank)); + } + + // Shift domain ranks to make room for loopback-left entries. + // Each LoopbackLeft at rank R needs a slot before R, so all + // domain nodes at rank >= R get shifted up by 1. + // We process insertions from highest rank to lowest to avoid cascading. + let mut left_ranks: Vec = loopback_entries + .iter() + .filter(|(node, _)| matches!(node, RankedNode::LoopbackLeft { .. })) + .map(|(_, rank)| *rank) + .collect(); + left_ranks.sort_unstable(); + left_ranks.dedup(); + left_ranks.reverse(); // Process highest first + + // Build mutable rank map + let mut adjusted_ranks: HashMap = rank_map; + + // Also track adjustments for loopback-right entries + let mut rank_adjustments: Vec<(usize, usize)> = Vec::new(); // (threshold, shift) + + for (shift_idx, &threshold) in left_ranks.iter().rev().enumerate() { + // Shift all nodes at rank >= threshold up by (shift_idx + 1) + // But we need cumulative shifts, so we track them + rank_adjustments.push((threshold, shift_idx + 1)); + } + + // Apply shifts: for each node, count how many thresholds are <= its rank + for rank in adjusted_ranks.values_mut() { + let mut shift = 0; + for &(threshold, _) in &rank_adjustments { + if *rank >= threshold { + shift += 1; + } + } + *rank += shift; + } + + // Build final list + let mut result: Vec<(RankedNode, usize)> = adjusted_ranks + .into_iter() + .map(|(id, rank)| (RankedNode::Domain(id), rank)) + .collect(); + + // Add loopback entries with adjusted ranks + for (node, original_rank) in loopback_entries { + let adjusted = match &node { + RankedNode::LoopbackLeft { .. } => { + // LoopbackLeft goes at original_rank, but shifted by + // the number of other left-loopbacks at strictly lower ranks + let mut shift = 0; + for &threshold in &left_ranks { + // left_ranks is in reverse order (highest first) + if threshold < original_rank { + shift += 1; + } + } + original_rank + shift + } + RankedNode::LoopbackRight { .. } => { + // LoopbackRight: original_rank was u_rank + 1. + // Apply same shift as domain nodes at that rank. + let mut shift = 0; + for &(threshold, _) in &rank_adjustments { + if original_rank > threshold { + shift += 1; + } + } + original_rank + shift + } + RankedNode::Domain(_) => unreachable!(), + }; + result.push((node, adjusted)); + } + + result.sort_by_key(|(_, rank)| *rank); + result +} + /// Partition a Graph (StableDiGraph or DiGraphMap) into subgraphs, preferably at articulation points /// - Subgraph sizes are controlled by a minimum width (number of ranks) and maximum size in number of nodes. /// - Algorithm: @@ -192,87 +367,169 @@ where /// - If the maximum partition size is reached, forcibly close out the current subgraph. impl PartitionTable where - G: GraphBase - + Clone - + EdgeIndexable - + NodeIndexable - + NodeCount - + Visitable - + IntoEdgeReferences - + IntoNodeIdentifiers - + IntoNeighborsDirected, + G: GraphBase + NodeIndexable, G::NodeId: Copy + Eq + Hash + Ord, - G::EdgeId: Clone, { /// Create a new PartitionTable from a generic graph (e.g. StableDiGraph or DiGraphMap) - pub fn new(graph: G) -> Self + pub fn new(graph: &G) -> Self where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, ::NodeId: std::fmt::Debug, + for<'a> &'a G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, { // TODO: move these to const or config file Self::new_with_config(graph, 1000, usize::MAX) } /// Create a new PartitionTable from a generic graph (e.g. StableDiGraph or DiGraphMap) - pub fn new_with_config(graph: G, min_width: usize, max_nodes: usize) -> Self + pub fn new_with_config(graph: &G, min_width: usize, max_nodes: usize) -> Self where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, ::NodeId: std::fmt::Debug, + for<'a> &'a G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, { + let config = PartitionConfig { + layer_count: min_width, + node_count: max_nodes, + pin_source: None, + pin_sink: None, + }; + Self::new_with_full_config(graph, &config) + } + + /// Create a new PartitionTable with full configuration including pin options. + pub fn new_with_full_config(graph: &G, config: &PartitionConfig) -> Self + where + G: EdgeIndexable + NodeCount + Visitable, + G::EdgeId: Clone, + ::NodeId: std::fmt::Debug, + for<'a> &'a G: GraphBase + + IntoNodeIdentifiers + + IntoEdgeReferences> + + IntoNeighborsDirected, + { + let min_width = config.layer_count; + let max_nodes = config.node_count; + let mut all_partitions: Vec = Vec::new(); let mut current_partition: Partition = Partition::new(); let mut current_partition_index = 0; - // Mapping from node identifier to (partition index, node index) - // (G:NodeId has Copy, so this copies the value into the hashmap) + // Mapping from domain node identifier to (partition index, partition node index) let mut node_map: HashMap)> = HashMap::new(); - let articulation_points = find_articulation_points(&graph); + // Mapping from backward edge to (partition index, partition node index) for loopback nodes + #[allow(clippy::type_complexity)] + let mut loopback_left_map: HashMap< + (G::NodeId, G::NodeId), + (PartitionIndex, NodeIndex), + > = HashMap::new(); + #[allow(clippy::type_complexity)] + let mut loopback_right_map: HashMap< + (G::NodeId, G::NodeId), + (PartitionIndex, NodeIndex), + > = HashMap::new(); + + let articulation_points = find_articulation_points(graph); log::trace!( "Found {} articulation points: {:?}", articulation_points.len(), articulation_points ); - let node_ranks = - compute_all_ranks(&graph).expect("Could not compute ranks for graph layout"); + // Convert pin NodeIndex values to G::NodeId for cycle removal + let pin_source_id = config + .pin_source + .map(|idx| ::from_index(graph, idx.index())); + let pin_sink_id = config + .pin_sink + .map(|idx| ::from_index(graph, idx.index())); + + // Step 1: Compute ordering and identify backward edges + let CycleRemovalResult { + ordering, + backward_edges, + } = cycle_removal::remove_cycles(graph, pin_source_id, pin_sink_id); + + // Convert backward edges from G::NodeId to NodeIndex for storage + let backward_edges_as_indices: std::collections::HashSet<(NodeIndex, NodeIndex)> = + backward_edges + .iter() + .map(|&(u, v)| { + let u_idx = NodeIndex::new(::to_index(graph, u)); + let v_idx = NodeIndex::new(::to_index(graph, v)); + (u_idx, v_idx) + }) + .collect(); + + // Step 2: Compute ranks from ordering (skipping backward edges) + let domain_ranks = compute_ranks_from_ordering(graph, &ordering, &backward_edges); + + // Step 3: Inject loopback entries for backward edges + let ranked_entries = inject_loopback_entries(domain_ranks, &backward_edges); let mut min_rank = 0; // The minimum rank of the current partition let mut prev_rank = 0; // Rank of previous node evaluated - for (node, rank) in node_ranks { - // Convert the original node identifier to a NodeIndex, regardless of the graph type - let node_idx_usize = ::to_index(&graph, node); - let node_idx = NodeIndex::new(node_idx_usize); - let can_close_out = rank - min_rank >= min_width; - let must_close_out = current_partition.graph.node_count() >= max_nodes; - let is_articulation = articulation_points.contains(&node); - - if (can_close_out && is_articulation) || (must_close_out && rank != prev_rank) { - log::trace!( - "Split graph at: Node {:?}, rank {}, min_rank {}, can_close_out {}, must_close_out {}, is_articulation {}", - node, - rank, - min_rank, - can_close_out, - must_close_out, - is_articulation - ); - all_partitions.push(current_partition); - // The bridge partitions are empty at this point - all_partitions.push(Partition::new()); - current_partition = Partition::new(); - current_partition_index += 2; - min_rank = rank; - } + for (ranked_node, rank) in &ranked_entries { + match ranked_node { + RankedNode::Domain(node) => { + let node_idx_usize = ::to_index(graph, *node); + let node_idx = NodeIndex::new(node_idx_usize); + let can_close_out = rank - min_rank >= min_width; + let must_close_out = current_partition.graph.node_count() >= max_nodes; + let is_articulation = articulation_points.contains(node); + + if (can_close_out && is_articulation) || (must_close_out && *rank != prev_rank) + { + log::trace!( + "Split graph at: Node {:?}, rank {}, min_rank {}, can_close_out {}, must_close_out {}, is_articulation {}", + node, + rank, + min_rank, + can_close_out, + must_close_out, + is_articulation + ); + all_partitions.push(current_partition); + all_partitions.push(Partition::new()); + current_partition = Partition::new(); + current_partition_index += 2; + min_rank = *rank; + } - let partition_node_index: NodeIndex = current_partition - .graph - .add_node(PartitionNode::Data(node_idx)); + let partition_node_index: NodeIndex = current_partition + .graph + .add_node(PartitionNode::Data(node_idx)); - node_map.insert(node, (current_partition_index, partition_node_index)); - prev_rank = rank; + node_map.insert(*node, (current_partition_index, partition_node_index)); + prev_rank = *rank; + } + RankedNode::LoopbackLeft { back_edge } => { + let partition_node_index: NodeIndex = + current_partition.graph.add_node(PartitionNode::Loopback); + loopback_left_map + .insert(*back_edge, (current_partition_index, partition_node_index)); + prev_rank = *rank; + } + RankedNode::LoopbackRight { back_edge } => { + let partition_node_index: NodeIndex = + current_partition.graph.add_node(PartitionNode::Loopback); + loopback_right_map + .insert(*back_edge, (current_partition_index, partition_node_index)); + prev_rank = *rank; + } + } } - // Add the last section, without bridgesubgraph + // Add the last section, without bridge subgraph if current_partition.graph.node_count() > 0 { all_partitions.push(current_partition); } @@ -284,12 +541,44 @@ where Vec<(NodeIndex, NodeIndex, EdgeIndex)>, > = HashMap::new(); - for edge in (&graph).edge_references() { - let edge_idx_usize = ::to_index(&graph, edge.id()); - let edge_idx = EdgeIndex::new(edge_idx_usize); + // Helper to add an edge, either within a partition or across partitions + #[allow(clippy::type_complexity)] + let add_edge_to_partitions = |src_part: PartitionIndex, + src_node: NodeIndex, + tgt_part: PartitionIndex, + tgt_node: NodeIndex, + weight: PartitionEdge, + partitions: &mut Vec, + inter_edges: &mut HashMap< + (PartitionIndex, PartitionIndex), + Vec<(NodeIndex, NodeIndex, EdgeIndex)>, + >| { + if src_part == tgt_part { + partitions[src_part] + .graph + .add_edge(src_node, tgt_node, weight); + } else if let Some((src_domain_idx, tgt_domain_idx)) = weight { + inter_edges.entry((src_part, tgt_part)).or_default().push(( + src_domain_idx, + tgt_domain_idx, + EdgeIndex::new(0), + )); + } + }; + + // Pass 1: Normal edges (excluding backward edges) + for edge in graph.edge_references() { let source = edge.source(); let target = edge.target(); + // Skip backward edges — they are handled by the loopback chain + if backward_edges.contains(&(source, target)) { + continue; + } + + let edge_idx_usize = ::to_index(graph, edge.id()); + let edge_idx = EdgeIndex::new(edge_idx_usize); + let (source_partition_idx, source_node_index) = node_map .get(&source) .copied() @@ -299,18 +588,16 @@ where .copied() .expect("Encountered edge with unknown target node"); - let source_domain_idx = NodeIndex::new(::to_index(&graph, source)); - let target_domain_idx = NodeIndex::new(::to_index(&graph, target)); + let source_domain_idx = NodeIndex::new(::to_index(graph, source)); + let target_domain_idx = NodeIndex::new(::to_index(graph, target)); if source_partition_idx == target_partition_idx { - // Same partition -> add it to the graph all_partitions[source_partition_idx].graph.add_edge( source_node_index, target_node_index, Some((source_domain_idx, target_domain_idx)), ); } else { - // Different partition -> store using domain IDs for unified layout graph inter_partition_edges .entry((source_partition_idx, target_partition_idx)) .or_default() @@ -318,6 +605,48 @@ where } } + // Pass 2: Loopback chain edges for backward edges + for &(u_id, v_id) in &backward_edges { + let (l_part, l_node) = loopback_left_map[&(u_id, v_id)]; + let (r_part, r_node) = loopback_right_map[&(u_id, v_id)]; + let (v_part, v_node) = node_map[&v_id]; + let (u_part, u_node) = node_map[&u_id]; + let u_domain_idx = NodeIndex::new(::to_index(graph, u_id)); + let v_domain_idx = NodeIndex::new(::to_index(graph, v_id)); + let weight: PartitionEdge = Some((u_domain_idx, v_domain_idx)); + + // L→v + add_edge_to_partitions( + l_part, + l_node, + v_part, + v_node, + weight, + &mut all_partitions, + &mut inter_partition_edges, + ); + // L→R + add_edge_to_partitions( + l_part, + l_node, + r_part, + r_node, + weight, + &mut all_partitions, + &mut inter_partition_edges, + ); + // u→R + add_edge_to_partitions( + u_part, + u_node, + r_part, + r_node, + weight, + &mut all_partitions, + &mut inter_partition_edges, + ); + } + let num_partitions = all_partitions.len(); assert!(num_partitions > 0, "No partitions created"); @@ -363,8 +692,39 @@ where }) .collect(); - for (node_idx, domain_idx) in data_nodes { - // Find all incoming inter-partition edges to this domain node + // Collect loopback right nodes for left stitch connection + // LoopbackRight has incoming edges from domain node u (higher rank) + // and edges from L. For left stitch (incoming to partition), we need to + // find inter-partition edges where the TARGET is v (the lower rank endpoint). + // LoopbackRight is placed at the higher rank (u_rank + 1), so it represents v. + let loopback_right_nodes: Vec<(NodeIndex, NodeIndex)> = partition + .graph + .node_indices() + .filter(|&node_idx| { + matches!( + partition.graph.node_weight(node_idx), + Some(PartitionNode::Loopback) + ) + }) + .filter_map(|node_idx| { + loopback_right_map + .iter() + .find(|&(_, &(part, node))| part == partition_idx && node == node_idx) + .map(|(&back_edge, _)| { + let (_u, v) = back_edge; + // For left stitch (incoming edges), we match against v (the target of backward edge) + let v_domain_idx = + NodeIndex::new(::to_index(graph, v)); + (node_idx, v_domain_idx) + }) + }) + .collect(); + + for (node_idx, domain_idx) in data_nodes + .into_iter() + .chain(loopback_right_nodes.into_iter()) + { + // Find all incoming inter-partition edges to this domain/loopback node let mut bundles: Vec<(NodeIndex, NodeIndex)> = sorted_inter_partition_edges .iter() @@ -432,8 +792,48 @@ where }) .collect(); - for (node_idx, domain_idx) in data_nodes { - // Find all outgoing inter-partition edges from this domain node + // Collect loopback left nodes for right stitch connection + // LoopbackLeft has outgoing edges to domain node v (lower rank) + // We need to find inter-partition edges where target == v + let loopback_left_nodes: Vec<(NodeIndex, NodeIndex)> = partition + .graph + .node_indices() + .filter(|&node_idx| { + matches!( + partition.graph.node_weight(node_idx), + Some(PartitionNode::Loopback) + ) + }) + .filter_map(|node_idx| { + loopback_left_map + .iter() + .find(|&(_, &(part, node))| part == partition_idx && node == node_idx) + .map(|(&back_edge, _)| { + let (u, v) = back_edge; + // For right stitch (outgoing edges), we match against u (the source of backward edge) + // because the loopback edge goes from u -> LoopbackRight -> LoopbackLeft -> v + // LoopbackLeft is the target in the source partition, so outgoing edges from it + // should connect to right_stitch + let u_domain_idx = + NodeIndex::new(::to_index(graph, u)); + log::trace!( + "Found LoopbackLeft in partition {} at {:?}: back_edge=({:?}, {:?}), using u_domain={:?} for right stitch", + partition_idx, + node_idx, + u, + v, + u_domain_idx + ); + (node_idx, u_domain_idx) + }) + }) + .collect(); + + for (node_idx, domain_idx) in data_nodes + .into_iter() + .chain(loopback_left_nodes.into_iter()) + { + // Find all outgoing inter-partition edges from this domain/loopback node let mut bundles: Vec<(NodeIndex, NodeIndex)> = sorted_inter_partition_edges .iter() @@ -499,6 +899,7 @@ where partitions: all_partitions, node_map, inter_partition_edges, + backward_edges: backward_edges_as_indices, metrics: vec![ UnifiedLayout::new(num_partitions), // Minimal UnifiedLayout::new(num_partitions), // Full @@ -587,25 +988,14 @@ where // Bridge partition log::trace!( "load_partition: loading bridge partition {}", - partition_index, + partition_index ); - // Ensure adjacent sections are loaded before trying to build the bridge let (left_section, right_section) = Self::get_adjacent_sections(partition_index); if self.partitions[left_section].layouts[0].is_none() { - log::trace!( - "load_partition: loading left section {} for bridge {}", - left_section, - partition_index - ); self.load_partition(left_section, original_sizer, original_graph, vertex_spacing)?; } if self.partitions[right_section].layouts[0].is_none() { - log::trace!( - "load_partition: loading right section {} for bridge {}", - right_section, - partition_index - ); self.load_partition( right_section, original_sizer, @@ -620,27 +1010,8 @@ where VisualDetail::Truncated, ] { let (sources, targets) = self.get_bridge_edges(partition_index, detail_level)?; - log::trace!( - "get_bridge_edges: partition_index={}, sources.len()={}, targets.len()={}", - partition_index, - sources.len(), - targets.len() - ); - let bridge_graph = Self::make_bridge_graph(sources, targets); - log::trace!( - "make_bridge_graph: nodes={}, edges={}", - bridge_graph.node_count(), - bridge_graph.edge_count() - ); - let partition_layout = PartitionLayout::for_bridge(bridge_graph, vertex_spacing); - log::trace!( - "for_bridge: partition_index={}, layout width={}, height={}", - partition_index, - partition_layout.width, - partition_layout.height - ); let nominal_width = partition_layout.width; let nominal_height = partition_layout.height; @@ -650,38 +1021,14 @@ where self.metrics[detail_level.as_index()] .widths .add_at(partition_index, nominal_width); - // Why we're not updating the "rise" tree: - // In a bridge layouts endpoints are fixed on the Y-axis, and kept in the same reference - // as the partition to its right, hence we keep the rise value set to 0. - // (rise = height difference between the mean Y on the left and right side of a partition - // this allows graph i+1 to start at the y-level where graph i ended) + // Rise tree is not updated for bridge partitions (starts where left partition + // ends; ends where right partition starts) self.metrics[detail_level.as_index()].heights[partition_index] = nominal_height; } } Ok(()) } - pub fn debug_fenwick_state(&self, detail_level: VisualDetail) { - let metrics = &self.metrics[detail_level.as_index()]; - log::trace!("\n=== Fenwick Tree State ({:?}) ===", detail_level); - for i in 0..self.partitions.len() { - let width = if i == 0 { - metrics.widths.prefix_sum(0, 0) - } else { - metrics.widths.prefix_sum(i, 0) - metrics.widths.prefix_sum(i - 1, 0) - }; - let cum_x = metrics.widths.prefix_sum(i, 0); - let has_layout = self.has_layout(i, detail_level); - log::trace!( - " Partition {}: width={}, cumulative_x={}, has_layout={}", - i, - width, - cum_x, - has_layout - ); - } - } - /// Compute layout for a specific partition, using a specific node sizer and spacing. pub fn compute_partition_layouts( &mut self, @@ -695,13 +1042,6 @@ where G: GraphBase + NodeIndexable, for<'a> &'a G: petgraph::visit::IntoNeighbors, { - log::trace!( - "compute_partition_layouts: partition_index={}, vertex_spacing={}, total_partitions={}", - partition_index, - vertex_spacing, - self.partitions.len() - ); - if partition_index >= self.partitions.len() { return Err(format!( "Partition index {} out of bounds (max: {})", @@ -747,8 +1087,8 @@ where log::trace!("Partition {} is {} wide", partition_index, layout.width); let metrics = &mut self.metrics[detail_level.as_index()]; metrics.widths.add_at(partition_index, layout.width); + metrics.rise.add_at(partition_index, layout.height); metrics.heights[partition_index] = layout.height; - self.debug_fenwick_state(detail_level); } } @@ -1190,69 +1530,11 @@ where } } -/// Determine the rank of each node in a graph using a topological sorting of its nodes. -/// In a hierarchical graph layout, this corresponds to the layer (in our case x-coordinate). -/// The algorithm is simple: -/// - The first node in the topological order has rank 0 -/// - Each subsequent node has a rank that is one greater than the maximum rank of its predecessors -/// Results are returned as a Vec of (node, rank) pairs, sorted by rank. -/// TODO: cache the topological sort. -pub fn compute_all_ranks(graph: &G) -> Result, String> -where - G: GraphBase + NodeIndexable + NodeCount + Visitable, - for<'a> &'a G: IntoNodeIdentifiers + IntoNeighborsDirected, - G::NodeId: Copy + Eq + Hash + Ord, - G::EdgeId: Clone, -{ - if graph.node_count() == 0 { - return Ok(Vec::new()); - } - - // Perform topological sort - let sorted_nodes = match toposort(&graph, None) { - Ok(nodes) => nodes, - Err(_) => { - return Err("Could not compute ranks for graph layout. Is there a cycle?".to_string()); - } - }; - - // Initialize rank for all nodes to 0 - let mut node_ranks: HashMap = HashMap::new(); - for node in (&graph).node_identifiers() { - node_ranks.insert(node, 0); - } - - // Process nodes in topological order - for node in sorted_nodes { - let max_pred_rank = (&graph) - .neighbors_directed(node, Direction::Incoming) - .filter_map(|pred| node_ranks.get(&pred)) - .max(); - - let rank = match max_pred_rank { - Some(pred_rank) => pred_rank + 1, - None => 0, // No predecessors means this is a root node - }; - - node_ranks.insert(node, rank); - } - - // Convert to vector and sort by rank - let mut ranked_nodes: Vec<(G::NodeId, usize)> = (&graph) - .node_identifiers() - .map(|node| (node, node_ranks[&node])) - .collect(); - - ranked_nodes.sort_by_key(|&(_, rank)| rank); - - Ok(ranked_nodes) -} - #[cfg(test)] mod tests { use gen_core::HashId; use gen_graph::{GenGraph, GraphNode}; - use petgraph::{algo::toposort, graphmap::DiGraphMap}; + use petgraph::graphmap::DiGraphMap; use super::*; @@ -1281,69 +1563,6 @@ mod tests { })) } - #[test] - fn test_calculate_node_ranks_empty_graph() { - let graph = DiGraphMap::::new(); - let ranks = compute_all_ranks(&graph).unwrap(); - assert_eq!(ranks, Vec::new()); - } - - #[test] - fn test_calculate_node_ranks_single_node() { - let node = GraphNode { - block_id: 0, - node_id: HashId::pad_str(0), - sequence_start: 0, - sequence_end: 10, - }; - let mut graph = DiGraphMap::::new(); - graph.add_node(node); - let ranks = compute_all_ranks(&graph).unwrap(); - assert_eq!(ranks.len(), 1); - assert_eq!(ranks[0].1, 0); - assert_eq!(ranks[0].0, node); - } - - #[test] - fn test_calculate_node_ranks_linear_graph() { - // Test case: Simple linear graph - // 0 -> 1 -> 2 -> 3 -> 4 - let edges = vec![(0, 1), (1, 2), (2, 3), (3, 4)]; - let graph = make_test_graph(edges, None); - let _sorted_nodes = toposort(&graph, None).unwrap(); - let ranks = compute_all_ranks(&graph).unwrap(); - let rank_values: Vec = ranks.iter().map(|(_, rank)| *rank).collect(); - assert_eq!(rank_values, vec![0, 1, 2, 3, 4]); - } - - #[test] - fn test_calculate_node_ranks_parallel_paths() { - // Test case: Fork and join graph - // 0 -> 1 -> 3 - // \-> 2 -/ - let edges = vec![(0, 1), (0, 2), (1, 3), (2, 3)]; - let graph = make_test_graph(edges, None); - let ranks = compute_all_ranks(&graph).unwrap(); - let rank_values: Vec = ranks.iter().map(|(_, rank)| *rank).collect(); - assert_eq!(rank_values, vec![0, 1, 1, 2]); - } - - #[test] - fn test_calculate_node_ranks_dissimilar_paths() { - // Test case: Multiple paths of different lengths - // 0 -> 1 -> 3 -> 4 - // \----> 2 ----/ - let edges = vec![(0, 1), (0, 2), (1, 3), (2, 4), (3, 4)]; - let graph = make_test_graph(edges, None); - let ranks = compute_all_ranks(&graph).unwrap(); - // Petgraph toposort is not completely deterministic, so we can't assert the exact ranks - // other than the first and last nodes. - assert_eq!(ranks.len(), 5); - assert_eq!(ranks[0].1, 0); // First node in topo order should have rank 0 - let max_rank = ranks.iter().map(|(_, rank)| *rank).max().unwrap(); - assert_eq!(max_rank, 3); - } - #[test] fn test_skip_layer_inter_partition_edges() { // Test that layer-skipping edges are properly recorded in inter_partition_edges @@ -1890,9 +2109,9 @@ mod tests { let detail_level = VisualDetail::Minimal; struct SimpleSizer; - impl NodeSizer<&GenGraph> for SimpleSizer { + impl NodeSizer for SimpleSizer { fn get_node_size(&self, _node: &GraphNode, _detail_level: VisualDetail) -> (u64, u64) { - (10, 5) // Fixed size for testing + (10, 5) } fn get_dummy_size(&self) -> (u64, u64) { @@ -1903,7 +2122,7 @@ mod tests { // First call to compute_partition_layouts table - .compute_partition_layouts(0, &sizer, &&graph, VERTEX_SPACING_DEFAULT) + .compute_partition_layouts(0, &sizer, &graph, VERTEX_SPACING_DEFAULT) .unwrap(); let first_width = table.metrics[detail_level.as_index()] @@ -1917,7 +2136,7 @@ mod tests { // Second call should not change the values table - .compute_partition_layouts(0, &sizer, &&graph, VERTEX_SPACING_DEFAULT) + .compute_partition_layouts(0, &sizer, &graph, VERTEX_SPACING_DEFAULT) .unwrap(); let second_width = table.metrics[detail_level.as_index()] @@ -1935,7 +2154,7 @@ mod tests { // Third call for good measure table - .compute_partition_layouts(0, &sizer, &&graph, VERTEX_SPACING_DEFAULT) + .compute_partition_layouts(0, &sizer, &graph, VERTEX_SPACING_DEFAULT) .unwrap(); let third_width = table.metrics[detail_level.as_index()] diff --git a/gen-tui/src/plotter.rs b/gen-tui/src/plotter.rs index b353d01b..a045719c 100644 --- a/gen-tui/src/plotter.rs +++ b/gen-tui/src/plotter.rs @@ -1,11 +1,14 @@ // This module implements graph rendering using the ViewportGraph system. // All legacy rendering paths have been removed in favor of the unified ViewportGraph approach. -use std::hash::Hash; - -use petgraph::visit::{ - EdgeIndexable, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, IntoNodeIdentifiers, - NodeCount, NodeIndexable, Visitable, +use std::{collections::HashSet, hash::Hash}; + +use petgraph::{ + graph::NodeIndex, + visit::{ + EdgeIndexable, GraphBase, IntoEdgeReferences, IntoNeighborsDirected, IntoNodeIdentifiers, + NodeCount, NodeIndexable, Visitable, + }, }; use ratatui::{ style::{Color, Style}, @@ -22,6 +25,9 @@ use crate::{ viewport_graph::CroppedGraph, }; +/// Number of world-coordinate units between arrow markers on reversed edges. +const ARROW_GAPS: usize = 16; + /// Line style for path highlighting #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineStyle { @@ -461,6 +467,19 @@ pub fn plot_viewport_graph_with_highlights( } } } + + // Draw direction markers on reversed (backward) edges identified during cycle removal. + let mut drawn_reversed: HashSet<(NodeIndex, NodeIndex)> = HashSet::new(); + for (_, _, bundle) in viewport_graph.edges() { + for &(src, tgt) in bundle { + if src == tgt || !drawn_reversed.insert((src, tgt)) { + continue; + } + if viewport_graph.backward_edges.contains(&(src, tgt)) { + draw_arrows(buffer, viewport_graph, src, tgt, ARROW_GAPS); + } + } + } } /// Compute the junction glyph for a routing node based on its connections @@ -532,6 +551,192 @@ fn draw_edge_with_style( } } +/// Draw direction marker arrows along the visual path of an edge. +/// +/// Finds the visual path for the domain edge `(source, target)` by extracting the +/// subgraph of segments whose bundles contain that pair, then walks the path from the +/// Place directional arrow markers on every segment of a reversed edge. +/// +/// Arrow placement uses a diagonal grid: a marker is placed at every position where +/// `(pos.x + pos.y).rem_euclid(gaps) == 0`, keeping markers aligned across partition +/// boundaries regardless of where each viewport window starts. +/// +/// Direction is determined by walking the edge's subgraph from a known anchor node: +/// - If source is visible, walk forward from source. +/// - If only target is visible, walk backward from target. +/// - Otherwise, walk from the rightmost degree-1 node (assumed source side). +/// Segments unreachable from any anchor fall back to geometry: +/// - Vertical: above y=0 → `▼` (facing down toward nodes), below y=0 → `▲`. +/// - Horizontal: `◀` (backward edges always go right-to-left). +/// +/// Only cells already containing a straight line character are overwritten; +/// the existing style is preserved. +pub fn draw_arrows( + buffer: &mut WorldBuffer, + viewport_graph: &CroppedGraph, + source: NodeIndex, + target: NodeIndex, + gaps: usize, +) { + if gaps == 0 || source == target { + return; + } + let g = gaps as i64; + + // The subgraph already contains exactly the segments labelled with (source, target). + // No search needed — we just walk this pre-filtered graph from an anchor. + let sub = viewport_graph.subgraph(|bundle| bundle.contains(&(source, target))); + if sub.graph.node_count() == 0 { + return; + } + + // Find world position of the source or target domain node within the subgraph. + let source_pos = sub + .node_data_by_pos + .iter() + .find(|(_, n)| matches!(n.role, NodeRole::Data(idx) if idx == source)) + .map(|(pos, _)| *pos); + let target_pos = sub + .node_data_by_pos + .iter() + .find(|(_, n)| matches!(n.role, NodeRole::Data(idx) if idx == target)) + .map(|(pos, _)| *pos); + + // Choose walk anchor and direction. Prefer source (forward), else target (backward), + // else the rightmost degree-1 node (assumed source side for a backward edge). + let (start, forward) = if let Some(p) = source_pos { + (p, true) + } else if let Some(p) = target_pos { + (p, false) + } else { + let endpoint = sub + .graph + .nodes() + .filter(|&p| sub.graph.neighbors(p).count() == 1) + .max_by_key(|p| p.x) + .or_else(|| sub.graph.nodes().next()); + match endpoint { + Some(p) => (p, true), + None => return, + } + }; + + // Walk from `start` through the subgraph, recording directed (from, to) per edge. + // At each junction we simply continue to every unvisited neighbor — no search required. + // Key: normalised (lo, hi); value: directed (from, to). + let mut directed: Vec<((WorldPos, WorldPos), (WorldPos, WorldPos))> = Vec::new(); + let mut visited: HashSet = HashSet::new(); + let mut stack: Vec = vec![start]; + visited.insert(start); + + while let Some(cur) = stack.pop() { + for next in sub.graph.neighbors(cur) { + let key = if cur <= next { + (cur, next) + } else { + (next, cur) + }; + if directed.iter().any(|(k, _)| *k == key) { + continue; + } + let (from, to) = if forward { (cur, next) } else { (next, cur) }; + directed.push((key, (from, to))); + if !visited.contains(&next) { + visited.insert(next); + stack.push(next); + } + } + } + + // Offset the diagonal grid so that the outer markers on the longest segment are + // equidistant from their respective endpoints. For a segment of length L with + // spacing g, the unused remainder is L % g; splitting that equally gives a margin + // of m = (L % g) / 2 at each end, so the first marker lands at lo + m. + let grid_offset = { + let mut best_len: i64 = -1; + let mut offset: i64 = 0; + let mut seen_len: HashSet<(WorldPos, WorldPos)> = HashSet::new(); + for (seg_a, seg_b, _) in sub.edges() { + let key = if seg_a <= seg_b { + (seg_a, seg_b) + } else { + (seg_b, seg_a) + }; + if !seen_len.insert(key) { + continue; + } + let (lo, hi) = key; + let len = if lo.x == hi.x { + hi.y - lo.y + } else { + hi.x - lo.x + }; + if len > best_len { + best_len = len; + let margin = len.rem_euclid(g) / 2; + offset = (lo.x + lo.y + margin).rem_euclid(g); + } + } + offset + }; + + // Place markers on every segment in the subgraph. + let mut seen: HashSet<(WorldPos, WorldPos)> = HashSet::new(); + for (seg_a, seg_b, _) in sub.edges() { + let key = if seg_a <= seg_b { + (seg_a, seg_b) + } else { + (seg_b, seg_a) + }; + if !seen.insert(key) { + continue; + } + let (lo, hi) = key; + + let arrow_ch = if let Some((_, (from, to))) = directed.iter().find(|(k, _)| *k == key) { + let (from, to) = (*from, *to); + if to.x > from.x { + '▶' + } else if to.x < from.x { + '◀' + } else if to.y > from.y { + '▲' // world y increases upward → ▲ + } else { + '▼' + } + } else if lo.x == hi.x { + let mid_y = (lo.y + hi.y) / 2; + if mid_y >= 0 { '▼' } else { '▲' } + } else { + '◀' + }; + + if lo.x == hi.x { + for y in lo.y..=hi.y { + let pos = WorldPos::new(lo.x, y); + if (pos.x + pos.y - grid_offset).rem_euclid(g) == 0 + && matches!(buffer.get_char(pos), Some('│') | Some('┃') | Some('┆')) + { + if let Some((_, style)) = buffer.get_char_styled(pos) { + buffer.set_char_styled(pos, arrow_ch, style); + } + } + } + } else { + for x in lo.x..=hi.x { + let pos = WorldPos::new(x, lo.y); + if (pos.x + pos.y - grid_offset).rem_euclid(g) == 0 + && matches!(buffer.get_char(pos), Some('─') | Some('━') | Some('┄')) + { + if let Some((_, style)) = buffer.get_char_styled(pos) { + buffer.set_char_styled(pos, arrow_ch, style); + } + } + } + } + } +} + /// Render any graph widget to string representation using TestBackend /// /// This is a domain-agnostic function that can work with any graph type and custom renderers. diff --git a/gen-tui/src/testing/layout_tests.rs b/gen-tui/src/testing/layout_tests.rs index 33466ad4..4f18142e 100644 --- a/gen-tui/src/testing/layout_tests.rs +++ b/gen-tui/src/testing/layout_tests.rs @@ -80,7 +80,7 @@ where viewport_graph, &mut buffer, &mut renderer, - &controller.graph, + controller.graph(), detail_level, &controller.theme, ); @@ -1804,3 +1804,324 @@ fn test_diamond_variable_width_parallel_nodes() { insta::assert_snapshot!("diamond_variable_width_parallel", snapshot); } + +#[test] +fn viewport_visual_regression_simple_cycle() { + let _ = env_logger::try_init(); + // Create a simple cycle: A -> B -> C -> A + let mut domain_graph = MockDomainGraph::new(); + let n0 = domain_graph.add_node(()); + let n1 = domain_graph.add_node(()); + let n2 = domain_graph.add_node(()); + domain_graph.add_edge(n0, n1, ()); + domain_graph.add_edge(n1, n2, ()); + domain_graph.add_edge(n2, n0, ()); // Cycle edge + + let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); + insta::assert_snapshot!("simple_cycle", snapshot); +} + +#[test] +fn pinned_source_cycle() { + let _ = env_logger::try_init(); + use petgraph::graph::NodeIndex; + + use crate::{ + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + testing::mocks::TestRenderers, + }; + + // Create a 12-node cycle + let mut domain_graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); + for i in 0..12 { + domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); + } + + // Pin node 6 as the source (should appear at leftmost position) + let pinned_node = nodes[6]; + + let node_sizer = FixedNodeSizer { + width: 5, + height: 3, + }; + let mut renderer = TestRenderers::debug(); + + let mut config = GraphConfig::default(); + config.partition.layer_count = usize::MAX; + config.partition.node_count = usize::MAX; + + config.partition.pin_source = Some(NodeIndex::new(pinned_node.index())); + + let mut terminal = create_test_terminal(80, 25); + let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); + controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); + controller.set_detail_level(VisualDetail::Full); + + let _ = terminal.draw(|f| { + let area = f.area(); + controller.viewport_state.viewport_bounds = area; + let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); + + controller + .rebuild_viewport_graph() + .expect("Failed to rebuild viewport graph for snapshot generation"); + let viewport_graph = controller.get_viewport_graph(); + let detail_level = controller.get_detail_level(); + + let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); + plot_viewport_graph( + viewport_graph, + &mut buffer, + &mut renderer, + controller.graph(), + detail_level, + &controller.theme, + ); + }); + + let snapshot = format!("{}", terminal.backend()); + + insta::assert_snapshot!("pinned_source_cycle", snapshot); +} + +#[test] +fn pinned_source_cycle_partitioned() { + let _ = env_logger::try_init(); + use petgraph::graph::NodeIndex; + + use crate::{ + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + testing::mocks::TestRenderers, + }; + + // Create a 12-node cycle + let mut domain_graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); + for i in 0..12 { + domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); + } + + // Pin node 6 as the source (should appear at leftmost position) + let pinned_node = nodes[6]; + + let node_sizer = FixedNodeSizer { + width: 5, + height: 3, + }; + let mut renderer = TestRenderers::debug(); + + let mut config = GraphConfig::default(); + config.partition.layer_count = 2; + config.partition.node_count = 8; + + config.partition.pin_source = Some(NodeIndex::new(pinned_node.index())); + + let mut terminal = create_test_terminal(80, 25); + let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); + controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); + controller.set_detail_level(VisualDetail::Full); + + let _ = terminal.draw(|f| { + let area = f.area(); + controller.viewport_state.viewport_bounds = area; + let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); + + controller + .rebuild_viewport_graph() + .expect("Failed to rebuild viewport graph for snapshot generation"); + let viewport_graph = controller.get_viewport_graph(); + let detail_level = controller.get_detail_level(); + + let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); + // Export viewport graph to dot if RUST_LOG=debug is active + if std::env::var("RUST_LOG") + .map(|v| v.contains("debug")) + .unwrap_or(false) + { + // Generate a filename based on the current test name + let current_thread = std::thread::current(); + let test_name = current_thread.name().unwrap_or("unknown_test"); + let filename = format!("{}_viewport.dot", test_name); + if let Err(e) = crate::dot_export::export_to_dot(viewport_graph, &filename) { + eprintln!("Failed to export dot file {}: {}", filename, e); + } + } + + plot_viewport_graph( + viewport_graph, + &mut buffer, + &mut renderer, + controller.graph(), + detail_level, + &controller.theme, + ); + }); + + let snapshot = format!("{}", terminal.backend()); + + insta::assert_snapshot!("pinned_source_cycle_partitioned", snapshot); +} + +#[test] +fn cycle_with_chord() { + let _ = env_logger::try_init(); + + use crate::{ + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + testing::mocks::TestRenderers, + }; + + let mut domain_graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); + // Arrange nodes in a closed circle that we'll open at 0 + for i in 0..12 { + domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); + } + //let pinned_node = nodes[0]; + // Extra cycle + domain_graph.add_edge(nodes[6], nodes[3], ()); + + let snapshot = make_snapshot(domain_graph, 60, 20, usize::MAX, usize::MAX); + + insta::assert_snapshot!("cycle_with_chord", snapshot); +} + +#[test] +fn cycle_with_chords() { + let _ = env_logger::try_init(); + + use crate::{ + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + testing::mocks::TestRenderers, + }; + + let mut domain_graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); + // Arrange nodes in a closed circle that we'll open at 0 + for i in 0..12 { + domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); + } + // Extra cycle + domain_graph.add_edge(nodes[6], nodes[3], ()); + domain_graph.add_edge(nodes[4], nodes[0], ()); + + let node_sizer = FixedNodeSizer { + width: 5, + height: 3, + }; + let mut renderer = TestRenderers::debug(); + + let mut config = GraphConfig::default(); + config.partition.layer_count = usize::MAX; + config.partition.node_count = usize::MAX; + + config.partition.pin_source = None; + + let mut terminal = create_test_terminal(80, 25); + let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); + controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); + controller.set_detail_level(VisualDetail::Full); + + let _ = terminal.draw(|f| { + let area = f.area(); + controller.viewport_state.viewport_bounds = area; + let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); + + controller + .rebuild_viewport_graph() + .expect("Failed to rebuild viewport graph for snapshot generation"); + let viewport_graph = controller.get_viewport_graph(); + let detail_level = controller.get_detail_level(); + + let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); + plot_viewport_graph( + viewport_graph, + &mut buffer, + &mut renderer, + controller.graph(), + detail_level, + &controller.theme, + ); + }); + + let snapshot = format!("{}", terminal.backend()); + + insta::assert_snapshot!("cycle_with_chord", snapshot); +} + +#[test] +fn pinned_source_cycle_with_chord() { + let _ = env_logger::try_init(); + use petgraph::graph::NodeIndex; + + use crate::{ + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + testing::mocks::TestRenderers, + }; + + let mut domain_graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); + // Arrange nodes in a closed circle that we'll open at 0 + for i in 0..12 { + domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); + } + let pinned_node = nodes[0]; + // Extra cycle + domain_graph.add_edge(nodes[6], nodes[3], ()); + + let node_sizer = FixedNodeSizer { + width: 5, + height: 3, + }; + let mut renderer = TestRenderers::debug(); + + let mut config = GraphConfig::default(); + config.partition.layer_count = usize::MAX; + config.partition.node_count = usize::MAX; + + config.partition.pin_source = Some(NodeIndex::new(pinned_node.index())); + + let mut terminal = create_test_terminal(80, 25); + let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); + controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); + controller.set_detail_level(VisualDetail::Full); + + let _ = terminal.draw(|f| { + let area = f.area(); + controller.viewport_state.viewport_bounds = area; + let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); + + controller + .rebuild_viewport_graph() + .expect("Failed to rebuild viewport graph for snapshot generation"); + let viewport_graph = controller.get_viewport_graph(); + let detail_level = controller.get_detail_level(); + + let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); + plot_viewport_graph( + viewport_graph, + &mut buffer, + &mut renderer, + controller.graph(), + detail_level, + &controller.theme, + ); + }); + + let snapshot = format!("{}", terminal.backend()); + + insta::assert_snapshot!("pinned_source_cycle_with_chord", snapshot); +} + +#[test] +fn viewport_visual_regression_self_loop() { + let _ = env_logger::try_init(); + // Create a self-loop: A -> A + let mut domain_graph = MockDomainGraph::new(); + let n0 = domain_graph.add_node(()); + domain_graph.add_edge(n0, n0, ()); // Self-loop + + let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); + insta::assert_snapshot!("self_loop", snapshot); +} diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap new file mode 100644 index 00000000..5527276f --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap @@ -0,0 +1,19 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" ╭─●─●─╮ " +" │ │ " +" ╰──◀──╯ " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap new file mode 100644 index 00000000..c602efd0 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap @@ -0,0 +1,24 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" █████ █████ " +" ╭─█N0██─█N1██─╮ " +" │ █████ █████ │ " +" │ │ " +" ╰──────◀──────╯ " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap new file mode 100644 index 00000000..9815edc6 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap @@ -0,0 +1,24 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" █████ █████ █████ █████ █████ █████ █████ █████" +" ╭─█N7██─█N8██─█N9██─█N10█─█N11█─█N0██─█N1██─█N2██" +" █████ │ █████ █████ █████ █████ █████ █████ █████ █████" +" ╭─█N6██─┤ " +" │ █████ │ " +" │ ╰────────────────────────────────────────────────" +" │ " +" ╰──────◀───────────────◀───────────────◀───────────────◀─" +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap new file mode 100644 index 00000000..4bdb0576 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ " +" ╭─█N6██─█N7██─█N8██─█N9██─█N10█─█N11█─█N0██─█N1██─█N2██─█N3██─█N4██─█N5██─╮ " +" │ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ │ " +" │ │ " +" ╰────◀───────────────◀───────────────◀───────────────◀───────────────◀────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap new file mode 100644 index 00000000..7e99fa5f --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ " +" ╭▶█N6██─█N7██─█N8██─█N9██─█N10█─█N11█─█N0██─█N1██─█N2██─█N3██─█N4██─█N5██─╮ " +" │ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ │ " +" │ │ " +" ╰───◀───────────────◀───────────────◀───────────────◀───────────────◀─────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap new file mode 100644 index 00000000..2000f612 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭──────◀───────────────◀──────╮ " +" │ │ " +" █████ █████ █████ ╰─╮ █████ █████ █████ █████ ╭─╯ █████ █████ █████ █████" +" ╭──█N0██─█N1██─█N2██────┴─█N3██─█N4██─█N5██─█N6██─┴────█N7██─█N8██─█N9██─█N10█" +" │ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████ █████" +" │ " +" ╰──◀───────────────◀───────────────◀───────────────◀───────────────◀──────────" +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap new file mode 100644 index 00000000..1b2dc43d --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap @@ -0,0 +1,24 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" █████ " +" ╭─█N0██─╮ " +" │ █████ │ " +" │ │ " +" ╰───────╯ " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap new file mode 100644 index 00000000..e17c910d --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap @@ -0,0 +1,24 @@ +--- +source: gen-tui/src/testing/layout_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" █████ █████ █████ " +" ╭─█N0██─█N1██─█N2██─╮ " +" ▲ █████ █████ █████ │ " +" │ │ " +" ╰─◀───────────────◀─╯ " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/viewport_graph.rs b/gen-tui/src/viewport_graph.rs index 1e608539..a3ce08ed 100644 --- a/gen-tui/src/viewport_graph.rs +++ b/gen-tui/src/viewport_graph.rs @@ -33,6 +33,10 @@ pub struct CroppedGraph { pub node_highlights: Vec<(WorldPos, crate::plotter::PathStyle)>, /// Edge highlights: list of world position pairs with their associated styles pub edge_highlights: Vec<((WorldPos, WorldPos), crate::plotter::PathStyle)>, + + /// Domain edges that were reversed during cycle removal. + /// Used to draw directional arrows on loopback edges. + pub backward_edges: HashSet<(NodeIndex, NodeIndex)>, } impl CroppedGraph { @@ -48,6 +52,7 @@ impl CroppedGraph { included_nodes: HashSet::new(), node_highlights: Vec::new(), edge_highlights: Vec::new(), + backward_edges: HashSet::new(), } } @@ -189,9 +194,56 @@ impl CroppedGraph { // Divide the graph up in logical layers by grouping Data nodes by x-coordinate this.build_layers_from_coordinates(); + // Remove dangling loopback routing nodes (those with only one or zero visible + // connections after stitch edges are dropped). Trace each dead-end chain back + // through routing nodes until reaching a T/X junction (degree ≥ 3) or a data node. + this.prune_loopback_dead_ends(); + + // Copy reversed-edge information so the renderer can draw directional arrows. + this.backward_edges = partition_table.backward_edges.clone(); + this } + /// Prune routing nodes that are dead ends (degree ≤ 1) and trace back along the chain. + fn prune_loopback_dead_ends(&mut self) { + use std::collections::VecDeque; + + // Seed: routing nodes currently at degree ≤ 1 + let seeds: Vec = self + .node_data_by_pos + .iter() + .filter(|(pos, node)| { + matches!(node.role, NodeRole::Routing) && self.graph.neighbors(**pos).count() <= 1 + }) + .map(|(pos, _)| *pos) + .collect(); + + let mut queue: VecDeque = seeds.into_iter().collect(); + + while let Some(pos) = queue.pop_front() { + if !self.node_data_by_pos.contains_key(&pos) { + continue; + } + + let neighbors: Vec = self.graph.neighbors(pos).collect(); + self.graph.remove_node(pos); + self.node_data_by_pos.remove(&pos); + + // Propagate: if a routing neighbor becomes a new dead end, queue it + for neighbor in neighbors { + if let Some(node) = self.node_data_by_pos.get(&neighbor) + && matches!(node.role, NodeRole::Routing) + { + let new_degree = self.graph.neighbors(neighbor).count(); + if new_degree <= 1 { + queue.push_back(neighbor); + } + } + } + } + } + /// Build layers by grouping Data nodes by their Sugiyama rank within each partition. /// /// Nodes in the same (partition_idx, layer) belong to the same logical rank and should diff --git a/src/views/annotation_track.rs b/src/views/annotation_track.rs index 24b13b26..14302448 100644 --- a/src/views/annotation_track.rs +++ b/src/views/annotation_track.rs @@ -229,7 +229,7 @@ pub fn draw_annotations_panel( track, controller.get_viewport_graph(), &controller.viewport_state, - controller.graph, + controller.graph(), ); if visible_indices.is_empty() { return; diff --git a/src/views/block_group.rs b/src/views/block_group.rs index 08565b75..5b36f2c9 100644 --- a/src/views/block_group.rs +++ b/src/views/block_group.rs @@ -114,7 +114,7 @@ fn toggle_path_highlight( Ok(false) } else { // Get the path nodes for this block group - let path_nodes = get_block_group_path_nodes(conn, block_group_id, controller.graph)?; + let path_nodes = get_block_group_path_nodes(conn, block_group_id, controller.graph())?; // Set the path highlight using GraphNodes directly controller.set_path_highlight(style, path_nodes); @@ -148,7 +148,7 @@ fn current_view_coordinate_window( use petgraph::visit::NodeIndexable; let viewport_graph = controller.get_viewport_graph(); - let graph = controller.graph; + let graph = *controller.graph(); let mut start = i64::MAX; let mut end = i64::MIN; diff --git a/src/views/block_group_inline.rs b/src/views/block_group_inline.rs index 36a9aae9..59358c20 100644 --- a/src/views/block_group_inline.rs +++ b/src/views/block_group_inline.rs @@ -148,7 +148,7 @@ impl<'a> InlineGenGraphState<'a> { /// Add a path to the widget, starting from a Path object pub fn add_path(&mut self, path: &Path, conn: &'a GraphConnection) -> Result<()> { - let path_nodes = get_path_nodes(conn, path, self.controller.graph)?; + let path_nodes = get_path_nodes(conn, path, self.controller.graph())?; self.paths.push(path_nodes); Ok(()) } From e846233e79920bc53b9707e490c748ae427a1c19 Mon Sep 17 00:00:00 2001 From: Bob Van Hove <1587584+bobvh@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:44:01 -0400 Subject: [PATCH 2/5] Clean up tests --- gen-tui/src/plotter.rs | 12 +- gen-tui/src/testing/layout_tests.rs | 1642 ++--------------- gen-tui/src/testing/mod.rs | 1 + gen-tui/src/testing/snapshot_tests.rs | 851 +++++++++ ...out_tests__arrow_fallback_short_cycle.snap | 19 - ...ow_forced_marker_short_edge_large_gap.snap | 24 - ...sting__layout_tests__cycle_with_chord.snap | 24 - ...__snapshot_tests__asymmetric_diamond.snap} | 2 +- ...apshot_tests__asymmetric_diamond_2_1.snap} | 2 +- ...apshot_tests__asymmetric_diamond_3_2.snap} | 2 +- ...apshot_tests__asymmetric_diamond_4_1.snap} | 2 +- ...apshot_tests__asymmetric_diamond_4_2.snap} | 2 +- ...sts__bridge_position_variable_widths.snap} | 2 +- ...n_five_partitions_long_spanning_edge.snap} | 2 +- ...chain_three_partitions_spanning_edge.snap} | 2 +- ...testing__snapshot_tests__complex_dag.snap} | 2 +- ...ing__snapshot_tests__cycle_with_chord.snap | 29 + ...pshot_tests__cycle_with_chord_pinned.snap} | 2 +- ...ng__snapshot_tests__cycle_with_chords.snap | 29 + ...pshot_tests__cycle_with_chords_pinned.snap | 29 + ..._check_complex_dag_node_partitioning.snap} | 2 +- ...ui__testing__snapshot_tests__diamond.snap} | 2 +- ...sts__diamond_variable_width_parallel.snap} | 2 +- ...esting__snapshot_tests__double_chain.snap} | 2 +- ...ng__snapshot_tests__even_width_nodes.snap} | 2 +- ...ended_complex_dag_layer_partitioning.snap} | 2 +- ...extended_complex_dag_no_partitioning.snap} | 2 +- ...tended_complex_dag_node_partitioning.snap} | 2 +- ..._extended_diamond_layer_partitioning.snap} | 2 +- ...ts__extended_diamond_no_partitioning.snap} | 2 +- ...__extended_diamond_node_partitioning.snap} | 2 +- ...napshot_tests__multi_partition_chain.snap} | 2 +- ..._snapshot_tests__pinned_source_cycle.snap} | 2 +- ...sts__pinned_source_cycle_partitioned.snap} | 2 +- ...__testing__snapshot_tests__self_loop.snap} | 2 +- ...esting__snapshot_tests__simple_chain.snap} | 2 +- ...esting__snapshot_tests__simple_cycle.snap} | 2 +- ...testing__snapshot_tests__single_node.snap} | 2 +- ..._testing__snapshot_tests__skip_layer.snap} | 2 +- ...tests__skip_layer_partition_boundary.snap} | 2 +- ...snapshot_tests__subcombinatorial_dag.snap} | 2 +- 41 files changed, 1098 insertions(+), 1624 deletions(-) create mode 100644 gen-tui/src/testing/snapshot_tests.rs delete mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap delete mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap delete mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__asymmetric_diamond.snap => gen_tui__testing__snapshot_tests__asymmetric_diamond.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__asymmetric_diamond_2_1.snap => gen_tui__testing__snapshot_tests__asymmetric_diamond_2_1.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__asymmetric_diamond_3_2.snap => gen_tui__testing__snapshot_tests__asymmetric_diamond_3_2.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__asymmetric_diamond_4_1.snap => gen_tui__testing__snapshot_tests__asymmetric_diamond_4_1.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__asymmetric_diamond_4_2.snap => gen_tui__testing__snapshot_tests__asymmetric_diamond_4_2.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__bridge_position_variable_widths.snap => gen_tui__testing__snapshot_tests__bridge_position_variable_widths.snap} (97%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__chain_five_partitions_long_spanning_edge.snap => gen_tui__testing__snapshot_tests__chain_five_partitions_long_spanning_edge.snap} (99%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__chain_three_partitions_spanning_edge.snap => gen_tui__testing__snapshot_tests__chain_three_partitions_spanning_edge.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__complex_dag.snap => gen_tui__testing__snapshot_tests__complex_dag.snap} (97%) create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord.snap rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap => gen_tui__testing__snapshot_tests__cycle_with_chord_pinned.snap} (98%) create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords.snap create mode 100644 gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords_pinned.snap rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__determinism_check_complex_dag_node_partitioning.snap => gen_tui__testing__snapshot_tests__determinism_check_complex_dag_node_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__diamond.snap => gen_tui__testing__snapshot_tests__diamond.snap} (96%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__diamond_variable_width_parallel.snap => gen_tui__testing__snapshot_tests__diamond_variable_width_parallel.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__double_chain.snap => gen_tui__testing__snapshot_tests__double_chain.snap} (99%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__even_width_nodes.snap => gen_tui__testing__snapshot_tests__even_width_nodes.snap} (97%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__extended_complex_dag_layer_partitioning.snap => gen_tui__testing__snapshot_tests__extended_complex_dag_layer_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__extended_complex_dag_node_partitioning.snap => gen_tui__testing__snapshot_tests__extended_complex_dag_no_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__extended_complex_dag_no_partitioning.snap => gen_tui__testing__snapshot_tests__extended_complex_dag_node_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__extended_diamond_no_partitioning.snap => gen_tui__testing__snapshot_tests__extended_diamond_layer_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__extended_diamond_node_partitioning.snap => gen_tui__testing__snapshot_tests__extended_diamond_no_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__extended_diamond_layer_partitioning.snap => gen_tui__testing__snapshot_tests__extended_diamond_node_partitioning.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__multi_partition_chain.snap => gen_tui__testing__snapshot_tests__multi_partition_chain.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__pinned_source_cycle.snap => gen_tui__testing__snapshot_tests__pinned_source_cycle.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap => gen_tui__testing__snapshot_tests__pinned_source_cycle_partitioned.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__self_loop.snap => gen_tui__testing__snapshot_tests__self_loop.snap} (96%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__simple_chain.snap => gen_tui__testing__snapshot_tests__simple_chain.snap} (96%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__simple_cycle.snap => gen_tui__testing__snapshot_tests__simple_cycle.snap} (96%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__single_node.snap => gen_tui__testing__snapshot_tests__single_node.snap} (96%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__skip_layer.snap => gen_tui__testing__snapshot_tests__skip_layer.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__skip_layer_partition_boundary.snap => gen_tui__testing__snapshot_tests__skip_layer_partition_boundary.snap} (98%) rename gen-tui/src/testing/snapshots/{gen_tui__testing__layout_tests__subcombinatorial_dag.snap => gen_tui__testing__snapshot_tests__subcombinatorial_dag.snap} (97%) diff --git a/gen-tui/src/plotter.rs b/gen-tui/src/plotter.rs index a045719c..a966184d 100644 --- a/gen-tui/src/plotter.rs +++ b/gen-tui/src/plotter.rs @@ -565,7 +565,7 @@ fn draw_edge_with_style( /// - If source is visible, walk forward from source. /// - If only target is visible, walk backward from target. /// - Otherwise, walk from the rightmost degree-1 node (assumed source side). -/// Segments unreachable from any anchor fall back to geometry: +/// Segments unreachable from any anchor fall back to geometry: /// - Vertical: above y=0 → `▼` (facing down toward nodes), below y=0 → `▲`. /// - Horizontal: `◀` (backward edges always go right-to-left). /// @@ -716,10 +716,9 @@ pub fn draw_arrows( let pos = WorldPos::new(lo.x, y); if (pos.x + pos.y - grid_offset).rem_euclid(g) == 0 && matches!(buffer.get_char(pos), Some('│') | Some('┃') | Some('┆')) + && let Some((_, style)) = buffer.get_char_styled(pos) { - if let Some((_, style)) = buffer.get_char_styled(pos) { - buffer.set_char_styled(pos, arrow_ch, style); - } + buffer.set_char_styled(pos, arrow_ch, style); } } } else { @@ -727,10 +726,9 @@ pub fn draw_arrows( let pos = WorldPos::new(x, lo.y); if (pos.x + pos.y - grid_offset).rem_euclid(g) == 0 && matches!(buffer.get_char(pos), Some('─') | Some('━') | Some('┄')) + && let Some((_, style)) = buffer.get_char_styled(pos) { - if let Some((_, style)) = buffer.get_char_styled(pos) { - buffer.set_char_styled(pos, arrow_ch, style); - } + buffer.set_char_styled(pos, arrow_ch, style); } } } diff --git a/gen-tui/src/testing/layout_tests.rs b/gen-tui/src/testing/layout_tests.rs index 4f18142e..dd0bb275 100644 --- a/gen-tui/src/testing/layout_tests.rs +++ b/gen-tui/src/testing/layout_tests.rs @@ -1,372 +1,108 @@ -#[cfg(test)] -use crate::graph_controller::{GraphController, WorldBuffer}; -#[cfg(test)] -use crate::layout::VisualDetail; -#[cfg(test)] -use crate::plotter::plot_viewport_graph; -#[cfg(test)] -use crate::testing::create_test_terminal; -#[cfg(test)] -use crate::testing::mocks::{FixedNodeSizer, MockDomainGraph, TestRenderers}; - -/// Helper function to create viewport-based visual snapshots using GraphController -#[cfg(test)] -fn make_snapshot_custom( - domain_graph: MockDomainGraph, - viewport_width: u16, - viewport_height: u16, - layer_count: usize, - node_count: usize, - node_sizer: NS, - mut renderer: R, -) -> String -where - NS: for<'a> crate::plotter::NodeSizer<&'a MockDomainGraph>, - R: for<'a> crate::plotter::NodeRenderer<&'a MockDomainGraph>, -{ - use crate::graph_controller::GraphConfig; - - let mut terminal = create_test_terminal(viewport_width, viewport_height); - - // // alternatively - very minimalist node labels: - // let mut renderer = TestRenderers::minimal(); - // let node_sizer = TestNodeSizers::fixed_1x1(); - - // Use configurable partitions for testing - let mut config = GraphConfig::default(); - config.partition.layer_count = layer_count; - config.partition.node_count = node_count; - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - - let test_viewport = ratatui::layout::Rect::new(0, 0, viewport_width, viewport_height); - controller.viewport_state.viewport_bounds = test_viewport; - - // Set detail level before the camera - controller.set_detail_level(VisualDetail::Full); - - let result = terminal.draw(|f| { - let area = f.area(); - controller.viewport_state.viewport_bounds = area; - let loaded_partitions = controller.ensure_camera_coverage(); - let partition_indices = loaded_partitions.unwrap_or_default(); - println!( - "number of partitions loaded: {}, indices: {:?}", - partition_indices.len(), - partition_indices - ); - - controller - .rebuild_viewport_graph() - .expect("Failed to rebuild viewport graph for snapshot generation"); - let viewport_graph = controller.get_viewport_graph(); - let detail_level = controller.get_detail_level(); - - // Export viewport graph to dot if RUST_LOG=debug is active - if std::env::var("RUST_LOG") - .map(|v| v.contains("debug")) - .unwrap_or(false) - { - // Generate a filename based on the current test name - let current_thread = std::thread::current(); - let test_name = current_thread.name().unwrap_or("unknown_test"); - let filename = format!("{}_viewport.dot", test_name); - if let Err(e) = crate::dot_export::export_to_dot(viewport_graph, &filename) { - eprintln!("Failed to export dot file {}: {}", filename, e); - } - } - - let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); - plot_viewport_graph( - viewport_graph, - &mut buffer, - &mut renderer, - controller.graph(), - detail_level, - &controller.theme, - ); +#![cfg(test)] +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use petgraph::{ + graph::NodeIndex, + stable_graph::StableDiGraph, + visit::{EdgeRef, IntoEdgeReferences}, +}; +use ratatui::layout::Rect; + +use crate::{ + geometry::{BigRect, SpatialObjectType, ViewportPos, WorldPos, WorldRect}, + graph_algorithms::find_articulation_points, + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + graph_widget::GraphWidget, + layout::{LayoutEngine, NodeRole, VisualDetail}, + partition::{PartitionEdge, PartitionNode}, + plotter::{NodeRenderer, NodeSizer}, + testing::{ + create_test_terminal, + mocks::{FixedNodeSizer, MockDomainGraph, TestGraphs}, + }, +}; + +pub(super) fn init_test_logging() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let _ = env_logger::try_init(); }); - - match result { - Ok(_) => format!("{}", terminal.backend()), - Err(e) => format!("Rendering failed: {}", e), - } } -/// Helper function to create viewport-based visual snapshots with default node sizer and renderer -#[cfg(test)] -fn make_snapshot( - domain_graph: MockDomainGraph, - viewport_width: u16, - viewport_height: u16, - layer_count: usize, - node_count: usize, -) -> String { - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - let renderer = TestRenderers::debug(); - - make_snapshot_custom( - domain_graph, - viewport_width, - viewport_height, - layer_count, - node_count, - node_sizer, - renderer, - ) -} - -#[test] -fn viewport_visual_regression_simple_chain() { - let _ = env_logger::try_init(); - // Create a simple chain domain graph: 0 -> 1 -> 2 - let mut domain_graph = MockDomainGraph::new(); - let node_0 = domain_graph.add_node(()); - let node_1 = domain_graph.add_node(()); - let node_2 = domain_graph.add_node(()); - domain_graph.add_edge(node_0, node_1, ()); - domain_graph.add_edge(node_1, node_2, ()); - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("simple_chain", snapshot); -} - -#[test] -fn viewport_visual_regression_diamond() { - let _ = env_logger::try_init(); - // Create a diamond domain graph: 0 -> {1, 2} -> 3 - let mut domain_graph = MockDomainGraph::new(); - let node_0 = domain_graph.add_node(()); - let node_1 = domain_graph.add_node(()); - let node_2 = domain_graph.add_node(()); - let node_3 = domain_graph.add_node(()); - domain_graph.add_edge(node_0, node_1, ()); - domain_graph.add_edge(node_0, node_2, ()); - domain_graph.add_edge(node_1, node_3, ()); - domain_graph.add_edge(node_2, node_3, ()); - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("diamond", snapshot); -} - -#[test] -fn viewport_visual_regression_single_node() { - let _ = env_logger::try_init(); - // Create a single node domain graph - let mut domain_graph = MockDomainGraph::new(); - domain_graph.add_node(()); - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("single_node", snapshot); -} +pub(super) fn graph_from_edges(node_count: usize, edges: &[(usize, usize)]) -> MockDomainGraph { + let mut graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..node_count).map(|_| graph.add_node(())).collect(); -#[test] -fn viewport_visual_regression_subcombinatorial_dag() { - let _ = env_logger::try_init(); - // Create a DAG in which two subsequent layers are not fully connected all-to-all. - // This tests the challenge of handling complex edge routing between layers. - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..6).map(|_| domain_graph.add_node(())).collect(); - - // Create edges: 0->{1,2}, 1->3, 2->{3,4}, 3->5, 4->5 - domain_graph.add_edge(nodes[0], nodes[1], ()); - domain_graph.add_edge(nodes[0], nodes[2], ()); - domain_graph.add_edge(nodes[1], nodes[3], ()); - domain_graph.add_edge(nodes[2], nodes[3], ()); - domain_graph.add_edge(nodes[2], nodes[4], ()); - domain_graph.add_edge(nodes[3], nodes[5], ()); - domain_graph.add_edge(nodes[4], nodes[5], ()); - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("subcombinatorial_dag", snapshot); -} - -#[test] -fn viewport_visual_regression_complex_dag() { - let _ = env_logger::try_init(); - // Create the original complex DAG structure matching TestGraphs::complex_dag() - // This is a hierarchical 9-node DAG with multiple levels and convergence points - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..9).map(|_| domain_graph.add_node(())).collect(); - - // Create the complex hierarchical structure: - // 0 -> {1, 2} - // 1 -> {3, 4} - // 2 -> {4, 5} - // 3 -> 6 - // 4 -> {6, 7} - // 5 -> 7 - // 6 -> 8 - // 7 -> 8 - domain_graph.add_edge(nodes[0], nodes[1], ()); - domain_graph.add_edge(nodes[0], nodes[2], ()); - domain_graph.add_edge(nodes[1], nodes[3], ()); - domain_graph.add_edge(nodes[1], nodes[4], ()); - domain_graph.add_edge(nodes[2], nodes[4], ()); - domain_graph.add_edge(nodes[2], nodes[5], ()); - domain_graph.add_edge(nodes[3], nodes[6], ()); - domain_graph.add_edge(nodes[4], nodes[6], ()); - domain_graph.add_edge(nodes[4], nodes[7], ()); - domain_graph.add_edge(nodes[5], nodes[7], ()); - domain_graph.add_edge(nodes[6], nodes[8], ()); - domain_graph.add_edge(nodes[7], nodes[8], ()); - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("complex_dag", snapshot); -} - -#[test] -fn viewport_multi_partition_boundary_handling() { - let _ = env_logger::try_init(); - // Create a wide graph that forces multiple partitions to test boundary handling - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..20).map(|_| domain_graph.add_node(())).collect(); - - // Create a long chain that should force multiple partitions - for i in 0..19 { - domain_graph.add_edge(nodes[i], nodes[i + 1], ()); + for &(src, dst) in edges { + graph.add_edge(nodes[src], nodes[dst], ()); } - let snapshot = make_snapshot(domain_graph, 120, 30, 3, 5); // Wide viewport - - insta::assert_snapshot!("multi_partition_chain", snapshot); + graph } -#[test] -fn viewport_visual_regression_extended_complex_dag_no_partitioning() { - let _ = env_logger::try_init(); - // Test 1: No partitioning - everything in one partition - use crate::testing::mocks::TestGraphs; - let domain_graph = TestGraphs::domain_complex_dag(); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - insta::assert_snapshot!("extended_complex_dag_no_partitioning", snapshot); -} +pub(super) fn chain_graph(node_count: usize) -> MockDomainGraph { + let mut graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..node_count).map(|_| graph.add_node(())).collect(); -#[test] -fn viewport_visual_regression_extended_complex_dag_layer_partitioning() { - let _ = env_logger::try_init(); - // Test 2: Layer-based partitioning - use crate::testing::mocks::TestGraphs; - let domain_graph = TestGraphs::domain_complex_dag(); - - let snapshot = make_snapshot(domain_graph, 80, 25, 3, usize::MAX); - insta::assert_snapshot!("extended_complex_dag_layer_partitioning", snapshot); -} + for i in 0..node_count.saturating_sub(1) { + graph.add_edge(nodes[i], nodes[i + 1], ()); + } -// This test is a good example of why we try to go for articulation points: -// By breaking up the graph between layers that each have multiple nodes -// suboptimal node orderings are encountered. This is also non-deterministic, -// hence disabling this test. -// -// Valid outcome, but ugly: -// -// █████ -// ╭───█N5██───╮ -// █████ ╭─╯ █████ │ █████ -// ╭─█N1██─│─╮ ├─█N7██─╮ -// █████ │ █████ │ ├─╮ █████ ╭─╯ █████ │ █████ █████ -// █N0██─┤ │ │ ├─█N4██─┤ ├─█N8██─█N9██ -// █████ │ █████ ├─│─╯ █████ ╰─╮ █████ │ █████ █████ -// ╰─█N2██─╯ │ ├─█N6██─╯ -// █████ │ █████ │ █████ -// ╰───█N3██───╯ -// █████ -// -// Ideal outcome: -// █████ -// ╭───█N3██───╮ -// █████ │ █████ │ █████ -// ╭─█N1██─┤ ├─█N6██─╮ -// █████ │ █████ ╰─╮ █████ ╭─╯ █████ │ █████ █████ -// █N0██─┤ ├─█N4██─┤ ├─█N8██─█N9██ -// █████ │ █████ ╭─╯ █████ ╰─╮ █████ │ █████ █████ -// ╰─█N2██─┤ ├─█N7██─╯ -// █████ │ █████ │ █████ -// ╰───█N5██───╯ -// █████ -#[test] -fn viewport_visual_regression_extended_complex_dag_node_partitioning() { - let _ = env_logger::try_init(); - // Test 3: Node-based partitioning - use crate::testing::mocks::TestGraphs; - let domain_graph = TestGraphs::domain_complex_dag(); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, 3); - insta::assert_snapshot!("extended_complex_dag_node_partitioning", snapshot); + graph } -#[test] -fn viewport_visual_regression_extended_diamond_no_partitioning() { - let _ = env_logger::try_init(); - // Test 1: No partitioning - everything in one partition - use crate::testing::mocks::TestGraphs; - let domain_graph = TestGraphs::domain_extended_diamond(); +pub(super) fn cycle_graph(node_count: usize) -> MockDomainGraph { + let mut graph = MockDomainGraph::new(); + let nodes: Vec<_> = (0..node_count).map(|_| graph.add_node(())).collect(); - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - insta::assert_snapshot!("extended_diamond_no_partitioning", snapshot); -} - -#[test] -fn viewport_visual_regression_extended_diamond_layer_partitioning() { - let _ = env_logger::try_init(); - // Test 2: Layer-based partitioning - use crate::testing::mocks::TestGraphs; - let domain_graph = TestGraphs::domain_extended_diamond(); + for i in 0..node_count { + graph.add_edge(nodes[i], nodes[(i + 1) % node_count], ()); + } - let snapshot = make_snapshot(domain_graph, 80, 25, 3, usize::MAX); - insta::assert_snapshot!("extended_diamond_layer_partitioning", snapshot); + graph } -#[test] -fn viewport_visual_regression_extended_diamond_node_partitioning() { - let _ = env_logger::try_init(); - // Test 3: Node-based partitioning - use crate::testing::mocks::TestGraphs; - let domain_graph = TestGraphs::domain_extended_diamond(); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, 3); - insta::assert_snapshot!("extended_diamond_node_partitioning", snapshot); +pub(super) fn add_edge_by_index(graph: &mut MockDomainGraph, src: usize, dst: usize) { + let nodes: Vec<_> = graph.node_indices().collect(); + graph.add_edge(nodes[src], nodes[dst], ()); } -#[test] -fn test_layer_coordinate_alignment_and_ordering() { - let _ = env_logger::try_init(); - - use crate::{ - geometry::WorldPos, - graph_controller::{GraphConfig, GraphController}, - layout::VisualDetail, - testing::mocks::{FixedNodeSizer, TestGraphs}, - }; - - // Create domain_extended_diamond with partitioning to test layer alignment - let domain_graph = TestGraphs::domain_extended_diamond(); +pub(super) fn build_controller( + domain_graph: &MockDomainGraph, + layer_count: usize, + node_count: usize, +) -> GraphController<&MockDomainGraph, FixedNodeSizer> { + init_test_logging(); let node_sizer = FixedNodeSizer { width: 5, height: 3, }; - // Use partitioning settings that will force partition creation let mut config = GraphConfig::default(); - config.partition.layer_count = 2; // Force layer-based partitioning - config.partition.node_count = 3; // Force node-based partitioning as well - - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); + config.partition.layer_count = layer_count; + config.partition.node_count = node_count; - // Set detail level first + let mut controller = GraphController::new_with_config(domain_graph, node_sizer, config); controller.set_detail_level(VisualDetail::Full); + controller.viewport_state.viewport_bounds = Rect::new(0, 0, u16::MAX / 2, u16::MAX / 2); + + controller +} + +// ----------------------------------------------------------------------------- +// Non-snapshot layout tests +// ----------------------------------------------------------------------------- + +#[test] +fn test_layer_coordinate_alignment_and_ordering() { + // Create domain_extended_diamond with partitioning to test layer alignment. + let domain_graph = TestGraphs::domain_extended_diamond(); + let mut controller = build_controller(&domain_graph, 2, 3); - // Set camera bounds to cover the unlimited viewport BEFORE creating viewport graph - controller.viewport_state.viewport_bounds = - ratatui::layout::Rect::new(0, 0, u16::MAX / 2, u16::MAX / 2); controller.viewport_state.camera_current = WorldPos::new(0, 0); controller.viewport_state.camera_target = WorldPos::new(0, 0); - // Get total partition count first to verify all are loaded + // Get total partition count first to verify all are loaded. let total_partition_count = controller .partition_controller .partition_table @@ -374,11 +110,11 @@ fn test_layer_coordinate_alignment_and_ordering() { .len(); println!("Total partitions in graph: {}", total_partition_count); - // Load all partitions by ensuring camera coverage + // Load all partitions by ensuring camera coverage. let loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); println!("Number of partitions loaded: {}", loaded_partitions.len()); - // Assert that ALL partitions were loaded, not just multiple + // Assert that ALL partitions were loaded, not just multiple. assert_eq!( loaded_partitions.len(), total_partition_count, @@ -387,7 +123,7 @@ fn test_layer_coordinate_alignment_and_ordering() { loaded_partitions.len() ); - // Rebuild the viewport graph with unlimited bounds + // Rebuild the viewport graph with unlimited bounds. let result = controller.rebuild_viewport_graph(); assert!( result.is_ok(), @@ -397,14 +133,14 @@ fn test_layer_coordinate_alignment_and_ordering() { let viewport_graph = controller.get_viewport_graph(); - // Verify we have layers + // Verify we have layers. assert!( viewport_graph.layer_count() > 0, "No layers found in viewport graph" ); println!("Viewport graph has {} layers", viewport_graph.layer_count()); - // Group nodes by layer and collect their x-coordinates + // Group nodes by layer and collect their x-coordinates. let mut layer_x_coords: Vec> = Vec::new(); for layer_idx in 0..viewport_graph.layer_count() { @@ -412,7 +148,6 @@ fn test_layer_coordinate_alignment_and_ordering() { let mut x_coords = Vec::new(); for &domain_node in layer_nodes { - // Find world position for this domain node if let Some(world_pos) = viewport_graph.node_positions.get(&domain_node) { x_coords.push(world_pos.x); } else { @@ -433,7 +168,7 @@ fn test_layer_coordinate_alignment_and_ordering() { } } - // Test 1: Verify that all nodes in each layer share the same x-coordinate + // Test 1: Verify that all nodes in each layer share the same x-coordinate. for (layer_idx, x_coords) in layer_x_coords.iter().enumerate() { if !x_coords.is_empty() { let first_x = x_coords[0]; @@ -451,11 +186,11 @@ fn test_layer_coordinate_alignment_and_ordering() { } } - // Test 2: Verify that x-coordinates are ordered between layers (increasing from layer to layer) + // Test 2: Verify that x-coordinates are ordered between layers. let layer_x_representatives: Vec = layer_x_coords .iter() .filter(|coords| !coords.is_empty()) - .map(|coords| coords[0]) // Take first (they should all be the same per layer) + .map(|coords| coords[0]) .collect(); for i in 1..layer_x_representatives.len() { @@ -476,13 +211,10 @@ fn test_layer_coordinate_alignment_and_ordering() { layer_x_representatives ); - // Additional verification: Check that we have the expected layer structure for extended diamond - // Expected structure: [0] -> [1,2] -> [3] -> [4,5] -> [6] -> [7] - // So we should have layers with node counts roughly matching this pattern + // Additional verification: check that we have the expected layer structure. let layer_sizes: Vec = layer_x_coords.iter().map(|coords| coords.len()).collect(); println!("Layer sizes: {:?}", layer_sizes); - // For extended diamond, we expect some layers to have multiple nodes (the diamond middles) let has_multi_node_layers = layer_sizes.iter().any(|&size| size > 1); assert!( has_multi_node_layers, @@ -492,194 +224,11 @@ fn test_layer_coordinate_alignment_and_ordering() { println!("Test completed successfully - layer coordinate alignment and ordering verified"); } -#[test] -fn viewport_visual_regression_bridge_position_with_variable_node_widths() { - use crate::{ - layout::VisualDetail, - plotter::NodeSizer, - testing::mocks::{MockDomainGraph, TestGraphs, TestRenderers}, - }; - - let _ = env_logger::try_init(); - - // Custom node sizer with dramatically different widths for middle layer - #[derive(Debug, Clone)] - struct VariableWidthSizer; - - impl NodeSizer for VariableWidthSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - _scale: VisualDetail, - ) -> (u64, u64) { - match node.index() { - 0 => (4, 1), // Start node: medium width - 1 => (15, 2), // Left middle node: very wide - 2 => (2, 1), // Right middle node: very narrow - 3 => (5, 1), // End node: medium width - _ => (3, 1), // Default - } - } - - fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) - } - } - - // Also implement for reference type - impl NodeSizer<&MockDomainGraph> for VariableWidthSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - _scale: VisualDetail, - ) -> (u64, u64) { - match node.index() { - 0 => (4, 1), // Start node: medium width - 1 => (15, 2), // Left middle node: very wide - 2 => (2, 1), // Right middle node: very narrow - 3 => (5, 1), // End node: medium width - _ => (3, 1), // Default - } - } - - fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) - } - } - - let node_sizer = VariableWidthSizer; - let renderer = TestRenderers::debug(); - - let snapshot = make_snapshot_custom( - TestGraphs::domain_diamond(), - 80, - 25, - 2, - 3, - node_sizer, - renderer, - ); - - insta::assert_snapshot!("bridge_position_variable_widths", snapshot); -} - -#[test] -fn test_skip_layer() { - let _ = env_logger::try_init(); - use crate::testing::mocks::TestGraphs; - - let domain_graph = TestGraphs::domain_skip_layer(); - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - - insta::assert_snapshot!("skip_layer", snapshot); -} - -#[test] -fn test_skip_layer_partition_boundary() { - let _ = env_logger::try_init(); - use crate::testing::mocks::TestGraphs; - - let domain_graph = TestGraphs::domain_skip_layer(); - let snapshot = make_snapshot(domain_graph, 80, 25, 2, usize::MAX); - - insta::assert_snapshot!("skip_layer_partition_boundary", snapshot); -} - -#[test] -fn viewport_chain_three_partitions_spanning_edge() { - let _ = env_logger::try_init(); - // Create a chain graph divided into 3 partitions on layer basis - // with one edge completely spanning the middle partition. - // - // Graph structure with layers: - // Layer 0: [0] - // Layer 1: [1] - // Layer 2: [2] - // Layer 3: [3] - // Layer 4: [4] - // Layer 5: [5] - // - // Partitions (layer_count=2): - // Partition 0: Layers 0-1 (nodes 0, 1) - // Partition 1 (middle): Layers 2-3 (nodes 2, 3) - // Partition 2: Layers 4-5 (nodes 4, 5) - // - // Regular chain edges: 0->1->2->3->4->5 - // Spanning edge: 1->4 (spans middle partition completely) - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..6).map(|_| domain_graph.add_node(())).collect(); - - // Create the chain - for i in 0..5 { - domain_graph.add_edge(nodes[i], nodes[i + 1], ()); - } - - // Add the spanning edge that completely skips the middle partition - // Node 1 is in partition 0 (layer 1), node 4 is in partition 2 (layer 4) - domain_graph.add_edge(nodes[1], nodes[4], ()); - - // Use layer_count=2 to create 3 partitions from 6 layers - // This creates partition boundaries at layers 2 and 4 - let snapshot = make_snapshot(domain_graph, 100, 30, 2, usize::MAX); - - insta::assert_snapshot!("chain_three_partitions_spanning_edge", snapshot); -} - -#[test] -fn viewport_chain_five_partitions_long_spanning_edge() { - let _ = env_logger::try_init(); - // Create a longer chain graph divided into 5 partitions (3 data + 2 bridge) - // with one edge completely spanning the middle partitions. - // - // With node_count=5 and the spanning edge 1->8, we get: - // Partition 0: 6 nodes (0-5) - Data partition - // Partition 1: 0 nodes - Bridge partition for edges crossing from 0 to 2 - // Partition 2: 5 nodes (4-8) - Data partition - // Partition 3: 0 nodes - Bridge partition for edges crossing from 2 to 4 - // Partition 4: 3 nodes (7-9) - Data partition - // - // Regular chain edges: 0->1->2->3->4->5->6->7->8->9 - // Long spanning edge: 1->8 (spans bridge partitions 1 and 3, and data partition 2) - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..10).map(|_| domain_graph.add_node(())).collect(); - - // Create the chain - for i in 0..9 { - domain_graph.add_edge(nodes[i], nodes[i + 1], ()); - } - - // Add the spanning edge that completely skips partitions 1, 2, and 3 - // Node 1 is in partition 0 (layer 1), node 8 is in partition 4 (layer 8) - domain_graph.add_edge(nodes[1], nodes[8], ()); - - // Note: if you cut off the partitions using node_count=5 the test will fail due to - // a visual artefact, which is concession made when using node_count to create the cut. - // Topologically, the graph was still correct. - let snapshot = make_snapshot(domain_graph, 120, 35, 2, usize::MAX); - - insta::assert_snapshot!("chain_five_partitions_long_spanning_edge", snapshot); -} - #[test] fn viewport_chain_five_partitions_verify_partition_count() { - let _ = env_logger::try_init(); - use crate::{ - geometry::WorldPos, - graph_algorithms::find_articulation_points, - graph_controller::{GraphConfig, GraphController}, - layout::VisualDetail, - testing::mocks::FixedNodeSizer, - }; - - // Create the same chain graph as above - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..10).map(|_| domain_graph.add_node(())).collect(); - for i in 0..9 { - domain_graph.add_edge(nodes[i], nodes[i + 1], ()); - } - domain_graph.add_edge(nodes[1], nodes[8], ()); + let mut domain_graph = chain_graph(10); + add_edge_by_index(&mut domain_graph, 1, 8); - // Check articulation points let articulation_points = find_articulation_points(&domain_graph); println!( "Articulation points in 10-node chain: {:?}", @@ -690,27 +239,10 @@ fn viewport_chain_five_partitions_verify_partition_count() { articulation_points.len() ); - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - - // Configure for partitioning - use node_count to control partition size - // With 10 nodes and node_count=5, we get 3 data partitions (6 + 5 + 3 nodes) + 2 bridge partitions - let mut config = GraphConfig::default(); - config.partition.layer_count = 2; - config.partition.node_count = 5; // Allow up to 5 nodes per partition - - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - controller.set_detail_level(VisualDetail::Full); - - // Set viewport to cover everything - controller.viewport_state.viewport_bounds = - ratatui::layout::Rect::new(0, 0, u16::MAX / 2, u16::MAX / 2); + let mut controller = build_controller(&domain_graph, 2, 5); controller.viewport_state.camera_current = WorldPos::new(0, 0); controller.viewport_state.camera_target = WorldPos::new(0, 0); - // Check how many partitions are actually created let total_partition_count = controller .partition_controller .partition_table @@ -718,14 +250,12 @@ fn viewport_chain_five_partitions_verify_partition_count() { .len(); println!("Total partitions created: {}", total_partition_count); - // Verify we have 5 partitions total (3 data + 2 bridge partitions) assert_eq!( total_partition_count, 5, "Expected 5 partitions (3 data + 2 bridge), but found {}", total_partition_count ); - // Load all partitions let loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); println!("Partitions loaded: {}", loaded_partitions.len()); assert_eq!( @@ -734,7 +264,6 @@ fn viewport_chain_five_partitions_verify_partition_count() { "Expected all partitions to be loaded" ); - // Print partition details and verify spanning edge crosses multiple partitions let mut data_partition_count = 0; for (i, partition) in controller .partition_controller @@ -755,8 +284,6 @@ fn viewport_chain_five_partitions_verify_partition_count() { total_partition_count - data_partition_count ); - // The spanning edge 1->8 should cross partitions 1, 2, and 3 - // Node 1 is in partition 0, node 8 is in partition 4 println!( "✓ Confirmed: 5 partitions created (3 data + 2 bridge), spanning edge crosses middle partitions" ); @@ -764,35 +291,12 @@ fn viewport_chain_five_partitions_verify_partition_count() { #[test] fn test_skip_layer_terminal_stitch_edge_bundles() { - let _ = env_logger::try_init(); - - use crate::{ - geometry::WorldPos, - graph_controller::{GraphConfig, GraphController}, - layout::VisualDetail, - testing::mocks::{FixedNodeSizer, TestGraphs}, - }; - let domain_graph = TestGraphs::domain_skip_layer(); + let mut controller = build_controller(&domain_graph, 2, 3); - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - - let mut config = GraphConfig::default(); - config.partition.layer_count = 2; - config.partition.node_count = 3; - - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - - controller.set_detail_level(VisualDetail::Full); - - controller.viewport_state.viewport_bounds = - ratatui::layout::Rect::new(0, 0, u16::MAX / 2, u16::MAX / 2); - controller.initialize_cursor(); controller.viewport_state.camera_current = WorldPos::new(0, 0); controller.viewport_state.camera_target = WorldPos::new(0, 0); + controller.initialize_cursor(); let result = controller.rebuild_viewport_graph(); assert!( @@ -803,7 +307,6 @@ fn test_skip_layer_terminal_stitch_edge_bundles() { let viewport_graph = controller.get_viewport_graph(); - // Count edges that lack bundles by iterating through the viewport graph let mut edges_without_bundles = 0; let mut total_edges = 0; @@ -811,7 +314,6 @@ fn test_skip_layer_terminal_stitch_edge_bundles() { total_edges += 1; let (source, target, edge_data) = edge; - // Check if this edge has an empty bundle (indicating it's from terminal stitch nodes) if edge_data.is_empty() { edges_without_bundles += 1; println!( @@ -824,8 +326,6 @@ fn test_skip_layer_terminal_stitch_edge_bundles() { println!("Total edges in viewport graph: {}", total_edges); println!("Edges without bundles: {}", edges_without_bundles); - // After filtering out edges with empty bundles in viewport_graph.rs, - // there should be 0 edges without bundles in the viewport graph assert_eq!( edges_without_bundles, 0, "Expected 0 edges without bundles (they should be filtered out at viewport graph creation), but found {}", @@ -837,146 +337,43 @@ fn test_skip_layer_terminal_stitch_edge_bundles() { ); } -#[test] -fn viewport_even_width_node_spacing() { - let _ = env_logger::try_init(); - // Test that even-width nodes have proper spacing for edge routing - use crate::{ - layout::VisualDetail, - plotter::NodeSizer, - testing::mocks::{MockDomainGraph, TestRenderers}, - }; - - // Create a simple diamond graph - let mut domain_graph = MockDomainGraph::new(); - let node_0 = domain_graph.add_node(()); - let node_1 = domain_graph.add_node(()); - let node_2 = domain_graph.add_node(()); - let node_3 = domain_graph.add_node(()); - domain_graph.add_edge(node_0, node_1, ()); - domain_graph.add_edge(node_0, node_2, ()); - domain_graph.add_edge(node_1, node_3, ()); - domain_graph.add_edge(node_2, node_3, ()); - - // Custom node sizer to test asymmetric extent handling - #[derive(Debug, Clone)] - struct OddEvenSizer; - - impl NodeSizer for OddEvenSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - _scale: VisualDetail, - ) -> (u64, u64) { - match node.index() { - 0 => (3, 1), - 1 => (6, 1), - 2 => (8, 1), - 3 => (9, 1), - _ => (4, 1), - } - } - - fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) - } - } - - // Implement for reference type - impl NodeSizer<&MockDomainGraph> for OddEvenSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - _scale: VisualDetail, - ) -> (u64, u64) { - match node.index() { - 0 => (4, 1), - 1 => (6, 1), - 2 => (6, 1), - 3 => (8, 1), - _ => (4, 1), - } - } - - fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) - } - } - - let node_sizer = OddEvenSizer; - let renderer = TestRenderers::debug(); - - let snapshot = make_snapshot_custom( - domain_graph, - 80, - 25, - usize::MAX, - usize::MAX, - node_sizer, - renderer, - ); - - insta::assert_snapshot!("even_width_nodes", snapshot); -} - #[test] fn test_edge_viewport_intersection_endpoints_outside() { - let _ = env_logger::try_init(); - - use petgraph::{ - stable_graph::StableDiGraph, - visit::{EdgeRef, IntoEdgeReferences}, - }; - - use crate::{ - geometry::BigRect, - layout::{LayoutEngine, VisualDetail}, - partition::{PartitionEdge, PartitionNode}, - testing::mocks::FixedNodeSizer, - }; + init_test_logging(); // Create a larger partition graph with multiple nodes to ensure - // there's enough distance between the endpoints + // there's enough distance between the endpoints. let mut partition_graph = StableDiGraph::::new(); - // Create a chain: 0 -> 1 -> 2 -> 3 -> 4 - // This will space out the nodes significantly + // Create a chain: 0 -> 1 -> 2 -> 3 -> 4. let nodes: Vec<_> = (0..5) - .map(|i| partition_graph.add_node(PartitionNode::Data(petgraph::graph::NodeIndex::new(i)))) + .map(|i| partition_graph.add_node(PartitionNode::Data(NodeIndex::new(i)))) .collect(); for i in 0..4 { partition_graph.add_edge( nodes[i], nodes[i + 1], - Some(( - petgraph::graph::NodeIndex::new(i), - petgraph::graph::NodeIndex::new(i + 1), - )), + Some((NodeIndex::new(i), NodeIndex::new(i + 1))), ); } - // Create a layout engine let mut layout_engine = LayoutEngine::new(&partition_graph, 0); - - // Use small node sizes to ensure clear separation let node_sizer = FixedNodeSizer { width: 2, height: 2, }; - // Compute the layout let layout = layout_engine .compute_layout(&node_sizer, VisualDetail::Full) .expect("Failed to compute layout"); - // Get the positions of the first and last data nodes let node0_pos = layout .graph .node_indices() .find_map(|idx| { let node = layout.graph.node_weight(idx)?; - if matches!(node.role, crate::layout::NodeRole::Data(n) if n.index() == 0) { + if matches!(node.role, NodeRole::Data(n) if n.index() == 0) { Some(node.pos) } else { None @@ -989,7 +386,7 @@ fn test_edge_viewport_intersection_endpoints_outside() { .node_indices() .find_map(|idx| { let node = layout.graph.node_weight(idx)?; - if matches!(node.role, crate::layout::NodeRole::Data(n) if n.index() == 4) { + if matches!(node.role, NodeRole::Data(n) if n.index() == 4) { Some(node.pos) } else { None @@ -1000,12 +397,9 @@ fn test_edge_viewport_intersection_endpoints_outside() { println!("Node 0 position: {:?}", node0_pos); println!("Node 4 position: {:?}", node4_pos); - // Create a small viewport in the middle of the graph - // Position it between nodes 1 and 3 to ensure it doesn't overlap with node 0 or 4 let viewport_center_x = (node0_pos.x + node4_pos.x) / 2; let viewport_center_y = (node0_pos.y + node4_pos.y) / 2; - // Make viewport very small - just 2x2 cells let viewport = BigRect::from_coords( viewport_center_x - 1, viewport_center_y - 1, @@ -1015,25 +409,17 @@ fn test_edge_viewport_intersection_endpoints_outside() { println!("Viewport: {:?}", viewport); - // Count data nodes in the viewport (excluding routing nodes) let data_nodes_in_viewport = layout .find_nodes_in_rect(viewport) .iter() - .filter(|obj| { - matches!( - obj.object_type, - crate::geometry::SpatialObjectType::DataNode(_) - ) - }) + .filter(|obj| matches!(obj.object_type, SpatialObjectType::DataNode(_))) .count(); println!("Data nodes in viewport: {}", data_nodes_in_viewport); - // Query for edges in the viewport let edges_in_viewport = layout.find_edges_in_rect(viewport); println!("Edges in viewport: {}", edges_in_viewport.len()); - // Debug: print all edges and their bounding boxes for (idx, edge_ref) in layout.graph.edge_references().enumerate() { let source = edge_ref.source(); let target = edge_ref.target(); @@ -1045,9 +431,6 @@ fn test_edge_viewport_intersection_endpoints_outside() { ); } - // The viewport should be in the middle of the chain, containing node 2 (the middle node) - // but not nodes 0 or 4 (the endpoints). There should be edges crossing through it - // from routing nodes connecting the chain segments. assert!( !edges_in_viewport.is_empty(), "Expected to find edges crossing through viewport, but found none. \ @@ -1063,61 +446,34 @@ fn test_edge_viewport_intersection_endpoints_outside() { #[test] fn test_diagonal_edge_viewport_intersection() { - let _ = env_logger::try_init(); - - use petgraph::stable_graph::StableDiGraph; + init_test_logging(); - use crate::{ - geometry::BigRect, - layout::{LayoutEngine, VisualDetail}, - partition::{PartitionEdge, PartitionNode}, - testing::mocks::FixedNodeSizer, - }; - - // Create a diamond graph to test diagonal edges - // Structure: 0 - // / \ - // 1 2 - // \ / - // 3 + // Create a diamond graph to test diagonal edges. let mut partition_graph = StableDiGraph::::new(); let nodes: Vec<_> = (0..4) - .map(|i| partition_graph.add_node(PartitionNode::Data(petgraph::graph::NodeIndex::new(i)))) + .map(|i| partition_graph.add_node(PartitionNode::Data(NodeIndex::new(i)))) .collect(); - // Add diamond edges partition_graph.add_edge( nodes[0], nodes[1], - Some(( - petgraph::graph::NodeIndex::new(0), - petgraph::graph::NodeIndex::new(1), - )), + Some((NodeIndex::new(0), NodeIndex::new(1))), ); partition_graph.add_edge( nodes[0], nodes[2], - Some(( - petgraph::graph::NodeIndex::new(0), - petgraph::graph::NodeIndex::new(2), - )), + Some((NodeIndex::new(0), NodeIndex::new(2))), ); partition_graph.add_edge( nodes[1], nodes[3], - Some(( - petgraph::graph::NodeIndex::new(1), - petgraph::graph::NodeIndex::new(3), - )), + Some((NodeIndex::new(1), NodeIndex::new(3))), ); partition_graph.add_edge( nodes[2], nodes[3], - Some(( - petgraph::graph::NodeIndex::new(2), - petgraph::graph::NodeIndex::new(3), - )), + Some((NodeIndex::new(2), NodeIndex::new(3))), ); let mut layout_engine = LayoutEngine::new(&partition_graph, 0); @@ -1130,15 +486,13 @@ fn test_diagonal_edge_viewport_intersection() { .compute_layout(&node_sizer, VisualDetail::Full) .expect("Failed to compute layout"); - // Find node positions let find_node = |node_index: usize| { layout .graph .node_indices() .find_map(|idx| { let node = layout.graph.node_weight(idx)?; - if matches!(node.role, crate::layout::NodeRole::Data(n) if n.index() == node_index) - { + if matches!(node.role, NodeRole::Data(n) if n.index() == node_index) { Some(node.pos) } else { None @@ -1157,33 +511,21 @@ fn test_diagonal_edge_viewport_intersection() { println!("Node 2 (right): {:?}", node2_pos); println!("Node 3 (bottom): {:?}", node3_pos); - // Create a small viewport positioned in the center of the diamond - // This should not contain any of the 4 corner nodes, but should - // intersect the diagonal routing edges between them let center_x = (node0_pos.x + node3_pos.x) / 2; let center_y = (node1_pos.y + node2_pos.y) / 2; - // Make a small viewport around the center - expand by 2 units in each direction let viewport = BigRect::from_coords(center_x - 2, center_y - 2, center_x + 2, center_y + 2); println!("Viewport (center region): {:?}", viewport); - // Query for nodes - we expect none of the data nodes to be in this tiny viewport let data_nodes_in_viewport = layout .find_nodes_in_rect(viewport) .iter() - .filter(|obj| { - matches!( - obj.object_type, - crate::geometry::SpatialObjectType::DataNode(_) - ) - }) + .filter(|obj| matches!(obj.object_type, SpatialObjectType::DataNode(_))) .count(); println!("Data nodes in viewport: {}", data_nodes_in_viewport); - // Debug: Print all edges and their positions - use petgraph::visit::{EdgeRef, IntoEdgeReferences}; println!("\nAll edges in layout:"); for edge_ref in layout.graph.edge_references() { let source = edge_ref.source(); @@ -1207,12 +549,9 @@ fn test_diagonal_edge_viewport_intersection() { ); } - // Query for edges - we expect to find routing edges that cross through this center point let edges_in_viewport = layout.find_edges_in_rect(viewport); println!("\nEdges found in viewport: {}", edges_in_viewport.len()); - // The spatial indexing should capture edges that cross through the viewport - // even if both endpoints are outside assert!( !edges_in_viewport.is_empty(), "Expected to find edges crossing through the center of the diamond, but found none. \ @@ -1226,334 +565,18 @@ fn test_diagonal_edge_viewport_intersection() { ); } -/// Test for determinism by running the same layout multiple times and comparing snapshots. -/// This test uses the complex_dag graph with node-based partitioning (which forces bridge creation) -/// to ensure that HashMap/HashSet iterations produce consistent results. -#[test] -fn test_layout_determinism_with_partitioning() { - let _ = env_logger::try_init(); - use crate::testing::mocks::TestGraphs; - - // Generate the same layout 10 times - let num_iterations = 10; - let mut snapshots = Vec::new(); - - for i in 0..num_iterations { - // Clone the graph for each iteration since make_snapshot takes ownership - let graph = TestGraphs::domain_complex_dag(); - let snapshot = make_snapshot(graph, 80, 25, usize::MAX, 3); - snapshots.push(snapshot); - log::trace!("Generated snapshot {} for determinism test", i); - } - - // All snapshots should be identical - let first = &snapshots[0]; - for (i, snapshot) in snapshots.iter().enumerate().skip(1) { - assert_eq!( - first, snapshot, - "Layout iteration {} produced different output than iteration 0. \ - This indicates non-determinism in the layout algorithm. \ - The difference suggests HashMap or HashSet iteration order is affecting the result.", - i - ); - } - - // Also verify against the stored snapshot to ensure the output is correct - insta::assert_snapshot!("determinism_check_complex_dag_node_partitioning", first); -} - -/// Proves crossing-reduction tie handling is deterministic by constructing the same symmetric -/// graph twice, but inserting edges in a different order (which affects DFS-based init order). -/// With the tiebreaker in `gen-sugiyama`, these should render identically. -#[test] -fn test_layout_determinism_across_edge_insertion_order_symmetric_fan() { - let _ = env_logger::try_init(); - - // Graph: - // node_0 -> {node_1, node_2, node_3} -> node_4 - // The three middle nodes are perfectly symmetric, so their barycenters tie. - // Without a deterministic tiebreaker, the middle layer can preserve the DFS visit order. - - let mut graph_1 = MockDomainGraph::new(); - let node_0 = graph_1.add_node(()); - let node_1 = graph_1.add_node(()); - let node_2 = graph_1.add_node(()); - let node_3 = graph_1.add_node(()); - let node_4 = graph_1.add_node(()); - graph_1.add_edge(node_0, node_1, ()); - graph_1.add_edge(node_0, node_2, ()); - graph_1.add_edge(node_0, node_3, ()); - graph_1.add_edge(node_1, node_4, ()); - graph_1.add_edge(node_2, node_4, ()); - graph_1.add_edge(node_3, node_4, ()); - - let mut graph_2 = MockDomainGraph::new(); - let node_0 = graph_2.add_node(()); - let node_1 = graph_2.add_node(()); - let node_2 = graph_2.add_node(()); - let node_3 = graph_2.add_node(()); - let node_4 = graph_2.add_node(()); - // Same edges, different insertion order. - graph_2.add_edge(node_0, node_3, ()); - graph_2.add_edge(node_0, node_1, ()); - graph_2.add_edge(node_0, node_2, ()); - graph_2.add_edge(node_3, node_4, ()); - graph_2.add_edge(node_1, node_4, ()); - graph_2.add_edge(node_2, node_4, ()); - - let snapshot1 = make_snapshot(graph_1, 80, 25, usize::MAX, usize::MAX); - let snapshot2 = make_snapshot(graph_2, 80, 25, usize::MAX, usize::MAX); - - assert_eq!( - snapshot1, snapshot2, - "Symmetric fan layout should be identical regardless of edge insertion order" - ); -} - -#[test] -fn test_double_chain() { - let _ = env_logger::try_init(); - - // Create a graph with 18 nodes arranged in two chains with a common start and stop node. - // Chain 1: node_1 -> node_2 -> node_3 -> node_4 -> node_5 -> node_6 -> node_7 -> node_8 -> node_9 -> node_10 (10 nodes) - // Chain 2: node_1 -> node_12 -> node_13 -> node_14 -> node_15 -> node_16 -> node_17 -> node_18 -> node_19 -> node_10 (10 nodes) - // Shared nodes: node_1 (start), node_10 (stop) - // Total unique nodes: 18 - - let mut domain_graph = MockDomainGraph::new(); - - // Add all nodes (indices 0-17 correspond to node_1-node_10 and node_12-node_19) - // Using index mapping: - // 0 -> node_1 (shared start) - // 1 -> node_2 - // 2 -> node_3 - // 3 -> node_4 - // 4 -> node_5 - // 5 -> node_6 - // 6 -> node_7 - // 7 -> node_8 - // 8 -> node_9 - // 9 -> node_10 (shared stop) - // 10 -> node_12 - // 11 -> node_13 - // 12 -> node_14 - // 13 -> node_15 - // 14 -> node_16 - // 15 -> node_17 - // 16 -> node_18 - // 17 -> node_19 - let nodes: Vec<_> = (0..18).map(|_| domain_graph.add_node(())).collect(); - - // Chain 1: node_1(0) -> node_2(1) -> node_3(2) -> node_4(3) -> node_5(4) -> node_6(5) -> node_7(6) -> node_8(7) -> node_9(8) -> node_10(9) - for i in 0..9 { - domain_graph.add_edge(nodes[i], nodes[i + 1], ()); - } - - // Chain 2: node_1(0) -> node_12(10) -> node_13(11) -> node_14(12) -> node_15(13) -> node_16(14) -> node_17(15) -> node_18(16) -> node_19(17) -> node_10(9) - domain_graph.add_edge(nodes[0], nodes[10], ()); - for i in 10..17 { - domain_graph.add_edge(nodes[i], nodes[i + 1], ()); - } - domain_graph.add_edge(nodes[17], nodes[9], ()); - - let snapshot = make_snapshot(domain_graph, 120, 40, 5, 20); - - insta::assert_snapshot!("double_chain", snapshot); -} - -#[test] -fn test_asymmetric_diamond() { - let _ = env_logger::try_init(); - - // Create an asymmetric diamond graph: - // A-B-C-D-E - // \ / - // --F-- - // - // Edges: - // A -> B, B -> C, C -> D, D -> E (main chain) - // A -> F, F -> E (bypass through single intermediate node) - - let mut domain_graph = MockDomainGraph::new(); - let node_a = domain_graph.add_node(()); - let node_b = domain_graph.add_node(()); - let node_c = domain_graph.add_node(()); - let node_d = domain_graph.add_node(()); - let node_e = domain_graph.add_node(()); - let node_f = domain_graph.add_node(()); - - // Main chain: A -> B -> C -> D -> E - domain_graph.add_edge(node_a, node_b, ()); - domain_graph.add_edge(node_b, node_c, ()); - domain_graph.add_edge(node_c, node_d, ()); - domain_graph.add_edge(node_d, node_e, ()); - - // Bypass: A -> F -> E - domain_graph.add_edge(node_a, node_f, ()); - domain_graph.add_edge(node_f, node_e, ()); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - - insta::assert_snapshot!("asymmetric_diamond", snapshot); -} - -#[test] -fn test_asymmetric_diamond_2_1() { - let _ = env_logger::try_init(); - - let mut domain_graph = MockDomainGraph::new(); - let node_a = domain_graph.add_node(()); - let node_b = domain_graph.add_node(()); - let node_c = domain_graph.add_node(()); - let node_d = domain_graph.add_node(()); - let node_e = domain_graph.add_node(()); - - domain_graph.add_edge(node_a, node_b, ()); - domain_graph.add_edge(node_b, node_c, ()); - domain_graph.add_edge(node_c, node_d, ()); - - domain_graph.add_edge(node_a, node_e, ()); - domain_graph.add_edge(node_e, node_d, ()); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - - insta::assert_snapshot!("asymmetric_diamond_2_1", snapshot); -} - -#[test] -fn test_asymmetric_diamond_4_1() { - let _ = env_logger::try_init(); - - // Longer leg: 4 intermediate nodes (6 total), shorter leg: 1 intermediate (2 total) - // A -> B -> C -> D -> E -> F - // A -> G -> E - - let mut domain_graph = MockDomainGraph::new(); - let node_a = domain_graph.add_node(()); - let node_b = domain_graph.add_node(()); - let node_c = domain_graph.add_node(()); - let node_d = domain_graph.add_node(()); - let node_e = domain_graph.add_node(()); - let node_f = domain_graph.add_node(()); - let node_g = domain_graph.add_node(()); - - domain_graph.add_edge(node_a, node_b, ()); - domain_graph.add_edge(node_b, node_c, ()); - domain_graph.add_edge(node_c, node_d, ()); - domain_graph.add_edge(node_d, node_e, ()); - domain_graph.add_edge(node_e, node_f, ()); - - domain_graph.add_edge(node_a, node_g, ()); - domain_graph.add_edge(node_g, node_f, ()); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - - insta::assert_snapshot!("asymmetric_diamond_4_1", snapshot); -} - -#[test] -fn test_asymmetric_diamond_3_2() { - let _ = env_logger::try_init(); - - // A -> B -> C -> D -> E - // A -> F1 -> F2 -> E - - let mut domain_graph = MockDomainGraph::new(); - let node_a = domain_graph.add_node(()); - let node_b = domain_graph.add_node(()); - let node_c = domain_graph.add_node(()); - let node_d = domain_graph.add_node(()); - let node_e = domain_graph.add_node(()); - let node_f1 = domain_graph.add_node(()); - let node_f2 = domain_graph.add_node(()); - - domain_graph.add_edge(node_a, node_b, ()); - domain_graph.add_edge(node_b, node_c, ()); - domain_graph.add_edge(node_c, node_d, ()); - domain_graph.add_edge(node_d, node_e, ()); - - domain_graph.add_edge(node_a, node_f1, ()); - domain_graph.add_edge(node_f1, node_f2, ()); - domain_graph.add_edge(node_f2, node_e, ()); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - - insta::assert_snapshot!("asymmetric_diamond_3_2", snapshot); -} - -#[test] -fn test_asymmetric_diamond_4_2() { - let _ = env_logger::try_init(); - - // A -> B -> C -> D -> E -> F - // A -> X -> Y -> F - - let mut domain_graph = MockDomainGraph::new(); - let node_a = domain_graph.add_node(()); - let node_b = domain_graph.add_node(()); - let node_c = domain_graph.add_node(()); - let node_d = domain_graph.add_node(()); - let node_e = domain_graph.add_node(()); - let node_f = domain_graph.add_node(()); - let node_x = domain_graph.add_node(()); - let node_y = domain_graph.add_node(()); - - domain_graph.add_edge(node_a, node_b, ()); - domain_graph.add_edge(node_b, node_c, ()); - domain_graph.add_edge(node_c, node_d, ()); - domain_graph.add_edge(node_d, node_e, ()); - domain_graph.add_edge(node_e, node_f, ()); - - domain_graph.add_edge(node_a, node_x, ()); - domain_graph.add_edge(node_x, node_y, ()); - domain_graph.add_edge(node_y, node_f, ()); - - let snapshot = make_snapshot(domain_graph, 80, 25, usize::MAX, usize::MAX); - - insta::assert_snapshot!("asymmetric_diamond_4_2", snapshot); -} - /// Test rendering and positioning of very large nodes during zoom operations. -/// -/// This test verifies that nodes with extreme widths (1000+ characters) are rendered -/// correctly without coordinate overflow issues. The test zooms through different -/// detail levels and ensures that: -/// 1. Large nodes don't cause coordinate wraparound or positioning errors -/// 2. Spatial relationships between nodes are maintained (left node < right node) -/// 3. Cursor positioning remains stable during zoom operations -/// -/// This test specifically addresses coordinate overflow bugs where very wide nodes -/// could exceed u16::MAX coordinates and cause rendering artifacts. #[test] fn test_large_node_rendering_with_zoom() { - let _ = env_logger::try_init(); - - use crate::{ - geometry::WorldRect, - graph_controller::{GraphConfig, GraphController, WorldBuffer}, - layout::VisualDetail, - plotter::{NodeRenderer, NodeSizer}, - testing::{create_test_terminal, mocks::MockDomainGraph}, - }; + init_test_logging(); - // 1. Create a 3-node chain graph: 0 -> 1 -> 2 - let mut domain_graph = MockDomainGraph::new(); - let node_0 = domain_graph.add_node(()); - let node_1 = domain_graph.add_node(()); - let node_2 = domain_graph.add_node(()); - domain_graph.add_edge(node_0, node_1, ()); - domain_graph.add_edge(node_1, node_2, ()); + let domain_graph = graph_from_edges(3, &[(0, 1), (1, 2)]); - // 2. Custom NodeSizer with adjustable node length #[derive(Debug, Clone)] struct VariableDetailSizer; impl NodeSizer for VariableDetailSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - scale: VisualDetail, - ) -> (u64, u64) { + fn get_node_size(&self, node: &NodeIndex, scale: VisualDetail) -> (u64, u64) { match scale { VisualDetail::Minimal => (1, 1), VisualDetail::Truncated => (10, 1), @@ -1572,25 +595,12 @@ fn test_large_node_rendering_with_zoom() { } impl NodeSizer<&MockDomainGraph> for VariableDetailSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - scale: VisualDetail, - ) -> (u64, u64) { - match scale { - VisualDetail::Minimal => (1, 1), - VisualDetail::Truncated => (10, 1), - VisualDetail::Full => match node.index() { - 0 => (5, 1), - 1 => (1000, 1), - 2 => (5, 1), - _ => (1, 1), - }, - } + fn get_node_size(&self, node: &NodeIndex, scale: VisualDetail) -> (u64, u64) { + >::get_node_size(self, node, scale) } fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) + >::get_dummy_size(self) } } @@ -1602,26 +612,20 @@ fn test_large_node_rendering_with_zoom() { &mut self, buffer: &mut WorldBuffer, area: WorldRect, - node_id: &petgraph::stable_graph::NodeIndex, + node_id: &NodeIndex, _scale: VisualDetail, ) { - // Viewport-aware rendering: only render the visible portion of large nodes - // This is critical for performance with very large nodes (1000+ width) - let Some(visible_area) = buffer.calculate_visible_area(area) else { - // Node is completely outside viewport - don't render anything return; }; let symbol = format!("{}", node_id.index()).chars().next().unwrap(); - // Only render the visible portion for y in visible_area.min.y..=visible_area.max.y { - // Calculate the visible width for this row let visible_width = (visible_area.max.x - visible_area.min.x + 1) as usize; let content = symbol.to_string().repeat(visible_width); - let start_pos = crate::geometry::WorldPos::new(visible_area.min.x, y); + let start_pos = WorldPos::new(visible_area.min.x, y); buffer.set_string(start_pos, &content); } } @@ -1630,6 +634,7 @@ fn test_large_node_rendering_with_zoom() { let viewport_width = 80; let viewport_height = 20; let mut terminal = create_test_terminal(viewport_width, viewport_height); + let mut config = GraphConfig::default(); config.partition.layer_count = usize::MAX; config.partition.node_count = usize::MAX; @@ -1637,30 +642,30 @@ fn test_large_node_rendering_with_zoom() { let mut controller = GraphController::new_with_config(&domain_graph, VariableDetailSizer, config); - // 4. Starts in minimal level-of-detail controller.set_detail_level(VisualDetail::Minimal); - - // 5. Cursor setup (not visible in the snapshots though) controller.show_cursor(); controller.initialize_cursor(); + + let node_indices: Vec<_> = domain_graph.node_indices().collect(); + let node_0 = node_indices[0]; + let node_1 = node_indices[1]; + controller.cursor.set_node(node_0, (0.0, 0.0)); - // Set cursor to the center of the viewport let vp_center_x = viewport_width / 2; let vp_center_y = viewport_height / 2; - let initial_cursor_viewport_pos = crate::geometry::ViewportPos::new(vp_center_x, vp_center_y); + let initial_cursor_viewport_pos = ViewportPos::new(vp_center_x, vp_center_y); controller .cursor .set_viewport_pos(initial_cursor_viewport_pos); let renderer = UltrawideRenderer; - // 6. Snapshot 1: Minimal detail level let _ = terminal.draw(|f| { let area = f.area(); controller.viewport_state.viewport_bounds = area; - let widget = crate::graph_widget::GraphWidget::with_renderer(renderer.clone()) + let widget = GraphWidget::with_renderer(renderer.clone()) .detail_level(VisualDetail::Minimal) .cursor(); f.render_stateful_widget(widget, area, &mut controller); @@ -1668,18 +673,15 @@ fn test_large_node_rendering_with_zoom() { let minimal_snapshot = format!("{}", terminal.backend()); insta::assert_snapshot!("variable_detail_chain_minimal", minimal_snapshot); - // 7. Simulate hitting '+' to zoom in (goes to Truncated) - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let plus_key = KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE); controller.handle_key_event(plus_key).unwrap(); controller.trigger_rebuild(); - // Snapshot 2: Truncated detail level let _ = terminal.draw(|f| { let area = f.area(); controller.viewport_state.viewport_bounds = area; - let widget = crate::graph_widget::GraphWidget::with_renderer(renderer.clone()) + let widget = GraphWidget::with_renderer(renderer.clone()) .detail_level(controller.get_detail_level()) .cursor(); f.render_stateful_widget(widget, area, &mut controller); @@ -1687,16 +689,14 @@ fn test_large_node_rendering_with_zoom() { let truncated_snapshot = format!("{}", terminal.backend()); insta::assert_snapshot!("variable_detail_chain_truncated", truncated_snapshot); - // 8. Simulate hitting '+' again to zoom in (goes to Full) controller.handle_key_event(plus_key).unwrap(); controller.trigger_rebuild(); - // Snapshot 3: Full detail level let _ = terminal.draw(|f| { let area = f.area(); controller.viewport_state.viewport_bounds = area; - let widget = crate::graph_widget::GraphWidget::with_renderer(renderer.clone()) + let widget = GraphWidget::with_renderer(renderer.clone()) .detail_level(controller.get_detail_level()) .cursor(); f.render_stateful_widget(widget, area, &mut controller); @@ -1704,8 +704,6 @@ fn test_large_node_rendering_with_zoom() { let full_snapshot = format!("{}", terminal.backend()); insta::assert_snapshot!("variable_detail_chain_full", full_snapshot); - // 9. Confirms node 1's minimum x is to the right of node 0's maximum x - let viewport_graph = controller.get_viewport_graph(); let pos0 = viewport_graph.node_positions.get(&node_0).unwrap(); @@ -1724,7 +722,6 @@ fn test_large_node_rendering_with_zoom() { rect0.max.x ); - // Verify that the cursor has maintained its viewport position throughout the zoom operations let final_cursor_viewport_pos = controller.cursor.viewport_pos; assert_eq!( initial_cursor_viewport_pos, final_cursor_viewport_pos, @@ -1732,396 +729,3 @@ fn test_large_node_rendering_with_zoom() { initial_cursor_viewport_pos, final_cursor_viewport_pos ); } - -/// Test diamond graph with variable width nodes on parallel branches. -/// This tests the horizontal chain redistribution with nodes of different sizes. -#[test] -fn test_diamond_variable_width_parallel_nodes() { - let _ = env_logger::try_init(); - - use crate::plotter::NodeSizer; - - // Create diamond: A -> {B, C} -> D - let mut domain_graph = MockDomainGraph::new(); - let node_a = domain_graph.add_node(()); - let node_b = domain_graph.add_node(()); - let node_c = domain_graph.add_node(()); - let node_d = domain_graph.add_node(()); - - domain_graph.add_edge(node_a, node_b, ()); - domain_graph.add_edge(node_a, node_c, ()); - domain_graph.add_edge(node_b, node_d, ()); - domain_graph.add_edge(node_c, node_d, ()); - - // Custom NodeSizer: B=3 wide, C=2 wide, others=5 wide - #[derive(Debug, Clone)] - struct VariableWidthSizer; - - impl NodeSizer for VariableWidthSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - _scale: VisualDetail, - ) -> (u64, u64) { - match node.index() { - 1 => (15, 3), // B - 3 units wide (15 chars = 3 * 5-char units) - 2 => (10, 3), // C - 2 units wide (10 chars = 2 * 5-char units) - _ => (5, 3), // A and D - 1 unit wide (5 chars) - } - } - - fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) - } - } - - impl NodeSizer<&MockDomainGraph> for VariableWidthSizer { - fn get_node_size( - &self, - node: &petgraph::stable_graph::NodeIndex, - scale: VisualDetail, - ) -> (u64, u64) { - >::get_node_size(self, node, scale) - } - - fn get_dummy_size(&self) -> (u64, u64) { - (1, 1) - } - } - - let renderer = TestRenderers::debug(); - let node_sizer = VariableWidthSizer; - - let snapshot = make_snapshot_custom( - domain_graph, - 80, - 25, - usize::MAX, - usize::MAX, - node_sizer, - renderer, - ); - - insta::assert_snapshot!("diamond_variable_width_parallel", snapshot); -} - -#[test] -fn viewport_visual_regression_simple_cycle() { - let _ = env_logger::try_init(); - // Create a simple cycle: A -> B -> C -> A - let mut domain_graph = MockDomainGraph::new(); - let n0 = domain_graph.add_node(()); - let n1 = domain_graph.add_node(()); - let n2 = domain_graph.add_node(()); - domain_graph.add_edge(n0, n1, ()); - domain_graph.add_edge(n1, n2, ()); - domain_graph.add_edge(n2, n0, ()); // Cycle edge - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("simple_cycle", snapshot); -} - -#[test] -fn pinned_source_cycle() { - let _ = env_logger::try_init(); - use petgraph::graph::NodeIndex; - - use crate::{ - graph_controller::{GraphConfig, GraphController, WorldBuffer}, - testing::mocks::TestRenderers, - }; - - // Create a 12-node cycle - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); - for i in 0..12 { - domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); - } - - // Pin node 6 as the source (should appear at leftmost position) - let pinned_node = nodes[6]; - - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - let mut renderer = TestRenderers::debug(); - - let mut config = GraphConfig::default(); - config.partition.layer_count = usize::MAX; - config.partition.node_count = usize::MAX; - - config.partition.pin_source = Some(NodeIndex::new(pinned_node.index())); - - let mut terminal = create_test_terminal(80, 25); - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); - controller.set_detail_level(VisualDetail::Full); - - let _ = terminal.draw(|f| { - let area = f.area(); - controller.viewport_state.viewport_bounds = area; - let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); - - controller - .rebuild_viewport_graph() - .expect("Failed to rebuild viewport graph for snapshot generation"); - let viewport_graph = controller.get_viewport_graph(); - let detail_level = controller.get_detail_level(); - - let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); - plot_viewport_graph( - viewport_graph, - &mut buffer, - &mut renderer, - controller.graph(), - detail_level, - &controller.theme, - ); - }); - - let snapshot = format!("{}", terminal.backend()); - - insta::assert_snapshot!("pinned_source_cycle", snapshot); -} - -#[test] -fn pinned_source_cycle_partitioned() { - let _ = env_logger::try_init(); - use petgraph::graph::NodeIndex; - - use crate::{ - graph_controller::{GraphConfig, GraphController, WorldBuffer}, - testing::mocks::TestRenderers, - }; - - // Create a 12-node cycle - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); - for i in 0..12 { - domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); - } - - // Pin node 6 as the source (should appear at leftmost position) - let pinned_node = nodes[6]; - - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - let mut renderer = TestRenderers::debug(); - - let mut config = GraphConfig::default(); - config.partition.layer_count = 2; - config.partition.node_count = 8; - - config.partition.pin_source = Some(NodeIndex::new(pinned_node.index())); - - let mut terminal = create_test_terminal(80, 25); - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); - controller.set_detail_level(VisualDetail::Full); - - let _ = terminal.draw(|f| { - let area = f.area(); - controller.viewport_state.viewport_bounds = area; - let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); - - controller - .rebuild_viewport_graph() - .expect("Failed to rebuild viewport graph for snapshot generation"); - let viewport_graph = controller.get_viewport_graph(); - let detail_level = controller.get_detail_level(); - - let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); - // Export viewport graph to dot if RUST_LOG=debug is active - if std::env::var("RUST_LOG") - .map(|v| v.contains("debug")) - .unwrap_or(false) - { - // Generate a filename based on the current test name - let current_thread = std::thread::current(); - let test_name = current_thread.name().unwrap_or("unknown_test"); - let filename = format!("{}_viewport.dot", test_name); - if let Err(e) = crate::dot_export::export_to_dot(viewport_graph, &filename) { - eprintln!("Failed to export dot file {}: {}", filename, e); - } - } - - plot_viewport_graph( - viewport_graph, - &mut buffer, - &mut renderer, - controller.graph(), - detail_level, - &controller.theme, - ); - }); - - let snapshot = format!("{}", terminal.backend()); - - insta::assert_snapshot!("pinned_source_cycle_partitioned", snapshot); -} - -#[test] -fn cycle_with_chord() { - let _ = env_logger::try_init(); - - use crate::{ - graph_controller::{GraphConfig, GraphController, WorldBuffer}, - testing::mocks::TestRenderers, - }; - - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); - // Arrange nodes in a closed circle that we'll open at 0 - for i in 0..12 { - domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); - } - //let pinned_node = nodes[0]; - // Extra cycle - domain_graph.add_edge(nodes[6], nodes[3], ()); - - let snapshot = make_snapshot(domain_graph, 60, 20, usize::MAX, usize::MAX); - - insta::assert_snapshot!("cycle_with_chord", snapshot); -} - -#[test] -fn cycle_with_chords() { - let _ = env_logger::try_init(); - - use crate::{ - graph_controller::{GraphConfig, GraphController, WorldBuffer}, - testing::mocks::TestRenderers, - }; - - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); - // Arrange nodes in a closed circle that we'll open at 0 - for i in 0..12 { - domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); - } - // Extra cycle - domain_graph.add_edge(nodes[6], nodes[3], ()); - domain_graph.add_edge(nodes[4], nodes[0], ()); - - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - let mut renderer = TestRenderers::debug(); - - let mut config = GraphConfig::default(); - config.partition.layer_count = usize::MAX; - config.partition.node_count = usize::MAX; - - config.partition.pin_source = None; - - let mut terminal = create_test_terminal(80, 25); - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); - controller.set_detail_level(VisualDetail::Full); - - let _ = terminal.draw(|f| { - let area = f.area(); - controller.viewport_state.viewport_bounds = area; - let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); - - controller - .rebuild_viewport_graph() - .expect("Failed to rebuild viewport graph for snapshot generation"); - let viewport_graph = controller.get_viewport_graph(); - let detail_level = controller.get_detail_level(); - - let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); - plot_viewport_graph( - viewport_graph, - &mut buffer, - &mut renderer, - controller.graph(), - detail_level, - &controller.theme, - ); - }); - - let snapshot = format!("{}", terminal.backend()); - - insta::assert_snapshot!("cycle_with_chord", snapshot); -} - -#[test] -fn pinned_source_cycle_with_chord() { - let _ = env_logger::try_init(); - use petgraph::graph::NodeIndex; - - use crate::{ - graph_controller::{GraphConfig, GraphController, WorldBuffer}, - testing::mocks::TestRenderers, - }; - - let mut domain_graph = MockDomainGraph::new(); - let nodes: Vec<_> = (0..12).map(|_| domain_graph.add_node(())).collect(); - // Arrange nodes in a closed circle that we'll open at 0 - for i in 0..12 { - domain_graph.add_edge(nodes[i], nodes[(i + 1) % 12], ()); - } - let pinned_node = nodes[0]; - // Extra cycle - domain_graph.add_edge(nodes[6], nodes[3], ()); - - let node_sizer = FixedNodeSizer { - width: 5, - height: 3, - }; - let mut renderer = TestRenderers::debug(); - - let mut config = GraphConfig::default(); - config.partition.layer_count = usize::MAX; - config.partition.node_count = usize::MAX; - - config.partition.pin_source = Some(NodeIndex::new(pinned_node.index())); - - let mut terminal = create_test_terminal(80, 25); - let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); - controller.viewport_state.viewport_bounds = ratatui::layout::Rect::new(0, 0, 80, 25); - controller.set_detail_level(VisualDetail::Full); - - let _ = terminal.draw(|f| { - let area = f.area(); - controller.viewport_state.viewport_bounds = area; - let _loaded_partitions = controller.ensure_camera_coverage().unwrap_or_default(); - - controller - .rebuild_viewport_graph() - .expect("Failed to rebuild viewport graph for snapshot generation"); - let viewport_graph = controller.get_viewport_graph(); - let detail_level = controller.get_detail_level(); - - let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); - plot_viewport_graph( - viewport_graph, - &mut buffer, - &mut renderer, - controller.graph(), - detail_level, - &controller.theme, - ); - }); - - let snapshot = format!("{}", terminal.backend()); - - insta::assert_snapshot!("pinned_source_cycle_with_chord", snapshot); -} - -#[test] -fn viewport_visual_regression_self_loop() { - let _ = env_logger::try_init(); - // Create a self-loop: A -> A - let mut domain_graph = MockDomainGraph::new(); - let n0 = domain_graph.add_node(()); - domain_graph.add_edge(n0, n0, ()); // Self-loop - - let snapshot = make_snapshot(domain_graph, 60, 20, 2, 8); - insta::assert_snapshot!("self_loop", snapshot); -} diff --git a/gen-tui/src/testing/mod.rs b/gen-tui/src/testing/mod.rs index 2abf17c7..ee2be531 100644 --- a/gen-tui/src/testing/mod.rs +++ b/gen-tui/src/testing/mod.rs @@ -9,6 +9,7 @@ pub mod graph_validation; pub mod layout_tests; pub mod mocks; pub mod navigation_tests; +pub mod snapshot_tests; // Re-export main testing APIs pub use graph_validation::{ diff --git a/gen-tui/src/testing/snapshot_tests.rs b/gen-tui/src/testing/snapshot_tests.rs new file mode 100644 index 00000000..47e93ee0 --- /dev/null +++ b/gen-tui/src/testing/snapshot_tests.rs @@ -0,0 +1,851 @@ +#![cfg(test)] +use petgraph::graph::NodeIndex; +use ratatui::layout::Rect; + +use super::layout_tests::{ + add_edge_by_index, chain_graph, cycle_graph, graph_from_edges, init_test_logging, +}; +use crate::{ + dot_export::export_to_dot, + graph_controller::{GraphConfig, GraphController, WorldBuffer}, + layout::VisualDetail, + plotter::{NodeRenderer, NodeSizer, plot_viewport_graph}, + testing::{ + create_test_terminal, + mocks::{FixedNodeSizer, MockDomainGraph, TestGraphs, TestRenderers}, + }, + viewport_graph::CroppedGraph, +}; + +#[derive(Debug, Clone, Copy)] +struct SnapshotOptions { + viewport: Rect, + layer_count: usize, + node_count: usize, + detail: VisualDetail, + pin_source: Option, +} + +impl Default for SnapshotOptions { + fn default() -> Self { + Self { + viewport: Rect::new(0, 0, 60, 20), + layer_count: 2, + node_count: 8, + detail: VisualDetail::Full, + pin_source: None, + } + } +} + +fn maybe_export_dot(viewport_graph: &CroppedGraph) { + let debug_enabled = std::env::var("RUST_LOG") + .map(|v| v.contains("debug")) + .unwrap_or(false); + + if !debug_enabled { + return; + } + + let thread = std::thread::current(); + let test_name = thread.name().unwrap_or("unknown_test"); + let filename = format!("{}_viewport.dot", test_name); + + if let Err(e) = export_to_dot(viewport_graph, &filename) { + eprintln!("Failed to export dot file {}: {}", filename, e); + } +} + +/// Helper function to create viewport-based visual snapshots using GraphController. +fn make_snapshot_with( + domain_graph: MockDomainGraph, + options: SnapshotOptions, + node_sizer: NS, + mut renderer: R, +) -> String +where + NS: for<'a> NodeSizer<&'a MockDomainGraph>, + R: for<'a> NodeRenderer<&'a MockDomainGraph>, +{ + init_test_logging(); + + let mut terminal = create_test_terminal(options.viewport.width, options.viewport.height); + + let mut config = GraphConfig::default(); + config.partition.layer_count = options.layer_count; + config.partition.node_count = options.node_count; + config.partition.pin_source = options.pin_source.map(NodeIndex::new); + + let mut controller = GraphController::new_with_config(&domain_graph, node_sizer, config); + controller.viewport_state.viewport_bounds = options.viewport; + controller.set_detail_level(options.detail); + + let result = terminal.draw(|f| { + let area = f.area(); + controller.viewport_state.viewport_bounds = area; + + let loaded_partitions = controller.ensure_camera_coverage(); + let partition_indices = loaded_partitions.unwrap_or_default(); + println!( + "number of partitions loaded: {}, indices: {:?}", + partition_indices.len(), + partition_indices + ); + + controller + .rebuild_viewport_graph() + .expect("Failed to rebuild viewport graph for snapshot generation"); + + let viewport_graph = controller.get_viewport_graph(); + let detail_level = controller.get_detail_level(); + + maybe_export_dot(viewport_graph); + + let mut buffer = WorldBuffer::new(f.buffer_mut(), &controller.viewport_state); + plot_viewport_graph( + viewport_graph, + &mut buffer, + &mut renderer, + controller.graph(), + detail_level, + &controller.theme, + ); + }); + + match result { + Ok(_) => format!("{}", terminal.backend()), + Err(e) => format!("Rendering failed: {}", e), + } +} + +fn make_snapshot(domain_graph: MockDomainGraph) -> String { + make_snapshot_with( + domain_graph, + SnapshotOptions::default(), + FixedNodeSizer { + width: 5, + height: 3, + }, + TestRenderers::debug(), + ) +} + +fn make_snapshot_with_options(domain_graph: MockDomainGraph, options: SnapshotOptions) -> String { + make_snapshot_with( + domain_graph, + options, + FixedNodeSizer { + width: 5, + height: 3, + }, + TestRenderers::debug(), + ) +} + +// ----------------------------------------------------------------------------- +// Snapshot regression tests +// ----------------------------------------------------------------------------- + +#[test] +fn simple_chain() { + let snapshot = make_snapshot(graph_from_edges(3, &[(0, 1), (1, 2)])); + insta::assert_snapshot!("simple_chain", snapshot); +} + +#[test] +fn diamond() { + let snapshot = make_snapshot(graph_from_edges(4, &[(0, 1), (0, 2), (1, 3), (2, 3)])); + insta::assert_snapshot!("diamond", snapshot); +} + +#[test] +fn single_node() { + let snapshot = make_snapshot(graph_from_edges(1, &[])); + insta::assert_snapshot!("single_node", snapshot); +} + +#[test] +fn subcombinatorial_dag() { + let snapshot = make_snapshot(graph_from_edges( + 6, + &[(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5)], + )); + insta::assert_snapshot!("subcombinatorial_dag", snapshot); +} + +#[test] +fn complex_dag() { + let snapshot = make_snapshot(graph_from_edges( + 9, + &[ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 6), + (4, 6), + (4, 7), + (5, 7), + (6, 8), + (7, 8), + ], + )); + insta::assert_snapshot!("complex_dag", snapshot); +} + +#[test] +fn viewport_multi_partition_boundary_handling() { + let snapshot = make_snapshot_with_options( + chain_graph(20), + SnapshotOptions { + viewport: Rect::new(0, 0, 120, 30), + layer_count: 3, + node_count: 5, + ..Default::default() + }, + ); + + insta::assert_snapshot!("multi_partition_chain", snapshot); +} + +#[test] +fn extended_complex_dag_no_partitioning() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_complex_dag(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("extended_complex_dag_no_partitioning", snapshot); +} + +#[test] +fn extended_complex_dag_layer_partitioning() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_complex_dag(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: 3, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("extended_complex_dag_layer_partitioning", snapshot); +} + +// This test is a good example of why we try to go for articulation points: +// By breaking up the graph between layers that each have multiple nodes +// suboptimal node orderings are encountered. +// +// Valid outcome, but ugly: +// +// █████ +// ╭───█N5██───╮ +// █████ ╭─╯ █████ │ █████ +// ╭─█N1██─│─╮ ├─█N7██─╮ +// █████ │ █████ │ ├─╮ █████ ╭─╯ █████ │ █████ █████ +// █N0██─┤ │ │ ├─█N4██─┤ ├─█N8██─█N9██ +// █████ │ █████ ├─│─╯ █████ ╰─╮ █████ │ █████ █████ +// ╰─█N2██─╯ │ ├─█N6██─╯ +// █████ │ █████ │ █████ +// ╰───█N3██───╯ +// █████ +// +// Ideal outcome: +// █████ +// ╭───█N3██───╮ +// █████ │ █████ │ █████ +// ╭─█N1██─┤ ├─█N6██─╮ +// █████ │ █████ ╰─╮ █████ ╭─╯ █████ │ █████ █████ +// █N0██─┤ ├─█N4██─┤ ├─█N8██─█N9██ +// █████ │ █████ ╭─╯ █████ ╰─╮ █████ │ █████ █████ +// ╰─█N2██─┤ ├─█N7██─╯ +// █████ │ █████ │ █████ +// ╰───█N5██───╯ +// █████ +#[test] +fn extended_complex_dag_node_partitioning() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_complex_dag(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: 3, + ..Default::default() + }, + ); + + insta::assert_snapshot!("extended_complex_dag_node_partitioning", snapshot); +} + +#[test] +fn extended_diamond_no_partitioning() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_extended_diamond(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("extended_diamond_no_partitioning", snapshot); +} + +#[test] +fn extended_diamond_layer_partitioning() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_extended_diamond(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: 3, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("extended_diamond_layer_partitioning", snapshot); +} + +#[test] +fn extended_diamond_node_partitioning() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_extended_diamond(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: 3, + ..Default::default() + }, + ); + + insta::assert_snapshot!("extended_diamond_node_partitioning", snapshot); +} + +#[test] +fn bridge_position_with_variable_node_widths() { + #[derive(Debug, Clone)] + struct VariableWidthSizer; + + impl NodeSizer for VariableWidthSizer { + fn get_node_size(&self, node: &NodeIndex, _scale: VisualDetail) -> (u64, u64) { + match node.index() { + 0 => (4, 1), + 1 => (15, 2), + 2 => (2, 1), + 3 => (5, 1), + _ => (3, 1), + } + } + + fn get_dummy_size(&self) -> (u64, u64) { + (1, 1) + } + } + + impl NodeSizer<&MockDomainGraph> for VariableWidthSizer { + fn get_node_size(&self, node: &NodeIndex, scale: VisualDetail) -> (u64, u64) { + >::get_node_size(self, node, scale) + } + + fn get_dummy_size(&self) -> (u64, u64) { + >::get_dummy_size(self) + } + } + + let snapshot = make_snapshot_with( + TestGraphs::domain_diamond(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: 2, + node_count: 3, + ..Default::default() + }, + VariableWidthSizer, + TestRenderers::debug(), + ); + + insta::assert_snapshot!("bridge_position_variable_widths", snapshot); +} + +#[test] +fn test_skip_layer() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_skip_layer(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("skip_layer", snapshot); +} + +#[test] +fn test_skip_layer_partition_boundary() { + let snapshot = make_snapshot_with_options( + TestGraphs::domain_skip_layer(), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: 2, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("skip_layer_partition_boundary", snapshot); +} + +#[test] +fn viewport_chain_three_partitions_spanning_edge() { + let mut domain_graph = chain_graph(6); + add_edge_by_index(&mut domain_graph, 1, 4); + + let snapshot = make_snapshot_with_options( + domain_graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 100, 30), + layer_count: 2, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("chain_three_partitions_spanning_edge", snapshot); +} + +#[test] +fn viewport_chain_five_partitions_long_spanning_edge() { + let mut domain_graph = chain_graph(10); + add_edge_by_index(&mut domain_graph, 1, 8); + + // Note: if you cut off the partitions using node_count=5 the test will fail due to + // a visual artefact, which is concession made when using node_count to create the cut. + // Topologically, the graph was still correct. + let snapshot = make_snapshot_with_options( + domain_graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 120, 35), + layer_count: 2, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("chain_five_partitions_long_spanning_edge", snapshot); +} + +#[test] +fn viewport_even_width_node_spacing() { + #[derive(Debug, Clone)] + struct OddEvenSizer; + + impl NodeSizer for OddEvenSizer { + fn get_node_size(&self, node: &NodeIndex, _scale: VisualDetail) -> (u64, u64) { + match node.index() { + 0 => (3, 1), + 1 => (6, 1), + 2 => (8, 1), + 3 => (9, 1), + _ => (4, 1), + } + } + + fn get_dummy_size(&self) -> (u64, u64) { + (1, 1) + } + } + + impl NodeSizer<&MockDomainGraph> for OddEvenSizer { + fn get_node_size(&self, node: &NodeIndex, _scale: VisualDetail) -> (u64, u64) { + match node.index() { + 0 => (4, 1), + 1 => (6, 1), + 2 => (6, 1), + 3 => (8, 1), + _ => (4, 1), + } + } + + fn get_dummy_size(&self) -> (u64, u64) { + (1, 1) + } + } + + let snapshot = make_snapshot_with( + graph_from_edges(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + OddEvenSizer, + TestRenderers::debug(), + ); + + insta::assert_snapshot!("even_width_nodes", snapshot); +} + +/// Test for determinism by running the same layout multiple times and comparing snapshots. +/// This test uses the complex_dag graph with node-based partitioning to ensure +/// that HashMap/HashSet iterations produce consistent results. +#[test] +fn test_layout_determinism_with_partitioning() { + init_test_logging(); + + let num_iterations = 10; + let mut snapshots = Vec::new(); + + for i in 0..num_iterations { + let graph = TestGraphs::domain_complex_dag(); + let snapshot = make_snapshot_with_options( + graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: 3, + ..Default::default() + }, + ); + snapshots.push(snapshot); + log::trace!("Generated snapshot {} for determinism test", i); + } + + let first = &snapshots[0]; + for (i, snapshot) in snapshots.iter().enumerate().skip(1) { + assert_eq!( + first, snapshot, + "Layout iteration {} produced different output than iteration 0. \ + This indicates non-determinism in the layout algorithm. \ + The difference suggests HashMap or HashSet iteration order is affecting the result.", + i + ); + } + + insta::assert_snapshot!("determinism_check_complex_dag_node_partitioning", first); +} + +/// Proves crossing-reduction tie handling is deterministic by constructing the same symmetric +/// graph twice, but inserting edges in a different order. +#[test] +fn test_layout_determinism_across_edge_insertion_order_symmetric_fan() { + init_test_logging(); + + let graph_1 = graph_from_edges(5, &[(0, 1), (0, 2), (0, 3), (1, 4), (2, 4), (3, 4)]); + + let graph_2 = graph_from_edges(5, &[(0, 3), (0, 1), (0, 2), (3, 4), (1, 4), (2, 4)]); + + let options = SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }; + + let snapshot1 = make_snapshot_with_options(graph_1, options); + let snapshot2 = make_snapshot_with_options(graph_2, options); + + assert_eq!( + snapshot1, snapshot2, + "Symmetric fan layout should be identical regardless of edge insertion order" + ); +} + +#[test] +fn test_double_chain() { + // Shared start node 0 and shared stop node 9. + let snapshot = make_snapshot_with_options( + graph_from_edges( + 18, + &[ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (7, 8), + (8, 9), + (0, 10), + (10, 11), + (11, 12), + (12, 13), + (13, 14), + (14, 15), + (15, 16), + (16, 17), + (17, 9), + ], + ), + SnapshotOptions { + viewport: Rect::new(0, 0, 120, 40), + layer_count: 5, + node_count: 20, + ..Default::default() + }, + ); + + insta::assert_snapshot!("double_chain", snapshot); +} + +#[test] +fn test_asymmetric_diamond() { + let snapshot = make_snapshot_with_options( + graph_from_edges(6, &[(0, 1), (1, 2), (2, 3), (3, 4), (0, 5), (5, 4)]), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("asymmetric_diamond", snapshot); +} + +#[test] +fn test_asymmetric_diamond_2_1() { + let snapshot = make_snapshot_with_options( + graph_from_edges(5, &[(0, 1), (1, 2), (2, 3), (0, 4), (4, 3)]), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("asymmetric_diamond_2_1", snapshot); +} + +#[test] +fn test_asymmetric_diamond_4_1() { + let snapshot = make_snapshot_with_options( + graph_from_edges(7, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 6), (6, 5)]), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("asymmetric_diamond_4_1", snapshot); +} + +#[test] +fn test_asymmetric_diamond_3_2() { + let snapshot = make_snapshot_with_options( + graph_from_edges(7, &[(0, 1), (1, 2), (2, 3), (3, 4), (0, 5), (5, 6), (6, 4)]), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("asymmetric_diamond_3_2", snapshot); +} + +#[test] +fn test_asymmetric_diamond_4_2() { + let snapshot = make_snapshot_with_options( + graph_from_edges( + 8, + &[ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (0, 6), + (6, 7), + (7, 5), + ], + ), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("asymmetric_diamond_4_2", snapshot); +} + +#[test] +fn test_diamond_variable_width_parallel_nodes() { + #[derive(Debug, Clone)] + struct VariableWidthSizer; + + impl NodeSizer for VariableWidthSizer { + fn get_node_size(&self, node: &NodeIndex, _scale: VisualDetail) -> (u64, u64) { + match node.index() { + 1 => (15, 3), + 2 => (10, 3), + _ => (5, 3), + } + } + + fn get_dummy_size(&self) -> (u64, u64) { + (1, 1) + } + } + + impl NodeSizer<&MockDomainGraph> for VariableWidthSizer { + fn get_node_size(&self, node: &NodeIndex, scale: VisualDetail) -> (u64, u64) { + >::get_node_size(self, node, scale) + } + + fn get_dummy_size(&self) -> (u64, u64) { + >::get_dummy_size(self) + } + } + + let snapshot = make_snapshot_with( + graph_from_edges(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + VariableWidthSizer, + TestRenderers::debug(), + ); + + insta::assert_snapshot!("diamond_variable_width_parallel", snapshot); +} + +#[test] +fn simple_cycle() { + let snapshot = make_snapshot(graph_from_edges(3, &[(0, 1), (1, 2), (2, 0)])); + insta::assert_snapshot!("simple_cycle", snapshot); +} + +#[test] +fn pinned_source_cycle() { + let snapshot = make_snapshot_with_options( + cycle_graph(12), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + pin_source: Some(6), + ..Default::default() + }, + ); + + insta::assert_snapshot!("pinned_source_cycle", snapshot); +} + +#[test] +fn pinned_source_cycle_partitioned() { + let snapshot = make_snapshot_with_options( + cycle_graph(12), + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: 2, + node_count: 8, + pin_source: Some(6), + ..Default::default() + }, + ); + + insta::assert_snapshot!("pinned_source_cycle_partitioned", snapshot); +} + +#[test] +fn cycle_with_chord() { + let mut domain_graph = cycle_graph(8); + add_edge_by_index(&mut domain_graph, 6, 3); + + let snapshot = make_snapshot_with_options( + domain_graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("cycle_with_chord", snapshot); +} + +#[test] +fn cycle_with_chords() { + let mut domain_graph = cycle_graph(8); + add_edge_by_index(&mut domain_graph, 6, 3); + add_edge_by_index(&mut domain_graph, 4, 1); + + let snapshot = make_snapshot_with_options( + domain_graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + ..Default::default() + }, + ); + + insta::assert_snapshot!("cycle_with_chords", snapshot); +} + +#[test] +fn cycle_with_chord_pinned() { + let mut domain_graph = cycle_graph(12); + add_edge_by_index(&mut domain_graph, 6, 3); + + let snapshot = make_snapshot_with_options( + domain_graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + pin_source: Some(0), + ..Default::default() + }, + ); + + insta::assert_snapshot!("cycle_with_chord_pinned", snapshot); +} + +#[test] +fn cycle_with_chords_pinned() { + let mut domain_graph = cycle_graph(12); + add_edge_by_index(&mut domain_graph, 6, 3); + add_edge_by_index(&mut domain_graph, 4, 1); + + let snapshot = make_snapshot_with_options( + domain_graph, + SnapshotOptions { + viewport: Rect::new(0, 0, 80, 25), + layer_count: usize::MAX, + node_count: usize::MAX, + pin_source: Some(0), + ..Default::default() + }, + ); + + insta::assert_snapshot!("cycle_with_chords_pinned", snapshot); +} +#[test] +fn self_loop() { + let snapshot = make_snapshot(graph_from_edges(1, &[(0, 0)])); + insta::assert_snapshot!("self_loop", snapshot); +} diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap deleted file mode 100644 index 5527276f..00000000 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_fallback_short_cycle.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: gen-tui/src/testing/layout_tests.rs -expression: snapshot ---- -" " -" " -" " -" " -" " -" " -" " -" ╭─●─●─╮ " -" │ │ " -" ╰──◀──╯ " -" " -" " -" " -" " -" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap deleted file mode 100644 index c602efd0..00000000 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__arrow_forced_marker_short_edge_large_gap.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: gen-tui/src/testing/layout_tests.rs -expression: snapshot ---- -" " -" " -" " -" " -" " -" " -" " -" " -" █████ █████ " -" ╭─█N0██─█N1██─╮ " -" │ █████ █████ │ " -" │ │ " -" ╰──────◀──────╯ " -" " -" " -" " -" " -" " -" " -" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap deleted file mode 100644 index 9815edc6..00000000 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__cycle_with_chord.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: gen-tui/src/testing/layout_tests.rs -expression: snapshot ---- -" " -" " -" " -" " -" " -" " -" █████ █████ █████ █████ █████ █████ █████ █████" -" ╭─█N7██─█N8██─█N9██─█N10█─█N11█─█N0██─█N1██─█N2██" -" █████ │ █████ █████ █████ █████ █████ █████ █████ █████" -" ╭─█N6██─┤ " -" │ █████ │ " -" │ ╰────────────────────────────────────────────────" -" │ " -" ╰──────◀───────────────◀───────────────◀───────────────◀─" -" " -" " -" " -" " -" " -" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond.snap index 8e21d1d6..e5745429 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_2_1.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_2_1.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_2_1.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_2_1.snap index ab2aee22..fe24ed83 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_2_1.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_2_1.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_3_2.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_3_2.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_3_2.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_3_2.snap index dc57c244..9c053825 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_3_2.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_3_2.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_4_1.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_4_1.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_4_1.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_4_1.snap index 67321f92..e40e4760 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_4_1.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_4_1.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_4_2.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_4_2.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_4_2.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_4_2.snap index 9b58a418..f0e153a3 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__asymmetric_diamond_4_2.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__asymmetric_diamond_4_2.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__bridge_position_variable_widths.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__bridge_position_variable_widths.snap similarity index 97% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__bridge_position_variable_widths.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__bridge_position_variable_widths.snap index c0eb595a..71463670 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__bridge_position_variable_widths.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__bridge_position_variable_widths.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__chain_five_partitions_long_spanning_edge.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__chain_five_partitions_long_spanning_edge.snap similarity index 99% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__chain_five_partitions_long_spanning_edge.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__chain_five_partitions_long_spanning_edge.snap index f036838a..1ae43518 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__chain_five_partitions_long_spanning_edge.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__chain_five_partitions_long_spanning_edge.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__chain_three_partitions_spanning_edge.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__chain_three_partitions_spanning_edge.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__chain_three_partitions_spanning_edge.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__chain_three_partitions_spanning_edge.snap index 2d38cf44..27a38dec 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__chain_three_partitions_spanning_edge.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__chain_three_partitions_spanning_edge.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__complex_dag.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__complex_dag.snap similarity index 97% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__complex_dag.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__complex_dag.snap index d1103ffd..2296113a 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__complex_dag.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__complex_dag.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord.snap new file mode 100644 index 00000000..dfe6f732 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" █████ █████ █████ █████ " +" ╭─█N7██─█N0██─█N1██─█N2██─╮ " +" █████ │ █████ █████ █████ █████ │ █████ █████ █████ " +" ╭─█N6██─┤ ├─█N3██─█N4██─█N5██─╮ " +" ▲ █████ │ │ █████ █████ █████ │ " +" │ ╰─────────────────────────╯ │ " +" │ │ " +" ╰──◀───────────────◀───────────────◀───────────────◀──╯ " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord_pinned.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord_pinned.snap index 2000f612..095e08b0 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_with_chord.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord_pinned.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords.snap new file mode 100644 index 00000000..bfa40838 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" █████ █████ " +" ╭─█N7██─█N0██─╮ " +" █████ █████ │ █████ █████ │ █████ █████ " +" ╭─█N5██─█N6██───┤ ├─█N1██─█N2██─╮ " +" █████ │ █████ █████ │ │ █████ █████ │ █████ " +" ╭─█N4██─┤ ╭─│─────────────╯ ├─█N3██─╮ " +" │ █████ │ │ │ │ █████ │ " +" │ ╰─────────────╯ │ │ │ " +" │ ╰───────────────────────────╯ │ " +" │ │ " +" │ │ " +" ╰─────◀───────────────◀───────────────◀───────────────◀─────╯ " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords_pinned.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords_pinned.snap new file mode 100644 index 00000000..ed5ca458 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chords_pinned.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" █████ █████ █████ █████ █████ █████ █████ █████ " +" ╭─█N4██─┬───█N5██─█N6██─┬────█N7██─█N8██─█N9██─█N10█─█N11█────╮ " +" │ █████ │ █████ █████ │ █████ █████ █████ █████ █████ │ " +" │ ╰─╮ ╰─╮ │ " +" │ █████ │ █████ █████ │ █████ │ " +" ╭─│─█N0██───┴─█N1██─█N2██───┴─█N3██─╮ │ " +" │ │ █████ █████ █████ █████ │ │ " +" │ ▲ │ │ " +" │ ╰◀───────────────◀───────────────◀╯ │ " +" │ │ " +" ╰───────────────◀───────────────◀───────────────◀───────────────╯ " +" " +" " +" " +" " +" " +" " +" " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__determinism_check_complex_dag_node_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__determinism_check_complex_dag_node_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__determinism_check_complex_dag_node_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__determinism_check_complex_dag_node_partitioning.snap index c7215627..7286bc85 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__determinism_check_complex_dag_node_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__determinism_check_complex_dag_node_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: first --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__diamond.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__diamond.snap similarity index 96% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__diamond.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__diamond.snap index e4946fa8..e310de2c 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__diamond.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__diamond.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__diamond_variable_width_parallel.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__diamond_variable_width_parallel.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__diamond_variable_width_parallel.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__diamond_variable_width_parallel.snap index ecff097c..5f52f24d 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__diamond_variable_width_parallel.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__diamond_variable_width_parallel.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__double_chain.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__double_chain.snap similarity index 99% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__double_chain.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__double_chain.snap index a68ea8f5..b02de05a 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__double_chain.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__double_chain.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__even_width_nodes.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__even_width_nodes.snap similarity index 97% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__even_width_nodes.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__even_width_nodes.snap index 344c88e7..f66bd08b 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__even_width_nodes.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__even_width_nodes.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_layer_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_layer_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_layer_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_layer_partitioning.snap index ce4b90ab..dcfa2a5e 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_layer_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_layer_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_node_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_no_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_node_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_no_partitioning.snap index ce4b90ab..dcfa2a5e 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_node_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_no_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_no_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_node_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_no_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_node_partitioning.snap index ce4b90ab..dcfa2a5e 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_complex_dag_no_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_complex_dag_node_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_no_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_layer_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_no_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_layer_partitioning.snap index e4d567cf..f03daf27 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_no_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_layer_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_node_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_no_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_node_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_no_partitioning.snap index e4d567cf..f03daf27 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_node_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_no_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_layer_partitioning.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_node_partitioning.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_layer_partitioning.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_node_partitioning.snap index e4d567cf..f03daf27 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__extended_diamond_layer_partitioning.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__extended_diamond_node_partitioning.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__multi_partition_chain.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__multi_partition_chain.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__multi_partition_chain.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__multi_partition_chain.snap index acc1c6ef..68c5b8d0 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__multi_partition_chain.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__multi_partition_chain.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle.snap index 4bdb0576..c006471e 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle_partitioned.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle_partitioned.snap index 7e99fa5f..3b48d8e8 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__pinned_source_cycle_partitioned.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle_partitioned.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap similarity index 96% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap index 1b2dc43d..4f103b22 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__self_loop.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_chain.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_chain.snap similarity index 96% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_chain.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_chain.snap index c3fcac31..9e0da63b 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_chain.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_chain.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_cycle.snap similarity index 96% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_cycle.snap index e17c910d..1e08f24c 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__simple_cycle.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_cycle.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__single_node.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__single_node.snap similarity index 96% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__single_node.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__single_node.snap index afb420cd..5715f504 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__single_node.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__single_node.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__skip_layer.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__skip_layer.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__skip_layer.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__skip_layer.snap index 0c112213..596dad9e 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__skip_layer.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__skip_layer.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__skip_layer_partition_boundary.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__skip_layer_partition_boundary.snap similarity index 98% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__skip_layer_partition_boundary.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__skip_layer_partition_boundary.snap index a825bd51..f14c7775 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__skip_layer_partition_boundary.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__skip_layer_partition_boundary.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__subcombinatorial_dag.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__subcombinatorial_dag.snap similarity index 97% rename from gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__subcombinatorial_dag.snap rename to gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__subcombinatorial_dag.snap index 0be88731..0854c6d9 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__layout_tests__subcombinatorial_dag.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__subcombinatorial_dag.snap @@ -1,5 +1,5 @@ --- -source: gen-tui/src/testing/layout_tests.rs +source: gen-tui/src/testing/snapshot_tests.rs expression: snapshot --- " " From 7c6138100a173e2d3531993cb587f4908f8d37d5 Mon Sep 17 00:00:00 2001 From: Bob Van Hove <1587584+bobvh@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:17:56 -0400 Subject: [PATCH 3/5] Fix self-loop --- gen-tui/src/plotter.rs | 42 ++++++++++++++++++- ...i__testing__snapshot_tests__self_loop.snap | 2 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/gen-tui/src/plotter.rs b/gen-tui/src/plotter.rs index a966184d..0df4d5fd 100644 --- a/gen-tui/src/plotter.rs +++ b/gen-tui/src/plotter.rs @@ -472,7 +472,7 @@ pub fn plot_viewport_graph_with_highlights( let mut drawn_reversed: HashSet<(NodeIndex, NodeIndex)> = HashSet::new(); for (_, _, bundle) in viewport_graph.edges() { for &(src, tgt) in bundle { - if src == tgt || !drawn_reversed.insert((src, tgt)) { + if !drawn_reversed.insert((src, tgt)) { continue; } if viewport_graph.backward_edges.contains(&(src, tgt)) { @@ -578,7 +578,7 @@ pub fn draw_arrows( target: NodeIndex, gaps: usize, ) { - if gaps == 0 || source == target { + if gaps == 0 { return; } let g = gaps as i64; @@ -602,6 +602,44 @@ pub fn draw_arrows( .find(|(_, n)| matches!(n.role, NodeRole::Data(idx) if idx == target)) .map(|(pos, _)| *pos); + // For self-loops, place a single arrow in the middle of the longest segment. + if source == target { + let mut best_seg: Option<(WorldPos, WorldPos)> = None; + let mut best_len: i64 = -1; + for (seg_a, seg_b, _) in sub.edges() { + let (lo, hi) = if seg_a <= seg_b { + (seg_a, seg_b) + } else { + (seg_b, seg_a) + }; + let len = if lo.x == hi.x { + hi.y - lo.y + } else { + hi.x - lo.x + }; + if len > best_len { + best_len = len; + best_seg = Some((lo, hi)); + } + } + if let Some((lo, hi)) = best_seg { + let mid = WorldPos::new((lo.x + hi.x) / 2, (lo.y + hi.y) / 2); + let arrow_ch = if lo.x == hi.x { + if hi.y > lo.y { '▼' } else { '▲' } + } else { + '◀' + }; + if matches!( + buffer.get_char(mid), + Some('│') | Some('┃') | Some('┆') | Some('─') | Some('━') | Some('┄') + ) && let Some((_, style)) = buffer.get_char_styled(mid) + { + buffer.set_char_styled(mid, arrow_ch, style); + } + } + return; + } + // Choose walk anchor and direction. Prefer source (forward), else target (backward), // else the rightmost degree-1 node (assumed source side for a backward edge). let (start, forward) = if let Some(p) = source_pos { diff --git a/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap index 4f103b22..fc02e1df 100644 --- a/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap @@ -14,7 +14,7 @@ expression: snapshot " ╭─█N0██─╮ " " │ █████ │ " " │ │ " -" ╰───────╯ " +" ╰───◀───╯ " " " " " " " From 012d596d944eced62a3303610f2885d4df227449 Mon Sep 17 00:00:00 2001 From: Bob Van Hove <1587584+bobvh@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:23:16 -0400 Subject: [PATCH 4/5] Cycle support in GenGraph --- src/views/gen_graph_widget.rs | 45 +++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/views/gen_graph_widget.rs b/src/views/gen_graph_widget.rs index 9f4ea56b..47b195d7 100644 --- a/src/views/gen_graph_widget.rs +++ b/src/views/gen_graph_widget.rs @@ -5,12 +5,13 @@ use gen_graph::{GenGraph, GraphNode}; use gen_models::{db::GraphConnection, node::Node}; use gen_tui::{ geometry::WorldRect, - graph_controller::{GraphController, WorldBuffer}, + graph_controller::{GraphConfig, GraphController, WorldBuffer}, graph_widget::{GraphWidget, NODE_GLYPH}, layout::VisualDetail, plotter::{NodeRenderer, NodeSizer}, theme::Theme, }; +use petgraph::{graph::NodeIndex, visit::IntoNodeIdentifiers}; use ratatui::style::{Color, Style}; use crate::config::get_theme_color; @@ -205,16 +206,40 @@ pub fn create_gen_graph_controller( graph: &GenGraph, ) -> GraphController<&GenGraph, GenGraphNodeSizer> { let node_sizer = GenGraphNodeSizer; - let mut controller = GraphController::new(graph, node_sizer).with_theme(Theme { - canvas: get_theme_color("canvas").unwrap(), - node_fg: get_theme_color("text").unwrap(), - node_bg: get_theme_color("node").unwrap(), - edge_fg: get_theme_color("edge").unwrap(), - edge_bg: get_theme_color("canvas").unwrap(), - cursor_fg: get_theme_color("cursor_fg").unwrap(), - cursor_bg: get_theme_color("cursor_bg").unwrap(), - highlight: Color::Cyan, + + // Find PATH_START/PATH_END nodes to pin as source/sink for cycle removal. + // For circular sequences, this ensures PATH_END→PATH_START is correctly + // identified as the backward/loopback edge rather than an arbitrary content edge. + let pin_source = graph.node_identifiers().enumerate().find_map(|(i, n)| { + if is_start_node(n.node_id) { + Some(NodeIndex::new(i)) + } else { + None + } }); + let pin_sink = graph.node_identifiers().enumerate().find_map(|(i, n)| { + if is_end_node(n.node_id) { + Some(NodeIndex::new(i)) + } else { + None + } + }); + + let mut config = GraphConfig::default(); + config.partition.pin_source = pin_source; + config.partition.pin_sink = pin_sink; + + let mut controller = + GraphController::new_with_config(graph, node_sizer, config).with_theme(Theme { + canvas: get_theme_color("canvas").unwrap(), + node_fg: get_theme_color("text").unwrap(), + node_bg: get_theme_color("node").unwrap(), + edge_fg: get_theme_color("edge").unwrap(), + edge_bg: get_theme_color("canvas").unwrap(), + cursor_fg: get_theme_color("cursor_fg").unwrap(), + cursor_bg: get_theme_color("cursor_bg").unwrap(), + highlight: Color::Cyan, + }); controller.set_detail_level(VisualDetail::Truncated); controller.show_cursor(); controller From 44292bcf7bf9550caa3192607895cc7b947327db Mon Sep 17 00:00:00 2001 From: Bob Van Hove <1587584+bobvh@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:25:52 -0400 Subject: [PATCH 5/5] Gen view snapshots --- Cargo.lock | 1 + Cargo.toml | 57 ++- src/views/testing/mod.rs | 1 + src/views/testing/snapshot_tests.rs | 372 ++++++++++++++++++ ...napshot_tests__cycle_no_path_loopback.snap | 29 ++ ...pshot_tests__cycle_with_path_loopback.snap | 29 ++ ..._snapshot_tests__import_cycle_no_path.snap | 29 ++ ...napshot_tests__import_cycle_with_path.snap | 29 ++ ..._snapshot_tests__import_library_affix.snap | 29 ++ ...t_tests__import_library_single_column.snap | 29 ++ ...hot_tests__import_library_two_columns.snap | 29 ++ ...g__snapshot_tests__import_no_path_gfa.snap | 29 ++ ...shot_tests__import_reverse_strand_gfa.snap | 29 ++ ...ng__snapshot_tests__import_simple_gfa.snap | 29 ++ ...ting__snapshot_tests__import_walk_gfa.snap | 29 ++ ...apshot_tests__update_fasta_with_fasta.snap | 29 ++ ...hot_tests__update_fasta_with_sequence.snap | 29 ++ ...snapshot_tests__update_fasta_with_vcf.snap | 29 ++ ...shot_tests__update_with_gfa_path_diff.snap | 29 ++ ...shot_tests__update_with_gfa_walk_diff.snap | 29 ++ ...pshot_tests__update_with_library_pool.snap | 29 ++ ...ts__update_with_library_single_column.snap | 29 ++ ...ests__update_with_library_two_columns.snap | 29 ++ 23 files changed, 971 insertions(+), 11 deletions(-) create mode 100644 src/views/testing/snapshot_tests.rs create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_no_path_loopback.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_with_path_loopback.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_no_path.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_with_path.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_affix.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_single_column.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_two_columns.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_no_path_gfa.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_reverse_strand_gfa.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_simple_gfa.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_walk_gfa.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_fasta.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_sequence.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_vcf.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_path_diff.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_walk_diff.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_pool.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_single_column.snap create mode 100644 src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_two_columns.snap diff --git a/Cargo.lock b/Cargo.lock index 87963487..b275513e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1513,6 +1513,7 @@ dependencies = [ "include_dir", "indexmap 2.13.0", "indicatif", + "insta", "interavl", "intervaltree", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 0cd07666..6d5b1c69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,7 @@ edition = "2024" repository = "https://github.com/genhub-bio/gen" homepage = "https://genhub.bio" license = "Apache-2.0" -include = [ - "/src", - "/LICENSE", -] +include = ["/src", "/LICENSE"] [lib] name = "gen" @@ -20,8 +17,25 @@ name = "gen" path = "src/main.rs" [workspace] -members = [".", "gen-core", "gen-models", "gen-graph", "gen-diff", "gen-tui", "gen-sugiyama", "gen-annotations", "gen-capnp-schemas"] -default-members = [".", "gen-core", "gen-models", "gen-graph", "gen-diff", "gen-capnp-schemas"] +members = [ + ".", + "gen-core", + "gen-models", + "gen-graph", + "gen-diff", + "gen-tui", + "gen-sugiyama", + "gen-annotations", + "gen-capnp-schemas", +] +default-members = [ + ".", + "gen-core", + "gen-models", + "gen-graph", + "gen-diff", + "gen-capnp-schemas", +] exclude = ["gen-python"] [features] @@ -46,11 +60,25 @@ fallible-streaming-iterator = "0.1.9" include_dir = "0.7.4" intervaltree = "0.2.7" itertools = "0.14.0" -noodles = { version = "0.101.0", features = ["async", "bed", "bgzf", "core", "fasta", "gff", "gtf", "vcf"] } +noodles = { version = "0.101.0", features = [ + "async", + "bed", + "bgzf", + "core", + "fasta", + "gff", + "gtf", + "vcf", +] } petgraph = "0.6.5" -rusqlite = { version = "0.32.1", features = ["bundled", "array", "limits", "session"] } -rusqlite_migration = { version = "1.3.1" , features = ["from-directory"]} -serde = { version = "1.0.219", features = ["derive"] } +rusqlite = { version = "0.32.1", features = [ + "bundled", + "array", + "limits", + "session", +] } +rusqlite_migration = { version = "1.3.1", features = ["from-directory"] } +serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" sha2 = "0.10.8" tempfile = "3.20.0" @@ -74,7 +102,13 @@ figment = { version = "0.10", features = ["yaml"] } serde_with = "3.0" once_cell = "1.21.3" webbrowser = "1.0.5" -reqwest = { version = "0.12.23", default-features = false,features = ["blocking", "json", "multipart", "stream", "rustls-tls"] } +reqwest = { version = "0.12.23", default-features = false, features = [ + "blocking", + "json", + "multipart", + "stream", + "rustls-tls", +] } rand = "0.9.2" base64 = "0.22.1" getrandom = "0.3.3" @@ -88,6 +122,7 @@ anyhow = { version = "1.0.100", features = ["backtrace"] } cargo-llvm-cov = "0.6.16" cargo-deny = "0.18.5" more-asserts = "0.3.1" +insta = "1.46.3" [profile.release] opt-level = "s" diff --git a/src/views/testing/mod.rs b/src/views/testing/mod.rs index a0689786..2c8063d2 100644 --- a/src/views/testing/mod.rs +++ b/src/views/testing/mod.rs @@ -3,3 +3,4 @@ //pub mod connectivity_test; //pub mod keyboard_navigation_test; +pub mod snapshot_tests; diff --git a/src/views/testing/snapshot_tests.rs b/src/views/testing/snapshot_tests.rs new file mode 100644 index 00000000..ef4563ba --- /dev/null +++ b/src/views/testing/snapshot_tests.rs @@ -0,0 +1,372 @@ +#![cfg(test)] + +use std::path::PathBuf; + +use gen_models::{db::DbContext, sample::Sample}; +use gen_tui::testing::create_test_terminal; +use ratatui::layout::Rect; + +use crate::{ + imports::{fasta::import_fasta, gfa::import_gfa, library::import_library}, + test_helpers::setup_gen, + track_database, + updates::{ + fasta::update_with_fasta, gfa::update_with_gfa, library::update_with_library, + sequence::update_with_sequence, vcf::update_with_vcf, + }, + views::gen_graph_widget::{create_gen_graph_controller, create_gen_graph_widget}, +}; + +fn fixture(relative_path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative_path) +} + +/// Render the graph widget for (collection, sample) after the given setup closure runs, +/// and assert the result matches the stored snapshot for the calling test. +fn make_snapshot(collection: &str, sample: Option<&str>, setup: impl FnOnce(&DbContext)) { + let context = setup_gen(); + track_database(context.graph().conn(), context.operations().conn()) + .expect("track_database failed"); + setup(&context); + + let conn = context.graph(); + let graph = Sample::get_graph(conn.conn(), collection, sample); + let mut controller = create_gen_graph_controller(&graph); + + let area = Rect::new(0, 0, 80, 25); + controller.viewport_state.viewport_bounds = area; + controller.viewport_state.focus(); + + let mut terminal = create_test_terminal(area.width, area.height); + terminal + .draw(|f| { + let widget = create_gen_graph_widget(conn.conn()); + f.render_stateful_widget(widget, f.area(), &mut controller); + }) + .expect("render failed"); + // Derive the snapshot name from the test thread name so each calling test + // gets its own snapshot file even though assert_snapshot! is called here. + let test_name = std::thread::current() + .name() + .and_then(|n| n.rsplit("::").next()) + .unwrap_or("snapshot") + .to_owned(); + insta::assert_snapshot!(test_name, format!("{}", terminal.backend())); +} + +// --- GFA import snapshots --- + +#[test] +fn import_simple_gfa() { + make_snapshot("test", None, |ctx| { + import_gfa(ctx, &fixture("fixtures/simple.gfa"), "test", None).expect("import failed"); + }); +} + +#[test] +fn import_no_path_gfa() { + make_snapshot("no path", None, |ctx| { + import_gfa(ctx, &fixture("fixtures/no_path.gfa"), "no path", None).expect("import failed"); + }); +} + +#[test] +fn import_walk_gfa() { + make_snapshot("walk", None, |ctx| { + import_gfa(ctx, &fixture("fixtures/walk.gfa"), "walk", None).expect("import failed"); + }); +} + +#[test] +fn import_reverse_strand_gfa() { + make_snapshot("test", None, |ctx| { + import_gfa(ctx, &fixture("fixtures/reverse_strand.gfa"), "test", None) + .expect("import failed"); + }); +} + +#[test] +fn import_cycle_no_path() { + make_snapshot("/", None, |ctx| { + import_gfa(ctx, &fixture("fixtures/gfa/cycle_no_path.gfa"), "/", None) + .expect("import failed"); + }); +} + +#[test] +fn import_cycle_with_path() { + make_snapshot("/", None, |ctx| { + import_gfa(ctx, &fixture("fixtures/gfa/cycle_with_path.gfa"), "/", None) + .expect("import failed"); + }); +} + +// --- GFA update snapshots --- + +#[test] +fn update_with_gfa_path_diff() { + make_snapshot("test", Some("applied diff"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_gfa( + ctx, + "test", + None, + "applied diff", + fixture("fixtures/path-diff.gfa").to_str().unwrap(), + ) + .expect("gfa update failed"); + }); +} + +#[test] +fn update_with_gfa_walk_diff() { + make_snapshot("test", Some("applied diff"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_gfa( + ctx, + "test", + None, + "applied diff", + fixture("fixtures/walk-diff.gfa").to_str().unwrap(), + ) + .expect("gfa update failed"); + }); +} + +// --- FASTA update snapshots --- + +#[test] +fn update_fasta_with_fasta() { + // Graph after update: AT -> CGA -> TCGATCGATCGATCGGGAACACACAGAGA + // \-> AAAAAAAA ->/ + make_snapshot("test", Some("child sample"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_fasta( + ctx, + "test", + None, + "child sample", + "m123", + 2, + 5, + fixture("fixtures/aaaaaaaa.fa").to_str().unwrap(), + false, + ) + .expect("fasta update failed"); + }); +} + +// --- VCF update snapshots --- + +#[test] +fn update_fasta_with_vcf() { + make_snapshot("test", None, |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_vcf( + ctx, + &fixture("fixtures/simple.vcf") + .to_string_lossy() + .into_owned(), + "test", + "".to_string(), + "".to_string(), + None, + ) + .expect("vcf update failed"); + }); +} + +// --- Sequence update snapshots --- + +#[test] +fn update_fasta_with_sequence() { + // Graph after update: AT -> CGA -> TCGATCGATCGATCGGGAACACACAGAGA + // \-> AAAAAAAA ->/ + make_snapshot("test", Some("child sample"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_sequence( + ctx, + "test", + None, + "child sample", + "m123", + 2, + 5, + "AAAAAAAA", + false, + ) + .expect("sequence update failed"); + }); +} + +// --- Library import snapshots --- + +#[test] +fn import_library_affix() { + make_snapshot("test", None, |ctx| { + import_library( + ctx, + "test", + None, + fixture("fixtures/affix_parts.fa").to_str().unwrap(), + fixture("fixtures/affix_layout.csv").to_str().unwrap(), + "library graph", + ) + .expect("library import failed"); + }); +} + +#[test] +fn import_library_single_column() { + make_snapshot("test", None, |ctx| { + import_library( + ctx, + "test", + None, + fixture("fixtures/parts.fa").to_str().unwrap(), + fixture("fixtures/single_column_design.csv") + .to_str() + .unwrap(), + "m123", + ) + .expect("library import failed"); + }); +} + +#[test] +fn import_library_two_columns() { + make_snapshot("test", None, |ctx| { + import_library( + ctx, + "test", + None, + fixture("fixtures/parts.fa").to_str().unwrap(), + fixture("fixtures/design_reusing_parts.csv") + .to_str() + .unwrap(), + "m123", + ) + .expect("library import failed"); + }); +} + +// --- Library update snapshots --- + +#[test] +fn update_with_library_pool() { + make_snapshot("test", Some("new sample"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_library( + ctx, + "test", + None, + "new sample", + "m123", + 7, + 20, + fixture("fixtures/parts.fa").to_str().unwrap(), + fixture("fixtures/combinatorial_design.csv") + .to_str() + .unwrap(), + ) + .expect("library update failed"); + }); +} + +#[test] +fn update_with_library_single_column() { + make_snapshot("test", Some("new sample"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_library( + ctx, + "test", + None, + "new sample", + "m123", + 7, + 20, + fixture("fixtures/parts.fa").to_str().unwrap(), + fixture("fixtures/single_column_design.csv") + .to_str() + .unwrap(), + ) + .expect("library update failed"); + }); +} + +#[test] +fn update_with_library_two_columns() { + make_snapshot("test", Some("new sample"), |ctx| { + import_fasta( + ctx, + &fixture("fixtures/simple.fa").to_string_lossy().into_owned(), + "test", + None, + false, + ) + .expect("fasta import failed"); + update_with_library( + ctx, + "test", + None, + "new sample", + "m123", + 7, + 20, + fixture("fixtures/parts.fa").to_str().unwrap(), + fixture("fixtures/design_reusing_parts.csv") + .to_str() + .unwrap(), + ) + .expect("library update failed"); + }); +} diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_no_path_loopback.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_no_path_loopback.snap new file mode 100644 index 00000000..d7d4ea57 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_no_path_loopback.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭──────◀───────────────◀──────╮ " +" ╰─╮ ╭─╯ " +" ╭───Start >───┴─AAA─CCC─TTT─GGG─ACT─CTA─┴────> End──╮ " +" │ │ " +" ╰─◀───────────────◀───────────────◀───────────────◀─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_with_path_loopback.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_with_path_loopback.snap new file mode 100644 index 00000000..630da307 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__cycle_with_path_loopback.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >───┬──TTT─GGG─ACT─CTA────╮ " +" ╭─╯ │ " +" ╭─▶─AAA───CCC───┴───> End │ " +" │ │ " +" ╰───◀───────────────◀───────────────◀───╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_no_path.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_no_path.snap new file mode 100644 index 00000000..d7d4ea57 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_no_path.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭──────◀───────────────◀──────╮ " +" ╰─╮ ╭─╯ " +" ╭───Start >───┴─AAA─CCC─TTT─GGG─ACT─CTA─┴────> End──╮ " +" │ │ " +" ╰─◀───────────────◀───────────────◀───────────────◀─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_with_path.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_with_path.snap new file mode 100644 index 00000000..630da307 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_cycle_with_path.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >───┬──TTT─GGG─ACT─CTA────╮ " +" ╭─╯ │ " +" ╭─▶─AAA───CCC───┴───> End │ " +" │ │ " +" ╰───◀───────────────◀───────────────◀───╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_affix.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_affix.snap new file mode 100644 index 00000000..e599319e --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_affix.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" ╭─TCTAG...CTAG─╮ " +" │ │ " +" ├─TCTAG...CTAG─┤ " +" │ │ " +" ├─TCTAG...CTAG─┤ " +" │ │ " +" ├─TCTAG...CTAG─┼─ATGAG...GTAA─╮ " +" Start >─┤ │ ├─> End " +" ├─TCTAG...CTAG─┼─ATGCG...TTAA─╯ " +" │ │ " +" ├─TCTAG...CTAG─┤ " +" │ │ " +" ├─TCTAG...CTAG─┤ " +" │ │ " +" ╰─TCTAG...CTAG─╯ " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_single_column.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_single_column.snap new file mode 100644 index 00000000..544d0afc --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_single_column.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─TAAT─╮ " +" │ │ " +" Start >─┼─CAAC─┼─> End " +" │ │ " +" ╰─AAAA─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_two_columns.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_two_columns.snap new file mode 100644 index 00000000..880480f8 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_library_two_columns.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─TAAT─┬─TAAT─╮ " +" │ │ │ " +" Start >─┼─CAAC─┼─CAAC─┼─> End " +" │ │ │ " +" ╰─AAAA─┴─AAAA─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_no_path_gfa.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_no_path_gfa.snap new file mode 100644 index 00000000..46f4c588 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_no_path_gfa.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >─AAAA─TTTT─GGGG─CCCC─> End " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_reverse_strand_gfa.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_reverse_strand_gfa.snap new file mode 100644 index 00000000..f28cc532 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_reverse_strand_gfa.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >─A─T─GGCA─TATTCGCAGCT─> End " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_simple_gfa.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_simple_gfa.snap new file mode 100644 index 00000000..9d495e22 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_simple_gfa.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >─ATC─GATCGATCGA─TCGATCGGG─AACACACAGAGA─> End " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_walk_gfa.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_walk_gfa.snap new file mode 100644 index 00000000..ebc18818 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__import_walk_gfa.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >─ACCT─ACAA─ATTC─AAAC─> End " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_fasta.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_fasta.snap new file mode 100644 index 00000000..80ccd328 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_fasta.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─AAAAAAAA─╮ " +" Start >─AT─┤ ├─TCGAT...GAGA─> End " +" ╰───CGA────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_sequence.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_sequence.snap new file mode 100644 index 00000000..80ccd328 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_sequence.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─AAAAAAAA─╮ " +" Start >─AT─┤ ├─TCGAT...GAGA─> End " +" ╰───CGA────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_vcf.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_vcf.snap new file mode 100644 index 00000000..bb85f775 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_fasta_with_vcf.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Start >─ATCGA...GAGA─> End " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_path_diff.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_path_diff.snap new file mode 100644 index 00000000..80ccd328 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_path_diff.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─AAAAAAAA─╮ " +" Start >─AT─┤ ├─TCGAT...GAGA─> End " +" ╰───CGA────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_walk_diff.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_walk_diff.snap new file mode 100644 index 00000000..80ccd328 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_gfa_walk_diff.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─AAAAAAAA─╮ " +" Start >─AT─┤ ├─TCGAT...GAGA─> End " +" ╰───CGA────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_pool.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_pool.snap new file mode 100644 index 00000000..61a56a91 --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_pool.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" ╭──────GATCG...ATCG──────╮ " +" │ │ " +" │ │ " +" ├─────TAAT─────┬─ATGATAA─┤ " +" Start >─ATCGATC─┤ │ ├─GGAAC...GAGA─> End " +" ├─────CAAC─────┼─ATGTTAA─┤ " +" │ │ │ " +" ╰─────AAAA─────┴─ATGCTAA─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_single_column.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_single_column.snap new file mode 100644 index 00000000..51efe4af --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_single_column.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" ╭─GATCG...ATCG─╮ " +" │ │ " +" ├─────TAAT─────┤ " +" Start >─ATCGATC─┤ ├─GGAAC...GAGA─> End " +" ├─────CAAC─────┤ " +" │ │ " +" ╰─────AAAA─────╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_two_columns.snap b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_two_columns.snap new file mode 100644 index 00000000..90f01e8f --- /dev/null +++ b/src/views/testing/snapshots/r#gen__views__testing__snapshot_tests__update_with_library_two_columns.snap @@ -0,0 +1,29 @@ +--- +source: src/views/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" ╭────GATCG...ATCG─────╮ " +" │ │ " +" │ │ " +" ├─────TAAT─────┬─TAAT─┤ " +" Start >─ATCGATC─┤ │ ├─GGAAC...GAGA─> End " +" ├─────CAAC─────┼─CAAC─┤ " +" │ │ │ " +" ╰─────AAAA─────┴─AAAA─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" "