reovim_driver_session/testing.rs
1//! Test utilities for session-based command testing.
2//!
3//! This module provides [`TestSessionRuntime`], which simplifies testing commands
4//! that use the new `SessionRuntime` signature.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use reovim_driver_session::testing::TestSessionRuntime;
10//! use reovim_driver_command::{CommandHandler, CommandContext, CommandResult};
11//!
12//! #[test]
13//! fn test_enter_insert_mode() {
14//! let mut test = TestSessionRuntime::new();
15//! let cmd = EnterInsertMode;
16//! let args = CommandContext::new();
17//!
18//! // Use with_runtime to automatically capture changes
19//! let result = test.with_runtime(|runtime| {
20//! cmd.execute(runtime, &args)
21//! });
22//!
23//! assert_eq!(result, CommandResult::Success);
24//! test.assert_mode_name("insert");
25//! assert!(test.changes().mode_changed);
26//! }
27//! ```
28
29use {
30 crate::{
31 ClientId, Session, WindowLayout,
32 api::{CommandExecutor, CommandHandle, StateChanges},
33 extension::ExtensionMap,
34 runtime::SessionRuntime,
35 },
36 reovim_kernel::api::v1::{
37 Buffer, BufferId, CommandId, HistoryRing, Jumplist, KernelContext, MarkBank, ModeId,
38 ModeStack, ModuleId, Position, RegisterBank,
39 },
40 std::sync::Arc,
41};
42
43/// Test helper for commands using `SessionRuntime`.
44///
45/// Owns all the components needed to create a `SessionRuntime` and provides
46/// convenient assertion methods for testing.
47///
48/// # Architecture (#471 Phase 0)
49///
50/// Per-client state (`mode_stack`, `windows`, `extensions`) is held as **separate
51/// fields** instead of inside `session`. This mirrors the production architecture
52/// where `EditingState` (per-client) is separate from `Session` (shared).
53///
54/// This separation is REQUIRED by the borrow checker: `SessionRuntime::new()` takes
55/// `&mut Session` AND `&mut ModeStack` etc. If `mode_stack` were inside `session`,
56/// we'd have a double mutable borrow conflict.
57///
58/// ```text
59/// TestSessionRuntime
60/// ├── session: Session // Shared infra (terminal_size, compositor)
61/// ├── mode_stack: ModeStack // Per-client (SEPARATE field)
62/// ├── windows: WindowLayout // Per-client (SEPARATE field)
63/// ├── extensions: ExtensionMap // Per-client (SEPARATE field)
64/// ├── kernel: KernelContext
65/// └── changes: StateChanges
66/// ```
67pub struct TestSessionRuntime {
68 /// Shared session infrastructure (compositor, `terminal_size`).
69 ///
70 /// Does NOT contain per-client state - that's in separate fields below.
71 session: Session,
72 /// Per-client mode stack (SEPARATE from session for borrow-checker).
73 ///
74 /// Commands use this via `runtime.current_mode()`, `runtime.push_mode()`, etc.
75 /// Public for direct test access (e.g., `test.mode_stack.current()`).
76 pub mode_stack: ModeStack,
77 /// Per-client window layout with cursors (SEPARATE from session).
78 ///
79 /// Commands use this via `runtime.windows()`.
80 /// Public for direct test access (e.g., `test.windows.active_mut()`).
81 pub windows: WindowLayout,
82 /// Per-client extensions (SEPARATE from session).
83 ///
84 /// Commands use this via `runtime.ext::<T>()`, `runtime.ext_mut::<T>()`.
85 pub extensions: ExtensionMap,
86 /// Per-client compositor (#474).
87 ///
88 /// `None` for tests that don't need compositor. Matches `EditingState.compositor`.
89 compositor: Option<Box<dyn reovim_driver_layout::RootCompositor>>,
90 /// Per-client tab pages (#401).
91 tabs: crate::TabPageSet,
92 /// Per-client register storage (#515).
93 registers: RegisterBank,
94 /// Per-client clipboard history ring (#515).
95 clipboard_history: HistoryRing,
96 /// Per-client local marks (#515).
97 local_marks: MarkBank,
98 jumplist: Jumplist,
99 /// Per-client active buffer (#471).
100 active_buffer: Option<BufferId>,
101 /// Per-client terminal dimensions (#471).
102 terminal_size: (u16, u16),
103 kernel: KernelContext,
104 executor: StubExecutor,
105 /// Accumulated changes from operations.
106 changes: StateChanges,
107}
108
109impl Default for TestSessionRuntime {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115// Test infrastructure — not production code.
116#[cfg_attr(coverage_nightly, coverage(off))]
117impl TestSessionRuntime {
118 /// Create a `KernelContext` that uses a real buffer manager for testing.
119 fn make_test_kernel() -> KernelContext {
120 reovim_kernel::testing::create_test_context()
121 }
122
123 /// Create a new test runtime with default normal mode.
124 #[must_use]
125 pub fn new() -> Self {
126 let home_mode = ModeId::new(ModuleId::new("test"), "normal");
127 Self {
128 session: Session::new(ClientId::new(1), home_mode.clone()), // #491: home_mode in shared
129 mode_stack: ModeStack::new(home_mode), // Per-client state
130 windows: WindowLayout::empty(), // Per-client state
131 extensions: ExtensionMap::new(), // Per-client state
132 compositor: None, // Per-client (#474)
133 tabs: crate::TabPageSet::new(), // Per-client (#401)
134 registers: RegisterBank::new(), // Per-client (#515)
135 clipboard_history: HistoryRing::new(), // Per-client (#515)
136 local_marks: MarkBank::new(), // Per-client (#515)
137 jumplist: Jumplist::new(),
138 active_buffer: None, // Per-client (#471)
139 terminal_size: (80, 24), // Per-client (#471)
140 kernel: Self::make_test_kernel(),
141 executor: StubExecutor,
142 changes: StateChanges::new(),
143 }
144 }
145
146 /// Create a test runtime with a specific home mode.
147 #[must_use]
148 pub fn with_home_mode(mode: ModeId) -> Self {
149 Self {
150 session: Session::new(ClientId::new(1), mode.clone()), // #491: home_mode in shared
151 mode_stack: ModeStack::new(mode), // Per-client state
152 windows: WindowLayout::empty(), // Per-client state
153 extensions: ExtensionMap::new(), // Per-client state
154 compositor: None, // Per-client (#474)
155 tabs: crate::TabPageSet::new(), // Per-client (#401)
156 registers: RegisterBank::new(), // Per-client (#515)
157 clipboard_history: HistoryRing::new(), // Per-client (#515)
158 local_marks: MarkBank::new(), // Per-client (#515)
159 jumplist: Jumplist::new(),
160 active_buffer: None, // Per-client (#471)
161 terminal_size: (80, 24), // Per-client (#471)
162 kernel: Self::make_test_kernel(),
163 executor: StubExecutor,
164 changes: StateChanges::new(),
165 }
166 }
167
168 /// Create a test runtime with a buffer containing the given content.
169 #[must_use]
170 pub fn with_buffer(content: &str) -> Self {
171 let mut test = Self::new();
172
173 // Create buffer with content
174 let buffer = Buffer::from_string(content);
175 let buffer_id = test.kernel.buffers.register(buffer);
176
177 // Create a window displaying this buffer
178 let mut window = crate::Window::new();
179 window.buffer_id = Some(buffer_id);
180 test.windows.add(window); // Use self.windows, NOT self.session.windows
181
182 // Set the active buffer (per-client state)
183 test.active_buffer = Some(buffer_id);
184
185 test
186 }
187
188 /// Create a test runtime with buffer content and a specific home mode.
189 ///
190 /// Use for mode-sensitive tests (e.g., textobjects that behave differently
191 /// in visual vs normal mode).
192 #[must_use]
193 pub fn with_buffer_and_mode(content: &str, mode: ModeId) -> Self {
194 let mut test = Self::with_home_mode(mode);
195 let buffer = Buffer::from_string(content);
196 let buffer_id = test.kernel.buffers.register(buffer);
197 let mut window = crate::Window::new();
198 window.buffer_id = Some(buffer_id);
199 test.windows.add(window);
200 test.active_buffer = Some(buffer_id);
201 test
202 }
203
204 /// Create a test runtime with a pre-configured window and mode.
205 ///
206 /// Use when tests need specific cursor position or window configuration
207 /// before executing a command.
208 #[must_use]
209 pub fn with_window(window: crate::Window, mode: ModeId) -> Self {
210 let mut test = Self::with_home_mode(mode);
211 test.active_buffer = window.buffer_id;
212 test.windows.add(window);
213 test
214 }
215
216 /// Execute operations with a runtime, capturing changes automatically.
217 ///
218 /// This is the preferred way to use the test runtime. Changes are
219 /// automatically captured when the callback returns.
220 ///
221 /// # Per-Client State (#471 Phase 0)
222 ///
223 /// Uses `SessionRuntime::new()` with per-client state held as
224 /// **separate fields** in `TestSessionRuntime`. This matches production behavior
225 /// where `EditingState` (per-client) is separate from `Session` (shared).
226 ///
227 /// # Example
228 ///
229 /// ```ignore
230 /// test.with_runtime(|runtime| {
231 /// runtime.push_mode(insert_mode, TransitionContext::new());
232 /// });
233 /// assert!(test.changes().mode_changed);
234 /// ```
235 pub fn with_runtime<F, R>(&mut self, f: F) -> R
236 where
237 F: FnOnce(&mut SessionRuntime<'_>) -> R,
238 {
239 use crate::api::ChangeTracker;
240
241 // Phase #471 Phase 0: Use new() with per-client state from SEPARATE fields
242 // (not from session, which would cause double mutable borrow)
243 let client = crate::ClientContext {
244 mode_stack: &mut self.mode_stack,
245 windows: &mut self.windows,
246 extensions: &mut self.extensions,
247 compositor: &mut self.compositor,
248 tabs: &mut self.tabs,
249 registers: &mut self.registers,
250 clipboard_history: &mut self.clipboard_history,
251 local_marks: &mut self.local_marks,
252 jumplist: &mut self.jumplist,
253 active_buffer: &mut self.active_buffer,
254 terminal_size: &mut self.terminal_size,
255 };
256 let mut runtime =
257 SessionRuntime::new(&mut self.session, client, &self.kernel, &self.executor);
258 let result = f(&mut runtime);
259 let changes = ChangeTracker::take_changes(&mut runtime);
260 self.changes.merge(changes);
261 result
262 }
263
264 /// Get a mutable reference to the `SessionRuntime`.
265 ///
266 /// **Note**: Changes are NOT automatically captured when using this method.
267 /// Prefer `with_runtime` for tests that need to verify changes.
268 /// Use this for simple operations where change tracking isn't needed.
269 ///
270 /// # Per-Client State (#471 Phase 0)
271 ///
272 /// Uses `SessionRuntime::new()` with per-client state from separate fields.
273 pub fn runtime(&mut self) -> SessionRuntime<'_> {
274 let client = crate::ClientContext {
275 mode_stack: &mut self.mode_stack,
276 windows: &mut self.windows,
277 extensions: &mut self.extensions,
278 compositor: &mut self.compositor,
279 tabs: &mut self.tabs,
280 registers: &mut self.registers,
281 clipboard_history: &mut self.clipboard_history,
282 local_marks: &mut self.local_marks,
283 jumplist: &mut self.jumplist,
284 active_buffer: &mut self.active_buffer,
285 terminal_size: &mut self.terminal_size,
286 };
287 SessionRuntime::new(&mut self.session, client, &self.kernel, &self.executor)
288 }
289
290 /// Take accumulated changes and reset the tracker.
291 ///
292 /// Returns all changes that have been recorded since the last call.
293 pub fn take_changes(&mut self) -> StateChanges {
294 std::mem::take(&mut self.changes)
295 }
296
297 /// Get reference to all accumulated changes (doesn't reset).
298 #[must_use]
299 pub const fn changes(&self) -> &StateChanges {
300 &self.changes
301 }
302
303 // === Assertions ===
304
305 /// Assert the current mode matches the expected mode ID.
306 ///
307 /// # Panics
308 ///
309 /// Panics if the current mode doesn't match.
310 pub fn assert_mode(&self, expected: &ModeId) {
311 let current = self.mode_stack.current(); // Use separate field, NOT session.mode_stack
312 assert_eq!(current, expected, "Expected mode {expected:?}, got {current:?}");
313 }
314
315 /// Assert the current mode name matches (ignores module).
316 ///
317 /// # Panics
318 ///
319 /// Panics if the mode name doesn't match.
320 #[cfg_attr(coverage_nightly, coverage(off))]
321 pub fn assert_mode_name(&self, expected_name: &str) {
322 let current = self.mode_stack.current(); // Use separate field
323 assert_eq!(
324 current.name(),
325 expected_name,
326 "Expected mode name '{}', got '{}'",
327 expected_name,
328 current.name()
329 );
330 }
331
332 /// Assert the mode stack depth.
333 ///
334 /// # Panics
335 ///
336 /// Panics if the depth doesn't match.
337 pub fn assert_mode_depth(&self, expected: usize) {
338 let depth = self.mode_stack.depth(); // Use separate field
339 assert_eq!(depth, expected, "Expected mode depth {expected}, got {depth}");
340 }
341
342 /// Assert the cursor position for the active buffer.
343 ///
344 /// # Panics
345 ///
346 /// Panics if no active window or cursor position doesn't match.
347 pub fn assert_cursor(&self, line: usize, column: usize) {
348 let window = self
349 .windows // Use separate field, NOT session.windows
350 .active()
351 .expect("No active window for cursor assertion");
352 assert_eq!(
353 (window.cursor.line, window.cursor.column),
354 (line, column),
355 "Expected cursor at ({}, {}), got ({}, {})",
356 line,
357 column,
358 window.cursor.line,
359 window.cursor.column
360 );
361 }
362
363 /// Assert the buffer content for the active buffer.
364 ///
365 /// # Panics
366 ///
367 /// Panics if no active buffer or content doesn't match.
368 pub fn assert_buffer_content(&self, expected: &str) {
369 let buffer_id = self
370 .active_buffer
371 .expect("No active buffer for content assertion");
372 let buffer = self
373 .kernel
374 .buffers
375 .get(buffer_id)
376 .expect("Buffer not found");
377 let content = buffer.read().content();
378 assert_eq!(
379 content, expected,
380 "Buffer content mismatch.\nExpected:\n{expected}\nGot:\n{content}"
381 );
382 }
383
384 /// Assert the buffer line count for the active buffer.
385 ///
386 /// # Panics
387 ///
388 /// Panics if no active buffer or line count doesn't match.
389 pub fn assert_line_count(&self, expected: usize) {
390 let buffer_id = self
391 .active_buffer
392 .expect("No active buffer for line count assertion");
393 let buffer = self
394 .kernel
395 .buffers
396 .get(buffer_id)
397 .expect("Buffer not found");
398 let count = buffer.read().line_count();
399 assert_eq!(count, expected, "Expected {expected} lines, got {count}");
400 }
401
402 /// Assert window count.
403 ///
404 /// # Panics
405 ///
406 /// Panics if window count doesn't match.
407 pub fn assert_window_count(&self, expected: usize) {
408 let count = self.windows.len(); // Use separate field
409 assert_eq!(count, expected, "Expected {expected} windows, got {count}");
410 }
411
412 // === Getters for advanced assertions ===
413
414 /// Get the current mode ID.
415 #[must_use]
416 pub fn current_mode(&self) -> &ModeId {
417 self.mode_stack.current() // Use separate field
418 }
419
420 /// Get the active buffer ID, if any.
421 #[must_use]
422 pub const fn active_buffer(&self) -> Option<BufferId> {
423 self.active_buffer
424 }
425
426 /// Get the cursor position for the active buffer.
427 #[must_use]
428 pub fn cursor_position(&self) -> Option<Position> {
429 self.windows // Use separate field
430 .active()
431 .map(|w| Position::new(w.cursor.line, w.cursor.column))
432 }
433
434 /// Get buffer content for the active buffer.
435 #[must_use]
436 pub fn buffer_content(&self) -> Option<String> {
437 self.active_buffer
438 .and_then(|id| self.kernel.buffers.get(id).map(|b| b.read().content()))
439 }
440
441 /// Get direct access to the kernel context.
442 #[must_use]
443 pub const fn kernel(&self) -> &KernelContext {
444 &self.kernel
445 }
446
447 /// Get direct access to the session.
448 #[must_use]
449 pub const fn session(&self) -> &Session {
450 &self.session
451 }
452
453 /// Set the compositor for layout testing.
454 ///
455 /// Required for testing commands that need window navigation, splitting,
456 /// or other compositor operations (e.g., window-ops commands).
457 pub fn set_compositor(&mut self, compositor: Box<dyn reovim_driver_layout::RootCompositor>) {
458 self.compositor = Some(compositor);
459 }
460}
461
462/// Stub command executor for testing.
463///
464/// Returns `None` (command not found) for all lookups.
465pub struct StubExecutor;
466
467#[cfg_attr(coverage_nightly, coverage(off))]
468impl CommandExecutor for StubExecutor {
469 fn get_handle(&self, _id: &CommandId) -> Option<Arc<dyn CommandHandle>> {
470 None
471 }
472}
473#[cfg(test)]
474#[path = "testing_tests.rs"]
475mod tests;