Skip to main content

ftui_widgets/focus/
graph.rs

1#![forbid(unsafe_code)]
2
3//! Directed graph for focus navigation relationships.
4//!
5//! Each node represents a focusable widget identified by a [`FocusId`].
6//! Edges encode navigation direction (up/down/left/right/next/prev).
7//!
8//! # Invariants
9//!
10//! 1. Node IDs are unique within the graph.
11//! 2. Removing a node removes all edges incident on it (both incoming and outgoing).
12//! 3. Edges reference only nodes that exist in the graph.
13//! 4. Tab-order traversal visits nodes in ascending `tab_index` order,
14//!    breaking ties by insertion order (via `FocusId`).
15//!
16//! # Complexity
17//!
18//! | Operation | Time |
19//! |-----------|------|
20//! | insert | O(1) |
21//! | remove | O(E) worst case (cleans incoming edges) |
22//! | connect | O(1) |
23//! | navigate | O(1) |
24//! | find_cycle | O(V+E) |
25
26use ahash::AHashMap;
27use ftui_core::geometry::Rect;
28
29/// Unique identifier for a focusable widget.
30pub type FocusId = u64;
31
32/// Navigation direction for focus traversal.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub enum NavDirection {
35    Up,
36    Down,
37    Left,
38    Right,
39    /// Tab order: forward.
40    Next,
41    /// Tab order: backward.
42    Prev,
43}
44
45impl NavDirection {
46    /// All six directions.
47    pub const ALL: [NavDirection; 6] = [
48        NavDirection::Up,
49        NavDirection::Down,
50        NavDirection::Left,
51        NavDirection::Right,
52        NavDirection::Next,
53        NavDirection::Prev,
54    ];
55}
56
57/// A focusable node in the graph.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct FocusNode {
60    /// Unique identifier.
61    pub id: FocusId,
62    /// Bounding rectangle in terminal coordinates.
63    pub bounds: Rect,
64    /// Tab index for sequential navigation. Negative values skip tab order.
65    pub tab_index: i32,
66    /// Whether this node can receive focus.
67    pub is_focusable: bool,
68    /// Optional group for focus trapping regions.
69    pub group_id: Option<u32>,
70}
71
72impl FocusNode {
73    /// Create a new focusable node.
74    #[must_use]
75    pub fn new(id: FocusId, bounds: Rect) -> Self {
76        Self {
77            id,
78            bounds,
79            tab_index: 0,
80            is_focusable: true,
81            group_id: None,
82        }
83    }
84
85    /// Builder: set tab index.
86    #[must_use]
87    pub fn with_tab_index(mut self, idx: i32) -> Self {
88        self.tab_index = idx;
89        self
90    }
91
92    /// Builder: set focusable flag.
93    #[must_use]
94    pub fn with_focusable(mut self, focusable: bool) -> Self {
95        self.is_focusable = focusable;
96        self
97    }
98
99    /// Builder: set group.
100    #[must_use]
101    pub fn with_group(mut self, group: u32) -> Self {
102        self.group_id = Some(group);
103        self
104    }
105}
106
107/// Directed graph for focus navigation.
108///
109/// Nodes are focusable widgets; edges encode directional navigation.
110/// The graph is sparse (most nodes have ≤6 outgoing edges).
111#[derive(Debug, Default)]
112pub struct FocusGraph {
113    nodes: AHashMap<FocusId, FocusNode>,
114    /// Outgoing edges: (from, direction) → to.
115    edges: AHashMap<(FocusId, NavDirection), FocusId>,
116}
117
118impl FocusGraph {
119    /// Create an empty graph.
120    #[must_use]
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Insert a node. Returns the node's ID.
126    ///
127    /// If a node with the same ID exists, it is replaced.
128    pub fn insert(&mut self, node: FocusNode) -> FocusId {
129        let id = node.id;
130        self.nodes.insert(id, node);
131        id
132    }
133
134    /// Remove a node and all edges incident on it.
135    ///
136    /// Returns the removed node, or `None` if not present.
137    #[must_use = "use the removed node (if any)"]
138    pub fn remove(&mut self, id: FocusId) -> Option<FocusNode> {
139        let node = self.nodes.remove(&id)?;
140
141        // Remove outgoing edges from this node.
142        for dir in NavDirection::ALL {
143            self.edges.remove(&(id, dir));
144        }
145
146        // Remove incoming edges pointing to this node.
147        self.edges.retain(|_, target| *target != id);
148
149        Some(node)
150    }
151
152    /// Connect two nodes: navigating `dir` from `from` leads to `to`.
153    ///
154    /// Both nodes must already exist. Silently no-ops if either is missing.
155    pub fn connect(&mut self, from: FocusId, dir: NavDirection, to: FocusId) {
156        if self.nodes.contains_key(&from) && self.nodes.contains_key(&to) {
157            self.edges.insert((from, dir), to);
158        }
159    }
160
161    /// Disconnect an edge.
162    pub fn disconnect(&mut self, from: FocusId, dir: NavDirection) {
163        self.edges.remove(&(from, dir));
164    }
165
166    /// Navigate from a node in a direction.
167    ///
168    /// Returns the target node ID, or `None` if no edge exists.
169    #[must_use = "use the returned target id (if any)"]
170    pub fn navigate(&self, from: FocusId, dir: NavDirection) -> Option<FocusId> {
171        self.edges.get(&(from, dir)).copied()
172    }
173
174    /// Look up a node by ID.
175    #[must_use = "use the returned node (if any)"]
176    pub fn get(&self, id: FocusId) -> Option<&FocusNode> {
177        self.nodes.get(&id)
178    }
179
180    /// Number of nodes.
181    #[must_use]
182    pub fn node_count(&self) -> usize {
183        self.nodes.len()
184    }
185
186    /// Number of edges.
187    #[must_use]
188    pub fn edge_count(&self) -> usize {
189        self.edges.len()
190    }
191
192    /// Whether the graph is empty (no nodes).
193    #[inline]
194    #[must_use]
195    pub fn is_empty(&self) -> bool {
196        self.nodes.is_empty()
197    }
198
199    /// All node IDs.
200    pub fn node_ids(&self) -> impl Iterator<Item = FocusId> + '_ {
201        self.nodes.keys().copied()
202    }
203
204    /// Nodes in tab order (ascending `tab_index`, ties broken by ID).
205    /// Skips nodes with `tab_index < 0` or `!is_focusable`.
206    pub fn tab_order(&self) -> Vec<FocusId> {
207        let mut ordered: Vec<_> = self
208            .nodes
209            .values()
210            .filter(|n| n.is_focusable && n.tab_index >= 0)
211            .collect();
212        ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
213        ordered.iter().map(|n| n.id).collect()
214    }
215
216    /// Nodes in a specific group, in tab order.
217    pub fn group_tab_order(&self, group: u32) -> Vec<FocusId> {
218        let mut ordered: Vec<_> = self
219            .nodes
220            .values()
221            .filter(|n| n.is_focusable && n.tab_index >= 0 && n.group_id == Some(group))
222            .collect();
223        ordered.sort_by(|a, b| a.tab_index.cmp(&b.tab_index).then(a.id.cmp(&b.id)));
224        ordered.iter().map(|n| n.id).collect()
225    }
226
227    /// Detect a cycle reachable from `start` following `Next` edges.
228    ///
229    /// Returns `Some(cycle)` where `cycle` is the sequence of node IDs
230    /// forming the cycle (starting and ending at the same node), or `None`.
231    ///
232    /// Uses tortoise-and-hare for O(V) time, O(1) extra space.
233    #[must_use = "use the returned cycle (if any) to diagnose invalid focus graphs"]
234    pub fn find_cycle(&self, start: FocusId) -> Option<Vec<FocusId>> {
235        // Phase 1: detect cycle with Floyd's algorithm.
236        let mut slow = start;
237        let mut fast = start;
238
239        loop {
240            slow = self.navigate(slow, NavDirection::Next)?;
241            fast = self.navigate(fast, NavDirection::Next)?;
242            fast = self.navigate(fast, NavDirection::Next)?;
243            if slow == fast {
244                break;
245            }
246        }
247
248        // Phase 2: find cycle start.
249        let mut p1 = start;
250        let mut p2 = slow;
251        while p1 != p2 {
252            p1 = self.navigate(p1, NavDirection::Next)?;
253            p2 = self.navigate(p2, NavDirection::Next)?;
254        }
255
256        // Phase 3: collect cycle.
257        let cycle_start = p1;
258        let mut cycle = vec![cycle_start];
259        let mut current = self.navigate(cycle_start, NavDirection::Next)?;
260        while current != cycle_start {
261            cycle.push(current);
262            current = self.navigate(current, NavDirection::Next)?;
263        }
264        cycle.push(cycle_start); // close the cycle
265
266        Some(cycle)
267    }
268
269    /// Detect cycles reachable from `start` following any single direction.
270    ///
271    /// More general than `find_cycle` (which only follows `Next`).
272    #[must_use = "use the returned cycle (if any) to diagnose invalid focus graphs"]
273    pub fn find_cycle_in_direction(
274        &self,
275        start: FocusId,
276        dir: NavDirection,
277    ) -> Option<Vec<FocusId>> {
278        let mut slow = start;
279        let mut fast = start;
280
281        loop {
282            slow = self.navigate(slow, dir)?;
283            fast = self.navigate(fast, dir)?;
284            fast = self.navigate(fast, dir)?;
285            if slow == fast {
286                break;
287            }
288        }
289
290        let mut p1 = start;
291        let mut p2 = slow;
292        while p1 != p2 {
293            p1 = self.navigate(p1, dir)?;
294            p2 = self.navigate(p2, dir)?;
295        }
296
297        let cycle_start = p1;
298        let mut cycle = vec![cycle_start];
299        let mut current = self.navigate(cycle_start, dir)?;
300        while current != cycle_start {
301            cycle.push(current);
302            current = self.navigate(current, dir)?;
303        }
304        cycle.push(cycle_start);
305
306        Some(cycle)
307    }
308
309    /// Build bidirectional `Next`/`Prev` chain from the current tab order.
310    ///
311    /// Overwrites existing `Next`/`Prev` edges. If `wrap` is true, the
312    /// last node links back to the first (and vice versa).
313    pub fn build_tab_chain(&mut self, wrap: bool) {
314        self.edges
315            .retain(|(_, dir), _| *dir != NavDirection::Next && *dir != NavDirection::Prev);
316        let order = self.tab_order();
317        if order.len() < 2 {
318            return;
319        }
320
321        for pair in order.windows(2) {
322            self.edges.insert((pair[0], NavDirection::Next), pair[1]);
323            self.edges.insert((pair[1], NavDirection::Prev), pair[0]);
324        }
325
326        if wrap {
327            let first = order[0];
328            let last = *order.last().unwrap();
329            self.edges.insert((last, NavDirection::Next), first);
330            self.edges.insert((first, NavDirection::Prev), last);
331        }
332    }
333
334    /// Clear all nodes and edges.
335    pub fn clear(&mut self) {
336        self.nodes.clear();
337        self.edges.clear();
338    }
339}
340
341// =========================================================================
342// Tests
343// =========================================================================
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
350        Rect::new(x, y, w, h)
351    }
352
353    fn node(id: FocusId) -> FocusNode {
354        FocusNode::new(id, rect(0, 0, 10, 1))
355    }
356
357    // --- Basic functionality ---
358
359    #[test]
360    fn empty_graph() {
361        let g = FocusGraph::new();
362        assert!(g.is_empty());
363        assert_eq!(g.node_count(), 0);
364        assert_eq!(g.edge_count(), 0);
365    }
366
367    #[test]
368    fn insert_node() {
369        let mut g = FocusGraph::new();
370        let id = g.insert(node(1));
371        assert_eq!(id, 1);
372        assert_eq!(g.node_count(), 1);
373        assert!(g.get(1).is_some());
374    }
375
376    #[test]
377    fn insert_replaces_existing() {
378        let mut g = FocusGraph::new();
379        g.insert(FocusNode::new(1, rect(0, 0, 10, 1)));
380        g.insert(FocusNode::new(1, rect(5, 5, 20, 2)));
381        assert_eq!(g.node_count(), 1);
382        assert_eq!(g.get(1).unwrap().bounds, rect(5, 5, 20, 2));
383    }
384
385    #[test]
386    fn remove_node() {
387        let mut g = FocusGraph::new();
388        g.insert(node(1));
389        let removed = g.remove(1);
390        assert!(removed.is_some());
391        assert_eq!(removed.unwrap().id, 1);
392        assert!(g.is_empty());
393    }
394
395    #[test]
396    fn remove_nonexistent() {
397        let mut g = FocusGraph::new();
398        assert!(g.remove(42).is_none());
399    }
400
401    // --- Navigation ---
402
403    #[test]
404    fn navigate_connected() {
405        let mut g = FocusGraph::new();
406        g.insert(node(1));
407        g.insert(node(2));
408        g.connect(1, NavDirection::Right, 2);
409
410        assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
411    }
412
413    #[test]
414    fn navigate_unconnected() {
415        let mut g = FocusGraph::new();
416        g.insert(node(1));
417        assert_eq!(g.navigate(1, NavDirection::Up), None);
418    }
419
420    #[test]
421    fn navigate_nonexistent_node() {
422        let g = FocusGraph::new();
423        assert_eq!(g.navigate(99, NavDirection::Down), None);
424    }
425
426    #[test]
427    fn connect_ignores_missing_nodes() {
428        let mut g = FocusGraph::new();
429        g.insert(node(1));
430        g.connect(1, NavDirection::Next, 99); // 99 doesn't exist
431        assert_eq!(g.navigate(1, NavDirection::Next), None);
432        assert_eq!(g.edge_count(), 0);
433    }
434
435    #[test]
436    fn disconnect_edge() {
437        let mut g = FocusGraph::new();
438        g.insert(node(1));
439        g.insert(node(2));
440        g.connect(1, NavDirection::Right, 2);
441        assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
442
443        g.disconnect(1, NavDirection::Right);
444        assert_eq!(g.navigate(1, NavDirection::Right), None);
445    }
446
447    // --- Remove cleans edges ---
448
449    #[test]
450    fn remove_cleans_outgoing_edges() {
451        let mut g = FocusGraph::new();
452        g.insert(node(1));
453        g.insert(node(2));
454        g.connect(1, NavDirection::Next, 2);
455        let _ = g.remove(1);
456        // Edge (1, Next) should be gone.
457        assert_eq!(g.edge_count(), 0);
458    }
459
460    #[test]
461    fn remove_cleans_incoming_edges() {
462        let mut g = FocusGraph::new();
463        g.insert(node(1));
464        g.insert(node(2));
465        g.connect(1, NavDirection::Next, 2);
466        let _ = g.remove(2);
467        // Edge (1, Next) → 2 should be removed because target is gone.
468        assert_eq!(g.edge_count(), 0);
469        assert_eq!(g.navigate(1, NavDirection::Next), None);
470    }
471
472    // --- Tab order ---
473
474    #[test]
475    fn tab_order_sorts_by_index_then_id() {
476        let mut g = FocusGraph::new();
477        g.insert(node(3).with_tab_index(1));
478        g.insert(node(1).with_tab_index(0));
479        g.insert(node(2).with_tab_index(0));
480
481        let order = g.tab_order();
482        assert_eq!(order, vec![1, 2, 3]);
483    }
484
485    #[test]
486    fn tab_order_skips_negative_index() {
487        let mut g = FocusGraph::new();
488        g.insert(node(1).with_tab_index(0));
489        g.insert(node(2).with_tab_index(-1));
490        g.insert(node(3).with_tab_index(1));
491
492        let order = g.tab_order();
493        assert_eq!(order, vec![1, 3]);
494    }
495
496    #[test]
497    fn tab_order_skips_unfocusable() {
498        let mut g = FocusGraph::new();
499        g.insert(node(1));
500        g.insert(node(2).with_focusable(false));
501        g.insert(node(3));
502
503        let order = g.tab_order();
504        assert_eq!(order, vec![1, 3]);
505    }
506
507    // --- Group tab order ---
508
509    #[test]
510    fn group_tab_order_filters_by_group() {
511        let mut g = FocusGraph::new();
512        g.insert(node(1).with_group(1));
513        g.insert(node(2).with_group(2));
514        g.insert(node(3).with_group(1).with_tab_index(1));
515
516        let order = g.group_tab_order(1);
517        assert_eq!(order, vec![1, 3]);
518    }
519
520    // --- Build tab chain ---
521
522    #[test]
523    fn build_tab_chain_no_wrap() {
524        let mut g = FocusGraph::new();
525        g.insert(node(1).with_tab_index(0));
526        g.insert(node(2).with_tab_index(1));
527        g.insert(node(3).with_tab_index(2));
528
529        g.build_tab_chain(false);
530
531        assert_eq!(g.navigate(1, NavDirection::Next), Some(2));
532        assert_eq!(g.navigate(2, NavDirection::Next), Some(3));
533        assert_eq!(g.navigate(3, NavDirection::Next), None);
534
535        assert_eq!(g.navigate(3, NavDirection::Prev), Some(2));
536        assert_eq!(g.navigate(2, NavDirection::Prev), Some(1));
537        assert_eq!(g.navigate(1, NavDirection::Prev), None);
538    }
539
540    #[test]
541    fn build_tab_chain_wrap() {
542        let mut g = FocusGraph::new();
543        g.insert(node(1).with_tab_index(0));
544        g.insert(node(2).with_tab_index(1));
545        g.insert(node(3).with_tab_index(2));
546
547        g.build_tab_chain(true);
548
549        assert_eq!(g.navigate(3, NavDirection::Next), Some(1));
550        assert_eq!(g.navigate(1, NavDirection::Prev), Some(3));
551    }
552
553    #[test]
554    fn build_tab_chain_single_node_noop() {
555        let mut g = FocusGraph::new();
556        g.insert(node(1));
557        g.build_tab_chain(true);
558        assert_eq!(g.edge_count(), 0);
559    }
560
561    // --- Cycle detection ---
562
563    #[test]
564    fn no_cycle_linear() {
565        let mut g = FocusGraph::new();
566        g.insert(node(1));
567        g.insert(node(2));
568        g.insert(node(3));
569        g.connect(1, NavDirection::Next, 2);
570        g.connect(2, NavDirection::Next, 3);
571
572        assert!(g.find_cycle(1).is_none());
573    }
574
575    #[test]
576    fn simple_cycle() {
577        let mut g = FocusGraph::new();
578        g.insert(node(1));
579        g.insert(node(2));
580        g.insert(node(3));
581        g.connect(1, NavDirection::Next, 2);
582        g.connect(2, NavDirection::Next, 3);
583        g.connect(3, NavDirection::Next, 1);
584
585        let cycle = g.find_cycle(1);
586        assert!(cycle.is_some());
587        let c = cycle.unwrap();
588        // Cycle should start and end with the same node.
589        assert_eq!(c.first(), c.last());
590        assert_eq!(c.len(), 4); // 1 → 2 → 3 → 1
591    }
592
593    #[test]
594    fn self_loop_cycle() {
595        let mut g = FocusGraph::new();
596        g.insert(node(1));
597        g.connect(1, NavDirection::Next, 1);
598
599        let cycle = g.find_cycle(1);
600        assert!(cycle.is_some());
601        let c = cycle.unwrap();
602        assert_eq!(c, vec![1, 1]);
603    }
604
605    #[test]
606    fn cycle_in_middle() {
607        // 1 → 2 → 3 → 4 → 2 (cycle at 2-3-4)
608        let mut g = FocusGraph::new();
609        for id in 1..=4 {
610            g.insert(node(id));
611        }
612        g.connect(1, NavDirection::Next, 2);
613        g.connect(2, NavDirection::Next, 3);
614        g.connect(3, NavDirection::Next, 4);
615        g.connect(4, NavDirection::Next, 2);
616
617        let cycle = g.find_cycle(1);
618        assert!(cycle.is_some());
619        let c = cycle.unwrap();
620        assert_eq!(c.first(), c.last());
621        // The cycle is 2 → 3 → 4 → 2
622        assert_eq!(c.len(), 4);
623        assert!(c.contains(&2));
624        assert!(c.contains(&3));
625        assert!(c.contains(&4));
626    }
627
628    #[test]
629    fn find_cycle_from_nonexistent_start() {
630        let g = FocusGraph::new();
631        assert!(g.find_cycle(99).is_none());
632    }
633
634    #[test]
635    fn find_cycle_in_direction_right() {
636        let mut g = FocusGraph::new();
637        g.insert(node(1));
638        g.insert(node(2));
639        g.connect(1, NavDirection::Right, 2);
640        g.connect(2, NavDirection::Right, 1);
641
642        let cycle = g.find_cycle_in_direction(1, NavDirection::Right);
643        assert!(cycle.is_some());
644    }
645
646    // --- Clear ---
647
648    #[test]
649    fn clear_empties_graph() {
650        let mut g = FocusGraph::new();
651        g.insert(node(1));
652        g.insert(node(2));
653        g.connect(1, NavDirection::Next, 2);
654        g.clear();
655        assert!(g.is_empty());
656        assert_eq!(g.edge_count(), 0);
657    }
658
659    // --- Node builder ---
660
661    #[test]
662    fn node_builder_defaults() {
663        let n = FocusNode::new(1, rect(0, 0, 10, 1));
664        assert_eq!(n.tab_index, 0);
665        assert!(n.is_focusable);
666        assert!(n.group_id.is_none());
667    }
668
669    #[test]
670    fn node_builder_chain() {
671        let n = FocusNode::new(1, rect(0, 0, 10, 1))
672            .with_tab_index(5)
673            .with_focusable(false)
674            .with_group(3);
675        assert_eq!(n.tab_index, 5);
676        assert!(!n.is_focusable);
677        assert_eq!(n.group_id, Some(3));
678    }
679
680    // --- Edge cases ---
681
682    #[test]
683    fn multiple_directions_same_source() {
684        let mut g = FocusGraph::new();
685        g.insert(node(1));
686        g.insert(node(2));
687        g.insert(node(3));
688        g.connect(1, NavDirection::Right, 2);
689        g.connect(1, NavDirection::Down, 3);
690
691        assert_eq!(g.navigate(1, NavDirection::Right), Some(2));
692        assert_eq!(g.navigate(1, NavDirection::Down), Some(3));
693        assert_eq!(g.edge_count(), 2);
694    }
695
696    #[test]
697    fn overwrite_edge() {
698        let mut g = FocusGraph::new();
699        g.insert(node(1));
700        g.insert(node(2));
701        g.insert(node(3));
702        g.connect(1, NavDirection::Next, 2);
703        g.connect(1, NavDirection::Next, 3);
704
705        assert_eq!(g.navigate(1, NavDirection::Next), Some(3));
706        assert_eq!(g.edge_count(), 1);
707    }
708
709    #[test]
710    fn node_ids_iteration() {
711        let mut g = FocusGraph::new();
712        g.insert(node(10));
713        g.insert(node(20));
714        g.insert(node(30));
715
716        let mut ids: Vec<_> = g.node_ids().collect();
717        ids.sort();
718        assert_eq!(ids, vec![10, 20, 30]);
719    }
720
721    // --- Property-style tests ---
722
723    #[test]
724    fn property_remove_insert_idempotent() {
725        let mut g = FocusGraph::new();
726        let n = node(1);
727        g.insert(n.clone());
728        let _ = g.remove(1);
729        g.insert(n.clone());
730        assert_eq!(g.node_count(), 1);
731        assert_eq!(g.get(1).unwrap().id, 1);
732    }
733
734    #[test]
735    fn property_tab_chain_wrap_forms_cycle() {
736        let mut g = FocusGraph::new();
737        for i in 1..=5 {
738            g.insert(node(i).with_tab_index(i as i32));
739        }
740        g.build_tab_chain(true);
741
742        let cycle = g.find_cycle(1);
743        assert!(cycle.is_some());
744        let c = cycle.unwrap();
745        assert_eq!(c.len(), 6); // 5 nodes + closing node
746    }
747
748    #[test]
749    fn property_tab_chain_no_wrap_no_cycle() {
750        let mut g = FocusGraph::new();
751        for i in 1..=5 {
752            g.insert(node(i).with_tab_index(i as i32));
753        }
754        g.build_tab_chain(false);
755
756        assert!(g.find_cycle(1).is_none());
757    }
758
759    #[test]
760    fn property_bidirectional_chain_consistency() {
761        let mut g = FocusGraph::new();
762        for i in 1..=4 {
763            g.insert(node(i).with_tab_index(i as i32));
764        }
765        g.build_tab_chain(false);
766
767        // For every Next edge a→b, there should be a Prev edge b→a.
768        for id in 1..=3u64 {
769            let next = g.navigate(id, NavDirection::Next).unwrap();
770            let prev_of_next = g.navigate(next, NavDirection::Prev).unwrap();
771            assert_eq!(prev_of_next, id);
772        }
773    }
774
775    // --- Stress ---
776
777    #[test]
778    fn stress_many_nodes() {
779        let mut g = FocusGraph::new();
780        for i in 0..1000 {
781            g.insert(node(i).with_tab_index(i as i32));
782        }
783        g.build_tab_chain(true);
784
785        assert_eq!(g.node_count(), 1000);
786        // Navigate the full ring.
787        let mut current = 0;
788        for _ in 0..1000 {
789            current = g.navigate(current, NavDirection::Next).unwrap();
790        }
791        assert_eq!(current, 0); // Full cycle back to start.
792    }
793
794    // --- Perf gates ---
795
796    #[test]
797    fn perf_insert_1000_nodes() {
798        let start = std::time::Instant::now();
799        let mut g = FocusGraph::new();
800        for i in 0..1000 {
801            g.insert(node(i));
802        }
803        let elapsed = start.elapsed();
804        assert!(
805            elapsed.as_micros() < 5000,
806            "Inserting 1000 nodes took {}μs (budget: 5000μs)",
807            elapsed.as_micros()
808        );
809    }
810
811    #[test]
812    fn perf_navigate_10000() {
813        let mut g = FocusGraph::new();
814        for i in 0..100 {
815            g.insert(node(i).with_tab_index(i as i32));
816        }
817        g.build_tab_chain(true);
818
819        let start = std::time::Instant::now();
820        let mut current = 0;
821        for _ in 0..10_000 {
822            current = g.navigate(current, NavDirection::Next).unwrap();
823        }
824        let elapsed = start.elapsed();
825        // Prevent optimizing away.
826        assert!(current < 100);
827        // Generous budget for shared CI/multi-agent environments.
828        let budget: u128 =
829            if std::env::var("CARGO_LLVM_COV").is_ok() || std::env::var("COVERAGE").is_ok() {
830                16_000
831            } else {
832                8_000
833            };
834        assert!(
835            elapsed.as_micros() < budget,
836            "10,000 navigations took {}μs (budget: {}μs)",
837            elapsed.as_micros(),
838            budget
839        );
840    }
841
842    #[test]
843    fn perf_cycle_detection_1000() {
844        let mut g = FocusGraph::new();
845        for i in 0..1000 {
846            g.insert(node(i).with_tab_index(i as i32));
847        }
848        g.build_tab_chain(true);
849
850        let start = std::time::Instant::now();
851        let cycle = g.find_cycle(0);
852        let elapsed = start.elapsed();
853
854        assert!(cycle.is_some());
855        assert!(
856            elapsed.as_micros() < 10_000,
857            "Cycle detection on 1000-node ring took {}μs (budget: 10000μs)",
858            elapsed.as_micros()
859        );
860    }
861}