aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 17:02:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 19:03:16 +0300
commitde0f43a061162a223c5946a4b50aa9f9ac7ab8d7 (patch)
treef1267f8c3d0f1b7775a5ad513ee7353faa133546 /src
parentea3a4db72f676ca180a8e0ca61c730c0cf9ed920 (diff)
test: add state invariants for refactor safety
Diffstat (limited to 'src')
-rw-r--r--src/app/state.rs89
-rw-r--r--src/workspace.rs200
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();
}
}