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/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..0df4d5fd 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 !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,228 @@ 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 { + 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); + + // 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 { + (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('┆')) + && 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('┄')) + && 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..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); -} - -#[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); -} +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_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; - - use crate::{ - geometry::BigRect, - layout::{LayoutEngine, VisualDetail}, - partition::{PartitionEdge, PartitionNode}, - testing::mocks::FixedNodeSizer, - }; + init_test_logging(); - // 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,75 +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); -} 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__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__snapshot_tests__cycle_with_chord_pinned.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord_pinned.snap new file mode 100644 index 00000000..095e08b0 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__cycle_with_chord_pinned.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/snapshot_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__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_node_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_node_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_node_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_no_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_no_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_no_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_layer_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_layer_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_layer_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__snapshot_tests__pinned_source_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle.snap new file mode 100644 index 00000000..c006471e --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/snapshot_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__snapshot_tests__pinned_source_cycle_partitioned.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle_partitioned.snap new file mode 100644 index 00000000..3b48d8e8 --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__pinned_source_cycle_partitioned.snap @@ -0,0 +1,29 @@ +--- +source: gen-tui/src/testing/snapshot_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__snapshot_tests__self_loop.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap new file mode 100644 index 00000000..fc02e1df --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__self_loop.snap @@ -0,0 +1,24 @@ +--- +source: gen-tui/src/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" █████ " +" ╭─█N0██─╮ " +" │ █████ │ " +" │ │ " +" ╰───◀───╯ " +" " +" " +" " +" " +" " +" " +" " 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__snapshot_tests__simple_cycle.snap b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_cycle.snap new file mode 100644 index 00000000..1e08f24c --- /dev/null +++ b/gen-tui/src/testing/snapshots/gen_tui__testing__snapshot_tests__simple_cycle.snap @@ -0,0 +1,24 @@ +--- +source: gen-tui/src/testing/snapshot_tests.rs +expression: snapshot +--- +" " +" " +" " +" " +" " +" " +" " +" " +" █████ █████ █████ " +" ╭─█N0██─█N1██─█N2██─╮ " +" ▲ █████ █████ █████ │ " +" │ │ " +" ╰─◀───────────────◀─╯ " +" " +" " +" " +" " +" " +" " +" " 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 --- " " 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(()) } 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 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─╯ " +" " +" " +" " +" " +" " +" " +" " +" " +" "