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