diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 17:02:25 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 19:03:16 +0300 |
| commit | de0f43a061162a223c5946a4b50aa9f9ac7ab8d7 (patch) | |
| tree | f1267f8c3d0f1b7775a5ad513ee7353faa133546 /src | |
| parent | ea3a4db72f676ca180a8e0ca61c730c0cf9ed920 (diff) | |
test: add state invariants for refactor safety
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/state.rs | 89 | ||||
| -rw-r--r-- | src/workspace.rs | 200 |
2 files changed, 289 insertions, 0 deletions
diff --git a/src/app/state.rs b/src/app/state.rs index ddf5c39c..2482a06d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1762,6 +1762,80 @@ impl AppState { } } + pub fn test_with_adversarial_identity_state() -> Self { + let mut state = Self::test_new(); + state.workspaces = vec![crate::workspace::Workspace::test_adversarial_identity_state()]; + state.active = Some(0); + state.selected = 0; + state.ensure_test_terminals(); + state + } + + pub fn assert_invariants_for_test(&self) { + if self.workspaces.is_empty() { + assert!( + self.active.is_none(), + "empty app state must not have active workspace {:?}", + self.active + ); + assert_eq!( + self.selected, 0, + "empty app state should keep selected workspace at 0" + ); + return; + } + + assert!( + self.selected < self.workspaces.len(), + "selected workspace {} out of bounds for {} workspaces", + self.selected, + self.workspaces.len() + ); + let active = self + .active + .expect("non-empty app state must have active workspace"); + assert!( + active < self.workspaces.len(), + "active workspace {} out of bounds for {} workspaces", + active, + self.workspaces.len() + ); + + let mut workspace_ids = std::collections::HashSet::new(); + let mut pane_ids = std::collections::HashSet::new(); + let mut attached_terminal_ids = std::collections::HashSet::new(); + for (ws_idx, ws) in self.workspaces.iter().enumerate() { + assert!( + workspace_ids.insert(ws.id.clone()), + "duplicate workspace id {} at workspace index {}", + ws.id, + ws_idx + ); + ws.assert_invariants_for_test(); + + for tab in &ws.tabs { + for (pane_id, pane) in &tab.panes { + assert!( + pane_ids.insert(*pane_id), + "pane {:?} appears in more than one workspace", + pane_id + ); + assert!( + attached_terminal_ids.insert(pane.attached_terminal_id.clone()), + "terminal {} is attached to more than one app pane", + pane.attached_terminal_id + ); + assert!( + self.terminals.contains_key(&pane.attached_terminal_id), + "pane {:?} is attached to missing terminal {}", + pane_id, + pane.attached_terminal_id + ); + } + } + } + } + pub fn insert_test_runtime( &mut self, pane_id: crate::layout::PaneId, @@ -1805,6 +1879,21 @@ mod tests { } #[test] + fn adversarial_identity_state_satisfies_app_invariants_after_mutation() { + let mut state = AppState::test_with_adversarial_identity_state(); + state.assert_invariants_for_test(); + + let ws = &mut state.workspaces[0]; + let active_public = ws.tabs[ws.active_tab].number; + assert_ne!(ws.active_tab + 1, active_public); + let new_pane = ws.test_split(ratatui::layout::Direction::Horizontal); + assert!(ws.public_pane_number(new_pane).is_some()); + state.ensure_test_terminals(); + + state.assert_invariants_for_test(); + } + + #[test] fn built_in_theme_names_resolve() { for name in THEME_NAMES { assert!( diff --git a/src/workspace.rs b/src/workspace.rs index f06fe8ab..02e9e9a6 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1241,6 +1241,179 @@ impl Workspace { self.tabs.push(tab); self.tabs.len() - 1 } + + pub(crate) fn test_adversarial_identity_state() -> Self { + let mut ws = Self::test_new("adversarial-identity"); + let removed_pane = ws.test_split(Direction::Horizontal); + ws.test_split(Direction::Vertical); + assert!(!ws.close_pane(removed_pane)); + let _unused_raw_id = PaneId::alloc(); + let later_pane = ws.test_split(Direction::Horizontal); + + let removed_tab = ws.test_add_tab(Some("removed")); + let survivor_tab = ws.test_add_tab(None); + let final_tab = ws.test_add_tab(None); + let survivor_root = ws.tabs[survivor_tab].root_pane; + let final_root = ws.tabs[final_tab].root_pane; + assert!(ws.close_tab(removed_tab)); + assert!(ws.move_tab(0, ws.tabs.len())); + ws.switch_tab( + ws.find_tab_index_for_pane(survivor_root) + .expect("survivor tab should still exist"), + ); + + assert_ne!( + ws.active_tab + 1, + ws.tabs[ws.active_tab].number, + "adversarial active tab must distinguish position from public tab number" + ); + assert_ne!( + later_pane.raw() as usize, + ws.public_pane_number(later_pane).unwrap(), + "adversarial pane must distinguish raw pane id from public pane number" + ); + assert_eq!(ws.find_tab_index_for_pane(final_root), Some(1)); + ws + } + + pub(crate) fn assert_invariants_for_test(&self) { + assert!( + !self.tabs.is_empty(), + "workspace {} must contain at least one tab", + self.id + ); + assert!( + self.active_tab < self.tabs.len(), + "workspace {} active_tab {} out of bounds for {} tabs", + self.id, + self.active_tab, + self.tabs.len() + ); + + let mut tab_numbers = std::collections::HashSet::new(); + let mut max_tab_number = 0usize; + let mut live_panes = std::collections::HashSet::new(); + let mut terminal_ids = std::collections::HashSet::new(); + + for (tab_idx, tab) in self.tabs.iter().enumerate() { + assert!( + tab.number > 0, + "workspace {} tab {} has invalid public tab number 0", + self.id, + tab_idx + ); + assert!( + tab_numbers.insert(tab.number), + "workspace {} has duplicate public tab number {}", + self.id, + tab.number + ); + max_tab_number = max_tab_number.max(tab.number); + assert!( + tab.panes.contains_key(&tab.root_pane), + "workspace {} tab {} root pane {:?} is missing from tab panes", + self.id, + tab_idx, + tab.root_pane + ); + + let layout_panes = tab.layout.pane_ids(); + let layout_set: std::collections::HashSet<_> = layout_panes.iter().copied().collect(); + assert_eq!( + layout_panes.len(), + layout_set.len(), + "workspace {} tab {} layout contains duplicate pane ids", + self.id, + tab_idx + ); + assert!( + layout_set.contains(&tab.layout.focused()), + "workspace {} tab {} focused pane {:?} is not in layout", + self.id, + tab_idx, + tab.layout.focused() + ); + let pane_set: std::collections::HashSet<_> = tab.panes.keys().copied().collect(); + assert_eq!( + layout_set, pane_set, + "workspace {} tab {} layout panes must exactly match pane states", + self.id, tab_idx + ); + + for (pane_id, pane) in &tab.panes { + assert!( + live_panes.insert(*pane_id), + "workspace {} pane {:?} appears in more than one tab", + self.id, + pane_id + ); + assert!( + self.public_pane_numbers.contains_key(pane_id), + "workspace {} live pane {:?} has no public pane number", + self.id, + pane_id + ); + assert!( + terminal_ids.insert(pane.attached_terminal_id.clone()), + "workspace {} terminal {} is attached to multiple panes", + self.id, + pane.attached_terminal_id + ); + } + } + + assert!( + self.next_public_tab_number > 0, + "workspace {} next_public_tab_number must be greater than 0", + self.id + ); + assert!( + self.next_public_tab_number > max_tab_number, + "workspace {} next_public_tab_number {} must be greater than max live public tab number {}", + self.id, + self.next_public_tab_number, + max_tab_number + ); + + let public_pane_keys: std::collections::HashSet<_> = + self.public_pane_numbers.keys().copied().collect(); + assert_eq!( + public_pane_keys, live_panes, + "workspace {} public pane map must exactly match live panes", + self.id + ); + + let mut pane_numbers = std::collections::HashSet::new(); + let mut max_pane_number = 0usize; + for (pane_id, pane_number) in &self.public_pane_numbers { + assert!( + *pane_number > 0, + "workspace {} pane {:?} has invalid public pane number 0", + self.id, + pane_id + ); + assert!( + pane_numbers.insert(*pane_number), + "workspace {} duplicate public pane number {} for pane {:?}", + self.id, + pane_number, + pane_id + ); + max_pane_number = max_pane_number.max(*pane_number); + } + assert!( + self.next_public_pane_number > 0, + "workspace {} next_public_pane_number must be greater than 0", + self.id + ); + assert!( + self.next_public_pane_number > max_pane_number, + "workspace {} next_public_pane_number {} must be greater than max live public pane number {}", + self.id, + self.next_public_pane_number, + max_pane_number + ); + } } #[cfg(test)] @@ -1331,6 +1504,32 @@ mod tests { let fourth_tab = ws.test_add_tab(None); let fourth_root = ws.tabs[fourth_tab].root_pane; assert_eq!(ws.public_tab_number_for_pane(fourth_root), Some(4)); + ws.assert_invariants_for_test(); + } + + #[test] + fn adversarial_identity_state_satisfies_workspace_invariants_after_mutation() { + let mut ws = Workspace::test_adversarial_identity_state(); + ws.assert_invariants_for_test(); + + let active_public = ws.tabs[ws.active_tab].number; + assert_ne!(ws.active_tab + 1, active_public); + let divergent_pane = ws + .public_pane_numbers + .iter() + .find_map(|(pane_id, public_number)| { + (pane_id.raw() as usize != *public_number).then_some(*pane_id) + }) + .expect("adversarial state should contain raw/public pane divergence"); + assert_ne!( + divergent_pane.raw() as usize, + ws.public_pane_number(divergent_pane).unwrap() + ); + + let new_pane = ws.test_split(Direction::Vertical); + assert!(ws.public_pane_number(new_pane).is_some()); + assert!(ws.move_tab(ws.active_tab, ws.tabs.len())); + ws.assert_invariants_for_test(); } #[test] @@ -1392,5 +1591,6 @@ mod tests { assert_eq!(ws.tabs[2].number, 1); assert_eq!(ws.tabs[2].root_pane, moved_root); assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root); + ws.assert_invariants_for_test(); } } |
