Skip to main content

reovim_testing/
step_test.rs

1//! Step-by-step integration test builder with per-key state tracking.
2//!
3//! Provides a fluent API for testing key-by-key behavior, capturing state
4//! (cursor, mode, registers) after each key and allowing assertions at each step.
5//!
6//! # Protocol
7//!
8//! This module uses **gRPC v2** for communication with the server.
9//!
10//! # Example
11//!
12//! ```ignore
13//! let trace = StepTest::new()
14//!     .await
15//!     .with_buffer("hello world")
16//!     .step("d")
17//!         .expect_mode_contains("delete")
18//!     .step("w")
19//!         .expect_buffer("world")
20//!         .expect_cursor(0, 0)
21//!         .expect_register("\"", "hello ", "characterwise")
22//!     .run()
23//!     .await;
24//! trace.print_trace();
25//! ```
26
27// Test infrastructure - suppress pedantic docs requirements
28#![allow(clippy::missing_errors_doc)]
29#![allow(clippy::missing_panics_doc)]
30
31use std::{
32    collections::HashMap,
33    io::Write,
34    sync::atomic::{AtomicU32, Ordering},
35    time::Duration,
36};
37
38use reovim_client_cli::GrpcClient;
39
40use super::{harness::TestServerHarness, integration::RegisterInfo};
41
42/// Counter for unique temp file names
43static STEP_TEMP_FILE_COUNTER: AtomicU32 = AtomicU32::new(0);
44
45/// Connection retry settings
46const MAX_CONNECT_ATTEMPTS: u32 = 20;
47const RETRY_BASE_MS: u64 = 50;
48const RETRY_MAX_MS: u64 = 200;
49
50/// Default delay after each key (allows event processing)
51const DEFAULT_STEP_DELAY_MS: u64 = 30;
52
53/// State snapshot captured after a key.
54#[derive(Debug, Clone)]
55pub struct StateSnapshot {
56    /// Key that was pressed to reach this state.
57    pub key: String,
58    /// Buffer content after this key.
59    pub buffer: String,
60    /// Cursor line (0-indexed).
61    pub cursor_line: u16,
62    /// Cursor column (0-indexed).
63    pub cursor_column: u16,
64    /// Mode display name (e.g., "NORMAL", "DELETE").
65    pub mode_display: String,
66    /// Edit mode string (e.g., "vim:normal", "vim:delete").
67    pub edit_mode: String,
68    /// Register contents.
69    pub registers: HashMap<String, RegisterInfo>,
70}
71
72#[cfg_attr(coverage_nightly, coverage(off))]
73impl StateSnapshot {
74    /// Format as compact single line: "key -> mode (line:col)"
75    #[must_use]
76    pub fn compact(&self) -> String {
77        format!(
78            "{} -> {} ({}:{})",
79            self.key, self.mode_display, self.cursor_line, self.cursor_column
80        )
81    }
82
83    /// Format with buffer preview.
84    #[must_use]
85    pub fn verbose(&self) -> String {
86        let buf_preview = if self.buffer.len() > 40 {
87            format!("{}...", &self.buffer[..40])
88        } else {
89            self.buffer.clone()
90        };
91        format!(
92            "{} -> {} ({}:{}) buf={:?}",
93            self.key, self.mode_display, self.cursor_line, self.cursor_column, buf_preview
94        )
95    }
96}
97
98/// Per-step expectation to be verified after the key.
99#[derive(Debug, Clone)]
100enum StepExpectation {
101    /// Expect cursor at (line, col).
102    Cursor(u16, u16),
103    /// Expect buffer equals.
104    Buffer(String),
105    /// Expect buffer contains.
106    BufferContains(String),
107    /// Expect mode display contains.
108    ModeContains(String),
109    /// Expect edit mode equals.
110    EditMode(String),
111    /// Expect register content and type.
112    Register(String, String, String),
113}
114
115/// A step in the test sequence.
116struct TestStep {
117    /// Key or key sequence to send.
118    keys: String,
119    /// Delay after this step.
120    delay_ms: u64,
121    /// Expectations to verify after this step.
122    expectations: Vec<StepExpectation>,
123}
124
125/// Step-by-step test builder.
126///
127/// # Example
128///
129/// ```ignore
130/// let trace = StepTest::new()
131///     .await
132///     .with_buffer("line 1\nline 2\nline 3")
133///     .step("d")
134///         .expect_mode_contains("DELETE")
135///     .step("j")
136///         .expect_buffer("line 3")
137///         .expect_cursor(0, 0)
138///     .run()
139///     .await;
140/// trace.print_trace();
141/// ```
142pub struct StepTest {
143    harness: TestServerHarness,
144    addr: String,
145    initial_content: Option<String>,
146    initial_cursor: Option<(u16, u16)>,
147    steps: Vec<TestStep>,
148    default_delay: u64,
149}
150
151#[cfg_attr(coverage_nightly, coverage(off))]
152impl StepTest {
153    /// Create new step test with automatic log capture (spawns server).
154    ///
155    /// Server logs are captured to `tmp/test-logs/{test_name}_{timestamp}.log`.
156    /// This is invaluable for debugging step-by-step test failures.
157    ///
158    /// # Panics
159    ///
160    /// Panics if server fails to spawn.
161    pub async fn new() -> Self {
162        let harness = TestServerHarness::spawn()
163            .await
164            .expect("Failed to spawn server");
165        let addr = format!("127.0.0.1:{}", harness.port());
166        Self {
167            harness,
168            addr,
169            initial_content: None,
170            initial_cursor: None,
171            steps: Vec::new(),
172            default_delay: DEFAULT_STEP_DELAY_MS,
173        }
174    }
175
176    /// Create new step test with extra modules loaded.
177    ///
178    /// See [`super::IntegrationTest::with_modules`] for details.
179    ///
180    /// # Panics
181    ///
182    /// Panics if server fails to spawn.
183    pub async fn with_modules(modules: &[&str]) -> Self {
184        let harness = TestServerHarness::spawn_with_modules(modules)
185            .await
186            .expect("Failed to spawn server with extra modules");
187        let addr = format!("127.0.0.1:{}", harness.port());
188        Self {
189            harness,
190            addr,
191            initial_content: None,
192            initial_cursor: None,
193            steps: Vec::new(),
194            default_delay: DEFAULT_STEP_DELAY_MS,
195        }
196    }
197
198    /// Create new step test with custom environment variables.
199    ///
200    /// See [`super::IntegrationTest::with_env`] for details.
201    ///
202    /// # Panics
203    ///
204    /// Panics if server fails to spawn.
205    pub async fn with_env(env_vars: &[(&str, &str)]) -> Self {
206        let harness = TestServerHarness::spawn_with_env(env_vars)
207            .await
208            .expect("Failed to spawn server with env vars");
209        let addr = format!("127.0.0.1:{}", harness.port());
210        Self {
211            harness,
212            addr,
213            initial_content: None,
214            initial_cursor: None,
215            steps: Vec::new(),
216            default_delay: DEFAULT_STEP_DELAY_MS,
217        }
218    }
219
220    /// Get the path to the server log file for debugging.
221    ///
222    /// Returns `None` if log capture is not enabled.
223    #[must_use]
224    pub fn log_path(&self) -> Option<&std::path::Path> {
225        self.harness.log_path()
226    }
227
228    /// Connect to server with retry logic using gRPC.
229    async fn connect_with_retry(&self) -> Result<GrpcClient, String> {
230        let mut attempts = 0;
231        loop {
232            match GrpcClient::connect(&self.addr).await {
233                Ok(c) => return Ok(c),
234                Err(_) if attempts < MAX_CONNECT_ATTEMPTS => {
235                    attempts += 1;
236                    let delay = std::cmp::min(
237                        RETRY_BASE_MS + u64::from(attempts) * RETRY_BASE_MS,
238                        RETRY_MAX_MS,
239                    );
240                    tokio::time::sleep(Duration::from_millis(delay)).await;
241                }
242                Err(e) => {
243                    return Err(format!(
244                        "Failed to connect after {MAX_CONNECT_ATTEMPTS} attempts: {e}"
245                    ));
246                }
247            }
248        }
249    }
250
251    /// Capture current state from server via gRPC.
252    async fn capture_state(&self, client: &mut GrpcClient, key: &str) -> StateSnapshot {
253        let buffer_response = client
254            .get_buffer_content(None)
255            .await
256            .expect("Failed to get buffer content");
257        let cursor_response = client.get_cursor().await.expect("Failed to get cursor");
258        let mode_response = client.get_mode().await.expect("Failed to get mode");
259        let register_response = client
260            .get_registers(vec![])
261            .await
262            .expect("Failed to get registers");
263
264        // Extract cursor position from nested Position message
265        let (cursor_line, cursor_column) = cursor_response
266            .position
267            .map_or((0, 0), |pos| (pos.line, pos.column));
268
269        // Populate registers from gRPC response
270        let registers = register_response
271            .registers
272            .into_iter()
273            .map(|entry| {
274                (
275                    entry.name,
276                    RegisterInfo {
277                        content: entry.content,
278                        yank_type: entry.yank_type,
279                    },
280                )
281            })
282            .collect();
283
284        #[allow(clippy::cast_possible_truncation)]
285        StateSnapshot {
286            key: key.to_string(),
287            buffer: buffer_response.lines.join("\n"),
288            cursor_line: cursor_line as u16,
289            cursor_column: cursor_column as u16,
290            mode_display: mode_response.display,
291            edit_mode: mode_response.name,
292            registers,
293        }
294    }
295
296    /// Set initial buffer content.
297    #[must_use]
298    pub fn with_buffer(mut self, content: &str) -> Self {
299        self.initial_content = Some(content.to_string());
300        self
301    }
302
303    /// Set initial cursor position (line, col) - 0-indexed.
304    #[must_use]
305    #[allow(clippy::missing_const_for_fn)]
306    pub fn with_cursor_at(mut self, line: u16, col: u16) -> Self {
307        self.initial_cursor = Some((line, col));
308        self
309    }
310
311    /// Set default delay between steps (ms).
312    #[must_use]
313    #[allow(clippy::missing_const_for_fn)]
314    pub fn with_delay(mut self, ms: u64) -> Self {
315        self.default_delay = ms;
316        self
317    }
318
319    /// Add a step (key to send).
320    #[must_use]
321    pub fn step(mut self, keys: &str) -> Self {
322        self.steps.push(TestStep {
323            keys: keys.to_string(),
324            delay_ms: self.default_delay,
325            expectations: Vec::new(),
326        });
327        self
328    }
329
330    /// Add cursor expectation to last step.
331    #[must_use]
332    pub fn expect_cursor(mut self, line: u16, col: u16) -> Self {
333        if let Some(step) = self.steps.last_mut() {
334            step.expectations.push(StepExpectation::Cursor(line, col));
335        }
336        self
337    }
338
339    /// Add buffer content expectation to last step.
340    #[must_use]
341    pub fn expect_buffer(mut self, expected: &str) -> Self {
342        if let Some(step) = self.steps.last_mut() {
343            step.expectations
344                .push(StepExpectation::Buffer(expected.to_string()));
345        }
346        self
347    }
348
349    /// Add buffer contains expectation to last step.
350    #[must_use]
351    pub fn expect_buffer_contains(mut self, substring: &str) -> Self {
352        if let Some(step) = self.steps.last_mut() {
353            step.expectations
354                .push(StepExpectation::BufferContains(substring.to_string()));
355        }
356        self
357    }
358
359    /// Add mode contains expectation to last step.
360    #[must_use]
361    pub fn expect_mode_contains(mut self, substring: &str) -> Self {
362        if let Some(step) = self.steps.last_mut() {
363            step.expectations
364                .push(StepExpectation::ModeContains(substring.to_string()));
365        }
366        self
367    }
368
369    /// Add exact edit mode expectation to last step.
370    #[must_use]
371    pub fn expect_edit_mode(mut self, mode: &str) -> Self {
372        if let Some(step) = self.steps.last_mut() {
373            step.expectations
374                .push(StepExpectation::EditMode(mode.to_string()));
375        }
376        self
377    }
378
379    /// Add register expectation to last step.
380    #[must_use]
381    pub fn expect_register(mut self, reg: &str, content: &str, yank_type: &str) -> Self {
382        if let Some(step) = self.steps.last_mut() {
383            step.expectations.push(StepExpectation::Register(
384                reg.to_string(),
385                content.to_string(),
386                yank_type.to_string(),
387            ));
388        }
389        self
390    }
391
392    /// Run the test and return trace.
393    ///
394    /// # Panics
395    ///
396    /// Panics if any step expectation fails.
397    #[allow(clippy::too_many_lines)]
398    pub async fn run(self) -> StepTrace {
399        // Create buffer via temp file
400        let temp_path = {
401            let id = STEP_TEMP_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
402            let path = format!("/tmp/reovim-step-test-{}-{id}.txt", std::process::id());
403            let content = self.initial_content.as_deref().unwrap_or("");
404            let mut file = std::fs::File::create(&path).expect("Failed to create temp file");
405            file.write_all(content.as_bytes())
406                .expect("Failed to write temp file");
407            path
408        };
409
410        let mut client = self.connect_with_retry().await.expect("Failed to connect");
411
412        // Open buffer file using key sequence
413        client
414            .send_keys(&format!(":e {temp_path}<CR>"))
415            .await
416            .expect("Failed to open buffer file");
417        tokio::time::sleep(Duration::from_millis(50)).await;
418
419        // Set initial cursor if specified
420        if let Some((line, col)) = self.initial_cursor {
421            if line > 0 {
422                client
423                    .send_keys(&format!("{line}j"))
424                    .await
425                    .expect("Failed to move cursor down");
426            }
427            if col > 0 {
428                client
429                    .send_keys(&format!("{col}l"))
430                    .await
431                    .expect("Failed to move cursor right");
432            }
433        }
434
435        // Capture initial state
436        let initial = self.capture_state(&mut client, "(initial)").await;
437
438        let mut snapshots = Vec::new();
439        let mut failed_expectations: Vec<String> = Vec::new();
440
441        // Execute each step
442        for step in &self.steps {
443            // Send key
444            client
445                .send_keys(&step.keys)
446                .await
447                .expect("Failed to inject key");
448            tokio::time::sleep(Duration::from_millis(step.delay_ms)).await;
449
450            // Capture state
451            let snapshot = self.capture_state(&mut client, &step.keys).await;
452
453            // Verify expectations
454            for expectation in &step.expectations {
455                match expectation {
456                    StepExpectation::Cursor(line, col) => {
457                        if snapshot.cursor_line != *line || snapshot.cursor_column != *col {
458                            failed_expectations.push(format!(
459                                "After '{}': cursor expected ({}:{}), got ({}:{})",
460                                step.keys, line, col, snapshot.cursor_line, snapshot.cursor_column
461                            ));
462                        }
463                    }
464                    StepExpectation::Buffer(expected) => {
465                        if snapshot.buffer.trim_end() != expected.trim_end() {
466                            failed_expectations.push(format!(
467                                "After '{}': buffer expected {:?}, got {:?}",
468                                step.keys, expected, snapshot.buffer
469                            ));
470                        }
471                    }
472                    StepExpectation::BufferContains(substring) => {
473                        if !snapshot.buffer.contains(substring) {
474                            failed_expectations.push(format!(
475                                "After '{}': buffer expected to contain {:?}, got {:?}",
476                                step.keys, substring, snapshot.buffer
477                            ));
478                        }
479                    }
480                    StepExpectation::ModeContains(substring) => {
481                        let mode_lower = snapshot.mode_display.to_lowercase();
482                        let edit_lower = snapshot.edit_mode.to_lowercase();
483                        let sub_lower = substring.to_lowercase();
484                        if !mode_lower.contains(&sub_lower) && !edit_lower.contains(&sub_lower) {
485                            failed_expectations.push(format!(
486                                "After '{}': mode expected to contain {:?}, got display={:?} edit={:?}",
487                                step.keys, substring, snapshot.mode_display, snapshot.edit_mode
488                            ));
489                        }
490                    }
491                    StepExpectation::EditMode(mode) => {
492                        if snapshot.edit_mode != *mode {
493                            failed_expectations.push(format!(
494                                "After '{}': edit_mode expected {:?}, got {:?}",
495                                step.keys, mode, snapshot.edit_mode
496                            ));
497                        }
498                    }
499                    StepExpectation::Register(reg, content, yank_type) => {
500                        if let Some(register) = snapshot.registers.get(reg) {
501                            if register.content.trim_end() != content.trim_end() {
502                                failed_expectations.push(format!(
503                                    "After '{}': register '{}' content expected {:?}, got {:?}",
504                                    step.keys, reg, content, register.content
505                                ));
506                            }
507                            if register.yank_type != *yank_type {
508                                failed_expectations.push(format!(
509                                    "After '{}': register '{}' type expected {:?}, got {:?}",
510                                    step.keys, reg, yank_type, register.yank_type
511                                ));
512                            }
513                        } else {
514                            failed_expectations.push(format!(
515                                "After '{}': register '{}' not found (available: {:?})",
516                                step.keys,
517                                reg,
518                                snapshot.registers.keys().collect::<Vec<_>>()
519                            ));
520                        }
521                    }
522                }
523            }
524
525            snapshots.push(snapshot);
526        }
527        drop(client); // Drop client early to avoid significant_drop_tightening warning
528
529        StepTrace {
530            initial,
531            snapshots,
532            failed_expectations,
533            harness: self.harness,
534            temp_path: Some(temp_path),
535        }
536    }
537}
538
539/// Trace of step-by-step execution.
540pub struct StepTrace {
541    /// Initial state before any keys.
542    pub initial: StateSnapshot,
543    /// State snapshots after each key.
544    pub snapshots: Vec<StateSnapshot>,
545    /// Failed expectations (if any).
546    pub failed_expectations: Vec<String>,
547    harness: TestServerHarness,
548    temp_path: Option<String>,
549}
550
551#[cfg_attr(coverage_nightly, coverage(off))]
552impl Drop for StepTrace {
553    fn drop(&mut self) {
554        if let Some(path) = &self.temp_path {
555            let _ = std::fs::remove_file(path);
556        }
557    }
558}
559
560#[cfg_attr(coverage_nightly, coverage(off))]
561impl StepTrace {
562    /// Get the path to the server log file for debugging.
563    ///
564    /// Returns `None` if log capture is not enabled.
565    #[must_use]
566    pub fn log_path(&self) -> Option<&std::path::Path> {
567        self.harness.log_path()
568    }
569
570    /// Format log path hint for output messages.
571    fn log_hint(&self) -> String {
572        self.log_path()
573            .map(|p| format!("\n\n  Server log: {}", p.display()))
574            .unwrap_or_default()
575    }
576
577    /// Print the full trace to stderr (for debugging).
578    pub fn print_trace(&self) {
579        eprintln!("\n╔════════════════════════════════════════════════════════════╗");
580        eprintln!("║                     STEP-BY-STEP TRACE                     ║");
581        eprintln!("╚════════════════════════════════════════════════════════════╝\n");
582
583        eprintln!(
584            "Initial: {} mode={} ({}:{})",
585            self.initial.key,
586            self.initial.mode_display,
587            self.initial.cursor_line,
588            self.initial.cursor_column
589        );
590        eprintln!("  Buffer: {:?}", self.initial.buffer);
591
592        for (i, snapshot) in self.snapshots.iter().enumerate() {
593            eprintln!(
594                "\nStep {}: '{}' -> {} ({}:{})",
595                i + 1,
596                snapshot.key,
597                snapshot.mode_display,
598                snapshot.cursor_line,
599                snapshot.cursor_column
600            );
601            eprintln!("  Buffer: {:?}", snapshot.buffer);
602            if !snapshot.registers.is_empty() {
603                eprintln!("  Registers:");
604                for (name, info) in &snapshot.registers {
605                    eprintln!("    '{}': {:?} ({})", name, info.content, info.yank_type);
606                }
607            }
608        }
609
610        eprintln!("\n────────────────────────────────────────────────────────────");
611
612        if self.failed_expectations.is_empty() {
613            eprintln!("OK: All expectations passed");
614        } else {
615            eprintln!("FAIL: {} expectation(s) failed:", self.failed_expectations.len());
616            for failure in &self.failed_expectations {
617                eprintln!("  - {failure}");
618            }
619        }
620
621        // Always show log path at the end for debugging
622        if let Some(path) = self.log_path() {
623            eprintln!("\n  Server log: {}", path.display());
624        }
625        eprintln!();
626    }
627
628    /// Print compact single-line trace.
629    pub fn print_compact(&self) {
630        let line: String = std::iter::once(format!(
631            "[init {}:{}]",
632            self.initial.cursor_line, self.initial.cursor_column
633        ))
634        .chain(
635            self.snapshots
636                .iter()
637                .map(|s| format!("'{}' -> {}:{}", s.key, s.cursor_line, s.cursor_column)),
638        )
639        .collect::<Vec<_>>()
640        .join(" → ");
641        eprintln!("Trace: {line}");
642    }
643
644    /// Assert no expectations failed.
645    ///
646    /// # Panics
647    ///
648    /// Panics with detailed trace if any expectations failed.
649    pub fn assert_ok(&self) {
650        if !self.failed_expectations.is_empty() {
651            self.print_trace();
652            panic!(
653                "Step test failed with {} errors:\n{}{}",
654                self.failed_expectations.len(),
655                self.failed_expectations.join("\n"),
656                self.log_hint()
657            );
658        }
659    }
660
661    /// Get final state (last snapshot).
662    #[must_use]
663    pub fn final_state(&self) -> &StateSnapshot {
664        self.snapshots.last().unwrap_or(&self.initial)
665    }
666
667    /// Get state after step N (0-indexed).
668    #[must_use]
669    pub fn state_after(&self, step: usize) -> Option<&StateSnapshot> {
670        self.snapshots.get(step)
671    }
672}