Skip to main content

fresh/
test_api.rs

1//! Test-only observation API for the editor.
2//!
3//! The semantic test suite (under `tests/semantic/`) binds **only** to this
4//! module — it must never reach into `crate::app::Editor`, `crate::model::*`,
5//! or `crate::view::*` directly. That keeps the test/production contract
6//! explicit and one-directional: production internals can be refactored
7//! freely, the test API is the only thing that has to stay stable.
8//!
9//! See `docs/internal/e2e-test-migration-design.md` for the full rationale.
10//!
11//! # Layers
12//!
13//! Phase 2 (current) exposes only Class A — pure state observables:
14//! `dispatch`, `dispatch_seq`, `buffer_text`, `primary_caret`, `carets`,
15//! `selection_text`. Layout (`RenderSnapshot`) and styled-frame
16//! (`StyledFrame`) observables are reserved for Phase 3+ and intentionally
17//! not present here yet — adding them is a design decision that should be
18//! made when the first theorem demanding them is written.
19//!
20//! # Determinism
21//!
22//! `carets()` returns cursors in ascending byte-position order so that
23//! tests don't depend on `HashMap` iteration order (cursors are stored in
24//! a hashmap internally).
25
26// Re-export Action so semantic tests can `use fresh::test_api::Action`
27// without reaching into `fresh::input::keybindings` directly. Keeping
28// the action alphabet behind the test_api module is part of the
29// one-directional contract documented in §2.1 of the design doc.
30pub use crate::input::keybindings::Action;
31
32/// A test-side projection of `crate::model::cursor::Cursor`.
33///
34/// Carries only the fields that semantic tests typically assert on
35/// (position + selection anchor). Internal fields like `sticky_column`,
36/// `deselect_on_move`, and `block_anchor` are intentionally hidden — if a
37/// test needs them, the right fix is to extend this projection (with
38/// review) rather than reach past it.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub struct Caret {
41    /// Byte offset where edits happen.
42    pub position: usize,
43    /// Selection anchor, if a selection is active.
44    pub anchor: Option<usize>,
45}
46
47/// Test-side projection of the editor's popup stack. Captures only
48/// the fields scenario tests assert on — kind, title, items,
49/// selection — so internal popup struct refactors don't break tests.
50#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub struct ModalSnapshot {
52    /// `None` ⇒ no popup visible.
53    pub top_popup: Option<PopupView>,
54    /// Popup-stack depth (0 = no popups, 1 = one popup, …).
55    pub depth: usize,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct PopupView {
60    /// Popup kind name: `"completion"`, `"hover"`, `"action"`,
61    /// `"list"`, `"text"`. Stable strings (not the enum variant
62    /// debug repr) so corpus JSON survives refactors.
63    pub kind: String,
64    pub title: Option<String>,
65    /// List items as plain text. Empty for non-list popups.
66    pub items: Vec<String>,
67    pub selected_index: Option<usize>,
68}
69
70impl Caret {
71    /// Cursor with no selection.
72    pub fn at(position: usize) -> Self {
73        Self {
74            position,
75            anchor: None,
76        }
77    }
78
79    /// Cursor with a selection from `anchor` to `position`.
80    /// Direction is preserved (anchor may be greater than position).
81    pub fn range(anchor: usize, position: usize) -> Self {
82        Self {
83            position,
84            anchor: Some(anchor),
85        }
86    }
87
88    /// Sorted byte range covered by this caret's selection, if any.
89    pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
90        self.anchor.map(|a| {
91            if a <= self.position {
92                a..self.position
93            } else {
94                self.position..a
95            }
96        })
97    }
98}
99
100/// The single observation surface for semantic theorem tests.
101///
102/// Implemented by `crate::app::Editor`. Tests obtain a
103/// `&mut dyn EditorTestApi` from the test harness and never see the
104/// underlying `Editor` type directly.
105pub trait EditorTestApi {
106    // ── Drive ────────────────────────────────────────────────────────────
107
108    /// Apply a single semantic action, then drain async messages.
109    fn dispatch(&mut self, action: Action);
110
111    /// Apply a sequence of actions in order, draining async messages once
112    /// at the end. Equivalent to calling `dispatch` per action but cheaper.
113    fn dispatch_seq(&mut self, actions: &[Action]);
114
115    // ── Class A: pure state observables ──────────────────────────────────
116
117    /// Full buffer text. Panics if the buffer has unloaded regions
118    /// (large-file mode); semantic theorems are not the right tool for
119    /// large-file scenarios — write a layout/E2E test instead.
120    fn buffer_text(&self) -> String;
121
122    /// Primary cursor projected to a `Caret`.
123    fn primary_caret(&self) -> Caret;
124
125    /// All cursors projected to `Caret`s, sorted by ascending position.
126    /// The primary cursor is included. Use `primary_caret()` if you only
127    /// care about the primary; use `carets()` for multi-cursor theorems.
128    fn carets(&self) -> Vec<Caret>;
129
130    /// Concatenated selected text across all cursors, in ascending position
131    /// order, joined by `\n`. Returns the empty string if no cursor has a
132    /// selection.
133    fn selection_text(&mut self) -> String;
134
135    // ── Class B: layout observables ──────────────────────────────────────
136    //
137    // These reflect viewport state that is reconciled by the render
138    // pipeline (`Viewport::ensure_visible_in_layout`), not by action
139    // dispatch alone. The `LayoutTheorem` runner invokes
140    // `EditorTestHarness::render` exactly once before reading them.
141    //
142    // This is intentionally a *thin* layout surface — just `top_byte`
143    // for now. The `RenderSnapshot` design (see §9.1 of the migration
144    // doc) is the right home for richer layout observables (gutter
145    // spans, scrollbar geometry, hardware cursor row/col, popup
146    // placement) and is reserved for a future expansion when a
147    // theorem actually needs them.
148
149    /// Byte offset of the first line currently visible in the active
150    /// viewport. After the renderer has run, this is the viewport's
151    /// scroll position. Without a render, this reflects the last
152    /// reconciliation point.
153    fn viewport_top_byte(&self) -> usize;
154
155    /// Width of the active terminal in cells, as set at harness
156    /// construction or via resize.
157    fn terminal_width(&self) -> u16;
158
159    /// Height of the active terminal in cells.
160    fn terminal_height(&self) -> u16;
161
162    /// Width of the line-number gutter in cells, computed from the
163    /// active buffer's line count. Includes the trailing separator
164    /// if the renderer adds one.
165    fn gutter_width(&self) -> u16;
166
167    /// Screen cell of the primary cursor, in `(col, row)`. None ⇒
168    /// the cursor is off-screen (scrolled past). Requires a prior
169    /// render to be meaningful.
170    fn hardware_cursor_position(&mut self) -> Option<(u16, u16)>;
171
172    /// `(start_byte, end_byte)` of the currently-visible buffer
173    /// region. End is exclusive. None ⇒ unknown / not yet
174    /// reconciled.
175    fn visible_byte_range(&self) -> Option<(usize, usize)>;
176
177    // ── Class C: modal observables (Phase 3) ─────────────────────────────
178
179    /// Snapshot of the modal-popup stack visible to the user. Used
180    /// by `ModalScenario` to assert on palette / picker / menu /
181    /// completion state without screen scraping.
182    fn modal_snapshot(&self) -> ModalSnapshot;
183
184    // ── Class D: workspace observables (Phase 7) ─────────────────────────
185
186    /// Number of buffers currently open across the workspace.
187    fn buffer_count(&self) -> usize;
188
189    /// Display path of the active buffer. None for unnamed buffers.
190    fn active_buffer_path(&self) -> Option<String>;
191
192    /// Display paths of every open buffer in stable insertion
193    /// order. Unnamed buffers appear as `"<unnamed:NNN>"`.
194    fn buffer_paths(&self) -> Vec<String>;
195
196    // ── Class E: input dispatch (Phase 9) ────────────────────────────────
197
198    /// Dispatch a mouse click projected through the active
199    /// viewport. `(col, row)` are absolute screen coordinates;
200    /// gutter offset is applied internally. Returns true if the
201    /// editor consumed the event.
202    fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool;
203
204    /// `true` if the active buffer has unsaved changes since it was
205    /// last loaded from / saved to disk. The "save point" is the
206    /// commit in the undo/redo log at which the buffer's on-disk
207    /// representation matches its in-memory state. After loading a
208    /// fresh file (no edits applied), this is `false`. After any
209    /// edit it becomes `true`. Undoing back to the save point flips
210    /// it back to `false` — the property under test in
211    /// `tests/semantic/undo_redo.rs::theorem_undo_to_save_point_*`.
212    fn is_modified(&self) -> bool;
213}
214
215// ─────────────────────────────────────────────────────────────────────────
216// Implementation on Editor.
217//
218// Implementation lives in this file (rather than next to Editor) so that
219// the entire test-facing surface — trait + impl + projection types — is
220// reviewable as one unit.
221// ─────────────────────────────────────────────────────────────────────────
222
223impl EditorTestApi for crate::app::Editor {
224    fn dispatch(&mut self, action: Action) {
225        // Routes through the same handle_action path the input layer
226        // uses; dispatch_action_for_tests is the existing pub shim.
227        self.dispatch_action_for_tests(action);
228        let _ = self.process_async_messages();
229    }
230
231    fn dispatch_seq(&mut self, actions: &[Action]) {
232        for a in actions {
233            self.dispatch_action_for_tests(a.clone());
234        }
235        let _ = self.process_async_messages();
236    }
237
238    fn buffer_text(&self) -> String {
239        self.active_state()
240            .buffer
241            .to_string()
242            .expect("buffer_text(): buffer has unloaded regions; semantic tests do not support large-file mode")
243    }
244
245    fn primary_caret(&self) -> Caret {
246        let c = self.active_cursors().primary();
247        Caret {
248            position: c.position,
249            anchor: c.anchor,
250        }
251    }
252
253    fn carets(&self) -> Vec<Caret> {
254        let mut out: Vec<Caret> = self
255            .active_cursors()
256            .iter()
257            .map(|(_, c)| Caret {
258                position: c.position,
259                anchor: c.anchor,
260            })
261            .collect();
262        out.sort_by_key(|c| c.position);
263        out
264    }
265
266    fn selection_text(&mut self) -> String {
267        // Collect ranges first to avoid holding an immutable borrow of
268        // `active_cursors` across the mutable `get_text_range` call.
269        let mut ranges: Vec<std::ops::Range<usize>> = self
270            .active_cursors()
271            .iter()
272            .filter_map(|(_, c)| c.selection_range())
273            .collect();
274        if ranges.is_empty() {
275            return String::new();
276        }
277        ranges.sort_by_key(|r| r.start);
278
279        let state = self.active_state_mut();
280        let parts: Vec<String> = ranges
281            .into_iter()
282            .map(|r| state.get_text_range(r.start, r.end))
283            .collect();
284        parts.join("\n")
285    }
286
287    fn viewport_top_byte(&self) -> usize {
288        self.active_viewport().top_byte
289    }
290
291    fn terminal_width(&self) -> u16 {
292        self.active_viewport().width
293    }
294
295    fn terminal_height(&self) -> u16 {
296        self.active_viewport().height
297    }
298
299    fn gutter_width(&self) -> u16 {
300        let buffer = &self.active_state().buffer;
301        u16::try_from(self.active_viewport().gutter_width(buffer)).unwrap_or(u16::MAX)
302    }
303
304    fn hardware_cursor_position(&mut self) -> Option<(u16, u16)> {
305        // The viewport's `cursor_screen_position` requires
306        // `&mut Buffer`. Cloning the viewport (cheap; mostly
307        // primitives) lets us drop the immutable viewport borrow
308        // before taking the mutable buffer borrow on the next
309        // accessor call.
310        let cursor = *self.active_cursors().primary();
311        let viewport = self.active_viewport().clone();
312        let viewport_height = viewport.height;
313        let viewport_width = viewport.width;
314        let buffer = &mut self.active_state_mut().buffer;
315        let (col, row) = viewport.cursor_screen_position(buffer, &cursor);
316        if row >= viewport_height || col >= viewport_width {
317            None
318        } else {
319            Some((col, row))
320        }
321    }
322
323    fn visible_byte_range(&self) -> Option<(usize, usize)> {
324        // Viewport tracks `top_byte` exactly but the bottom of the
325        // visible region depends on the wrapped view-line layout,
326        // which only the renderer knows. Today we conservatively
327        // return None until a future expansion plumbs the
328        // last-visible byte through the test API.
329        None
330    }
331
332    fn is_modified(&self) -> bool {
333        self.active_state().buffer.is_modified()
334    }
335
336    fn modal_snapshot(&self) -> ModalSnapshot {
337        // Two popup stacks live on the editor:
338        // - `global_popups`: editor-wide modals (palette, file open, …)
339        // - `active_state().popups`: per-buffer popups (completion, hover, …)
340        // We return the topmost across both, choosing global first
341        // since modal scenarios target the foreground stack.
342        use crate::view::popup::{Popup, PopupContent, PopupKind};
343
344        fn kind_name(kind: PopupKind) -> &'static str {
345            match kind {
346                PopupKind::Completion => "completion",
347                PopupKind::Hover => "hover",
348                PopupKind::Action => "action",
349                PopupKind::List => "list",
350                PopupKind::Text => "text",
351            }
352        }
353
354        fn project(p: &Popup) -> PopupView {
355            let (items, selected_index) = match &p.content {
356                PopupContent::List { items, selected } => (
357                    items.iter().map(|i| i.text.clone()).collect(),
358                    Some(*selected),
359                ),
360                _ => (Vec::new(), None),
361            };
362            PopupView {
363                kind: kind_name(p.kind).to_string(),
364                title: p.title.clone(),
365                items,
366                selected_index,
367            }
368        }
369
370        let global = self.global_popups.all();
371        let local = &self.active_state().popups;
372        let depth = global.len() + local.all().len();
373
374        // `top()` of the global stack is highest-priority. Fall back
375        // to per-buffer top if global is empty.
376        let top = self
377            .global_popups
378            .top()
379            .or_else(|| local.top())
380            .map(project);
381
382        ModalSnapshot {
383            top_popup: top,
384            depth,
385        }
386    }
387
388    fn buffer_count(&self) -> usize {
389        // `Editor::buffers` is the per-tab map; that's the count
390        // the workspace surface advertises.
391        self.buffer_count_for_tests()
392    }
393
394    fn active_buffer_path(&self) -> Option<String> {
395        let id = self.active_buffer();
396        let name = self.get_buffer_display_name(id);
397        if name.is_empty() {
398            None
399        } else {
400            Some(name)
401        }
402    }
403
404    fn buffer_paths(&self) -> Vec<String> {
405        self.all_buffer_ids_for_tests()
406            .into_iter()
407            .map(|id| {
408                let name = self.get_buffer_display_name(id);
409                if name.is_empty() {
410                    format!("<unnamed:{}>", id.0)
411                } else {
412                    name
413                }
414            })
415            .collect()
416    }
417
418    fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool {
419        use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
420        let down = MouseEvent {
421            kind: MouseEventKind::Down(MouseButton::Left),
422            column: col,
423            row,
424            modifiers: KeyModifiers::NONE,
425        };
426        // Discard the down result; we only act on the up — but
427        // explicitly use the value so clippy's
428        // `let_underscore_must_use` is satisfied.
429        if let Err(e) = self.handle_mouse(down) {
430            tracing::trace!("mouse down errored in test dispatch: {e}");
431        }
432        let up = MouseEvent {
433            kind: MouseEventKind::Up(MouseButton::Left),
434            column: col,
435            row,
436            modifiers: KeyModifiers::NONE,
437        };
438        self.handle_mouse(up).unwrap_or(false)
439    }
440}