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}