Skip to main content

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;