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///
51/// Two distinct modal channels live on the editor and both are
52/// projected here:
53///   - `top_popup` / `depth` come from the popup stacks
54///     (`global_popups`, per-window `popups`) — completion,
55///     hover, action, list, text overlays.
56///   - `prompt` comes from `active_window().prompt` — the
57///     minibuffer / floating-overlay prompts opened by actions
58///     like `CommandPalette`, `QuickOpen`, `GotoLine`, `Search`,
59///     `OpenLiveGrep`, `SaveAs`, `RecordMacro`, etc. These do not
60///     live on the popup stacks; without projecting them, modal
61///     scenarios that drive prompt flows pass by tautology.
62#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct ModalSnapshot {
64    /// `None` ⇒ no popup visible on either popup stack.
65    pub top_popup: Option<PopupView>,
66    /// Popup-stack depth across both stacks (0 = no popups).
67    /// Does NOT count an active prompt — see the `prompt` field
68    /// for that.
69    pub depth: usize,
70    /// Active minibuffer prompt, if one is open. `None` ⇒ no
71    /// prompt; the user is in normal editing mode.
72    pub prompt: Option<PromptView>,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76pub struct PopupView {
77    /// Popup kind name: `"completion"`, `"hover"`, `"action"`,
78    /// `"list"`, `"text"`. Stable strings (not the enum variant
79    /// debug repr) so corpus JSON survives refactors.
80    pub kind: String,
81    pub title: Option<String>,
82    /// List items as plain text. Empty for non-list popups.
83    pub items: Vec<String>,
84    pub selected_index: Option<usize>,
85}
86
87/// Test-side projection of an active minibuffer prompt.
88///
89/// `prompt_type` is the `PromptType` variant name as a stable
90/// string (`"CommandPalette"`, `"QuickOpen"`, `"GotoLine"`,
91/// `"Search"`, …) so corpus JSON survives variant renames in
92/// production code. The runner inserts characters into the active
93/// prompt via `Action::InsertChar` (the production input handler
94/// routes those automatically when a prompt is open), so the
95/// `input` / `cursor_pos` fields are the right level to assert on.
96#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub struct PromptView {
98    pub prompt_type: String,
99    pub input: String,
100    pub cursor_pos: usize,
101    /// Filtered suggestion texts shown to the user. Empty for
102    /// prompts that don't filter (e.g. `GotoLine`).
103    pub suggestions: Vec<String>,
104    pub selected_suggestion: Option<usize>,
105}
106
107impl Caret {
108    /// Cursor with no selection.
109    pub fn at(position: usize) -> Self {
110        Self {
111            position,
112            anchor: None,
113        }
114    }
115
116    /// Cursor with a selection from `anchor` to `position`.
117    /// Direction is preserved (anchor may be greater than position).
118    pub fn range(anchor: usize, position: usize) -> Self {
119        Self {
120            position,
121            anchor: Some(anchor),
122        }
123    }
124
125    /// Sorted byte range covered by this caret's selection, if any.
126    pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
127        self.anchor.map(|a| {
128            if a <= self.position {
129                a..self.position
130            } else {
131                self.position..a
132            }
133        })
134    }
135}
136
137/// The single observation surface for semantic theorem tests.
138///
139/// Implemented by `crate::app::Editor`. Tests obtain a
140/// `&mut dyn EditorTestApi` from the test harness and never see the
141/// underlying `Editor` type directly.
142pub trait EditorTestApi {
143    // ── Drive ────────────────────────────────────────────────────────────
144
145    /// Apply a single semantic action, then drain async messages.
146    fn dispatch(&mut self, action: Action);
147
148    /// Apply a sequence of actions in order, draining async messages once
149    /// at the end. Equivalent to calling `dispatch` per action but cheaper.
150    fn dispatch_seq(&mut self, actions: &[Action]);
151
152    // ── Class A: pure state observables ──────────────────────────────────
153
154    /// Full buffer text. Panics if the buffer has unloaded regions
155    /// (large-file mode); semantic theorems are not the right tool for
156    /// large-file scenarios — write a layout/E2E test instead.
157    fn buffer_text(&self) -> String;
158
159    /// Primary cursor projected to a `Caret`.
160    fn primary_caret(&self) -> Caret;
161
162    /// All cursors projected to `Caret`s, sorted by ascending position.
163    /// The primary cursor is included. Use `primary_caret()` if you only
164    /// care about the primary; use `carets()` for multi-cursor theorems.
165    fn carets(&self) -> Vec<Caret>;
166
167    /// Concatenated selected text across all cursors, in ascending position
168    /// order, joined by `\n`. Returns the empty string if no cursor has a
169    /// selection.
170    fn selection_text(&mut self) -> String;
171
172    // ── Class B: layout observables ──────────────────────────────────────
173    //
174    // These reflect viewport state that is reconciled by the render
175    // pipeline (`Viewport::ensure_visible_in_layout`), not by action
176    // dispatch alone. The `LayoutTheorem` runner invokes
177    // `EditorTestHarness::render` exactly once before reading them.
178    //
179    // This is intentionally a *thin* layout surface — just `top_byte`
180    // for now. The `RenderSnapshot` design (see §9.1 of the migration
181    // doc) is the right home for richer layout observables (gutter
182    // spans, scrollbar geometry, hardware cursor row/col, popup
183    // placement) and is reserved for a future expansion when a
184    // theorem actually needs them.
185
186    /// Byte offset of the first line currently visible in the active
187    /// viewport. After the renderer has run, this is the viewport's
188    /// scroll position. Without a render, this reflects the last
189    /// reconciliation point.
190    fn viewport_top_byte(&self) -> usize;
191
192    /// Width of the active terminal in cells, as set at harness
193    /// construction or via resize.
194    fn terminal_width(&self) -> u16;
195
196    /// Height of the active terminal in cells.
197    fn terminal_height(&self) -> u16;
198
199    /// Width of the line-number gutter in cells, computed from the
200    /// active buffer's line count. Includes the trailing separator
201    /// if the renderer adds one.
202    fn gutter_width(&self) -> u16;
203
204    /// Screen cell of the primary cursor, in `(col, row)`. None ⇒
205    /// the cursor is off-screen (scrolled past). Requires a prior
206    /// render to be meaningful.
207    fn hardware_cursor_position(&mut self) -> Option<(u16, u16)>;
208
209    /// `(start_byte, end_byte)` of the currently-visible buffer
210    /// region. End is exclusive. None ⇒ unknown / not yet
211    /// reconciled.
212    fn visible_byte_range(&self) -> Option<(usize, usize)>;
213
214    // ── Class C: modal observables (Phase 3) ─────────────────────────────
215
216    /// Snapshot of the modal-popup stack visible to the user. Used
217    /// by `ModalScenario` to assert on palette / picker / menu /
218    /// completion state without screen scraping.
219    fn modal_snapshot(&self) -> ModalSnapshot;
220
221    // ── Class D: workspace observables (Phase 7) ─────────────────────────
222
223    /// Number of buffers currently open across the workspace.
224    fn buffer_count(&self) -> usize;
225
226    /// Display path of the active buffer. None for unnamed buffers.
227    fn active_buffer_path(&self) -> Option<String>;
228
229    /// Display paths of every open buffer in stable insertion
230    /// order. Unnamed buffers appear as `"<unnamed:NNN>"`.
231    fn buffer_paths(&self) -> Vec<String>;
232
233    // ── Class E: input dispatch (Phase 9) ────────────────────────────────
234
235    /// Dispatch a mouse click projected through the active
236    /// viewport. `(col, row)` are absolute screen coordinates;
237    /// gutter offset is applied internally. Returns true if the
238    /// editor consumed the event.
239    fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool;
240
241    /// `true` if the active buffer has unsaved changes since it was
242    /// last loaded from / saved to disk. The "save point" is the
243    /// commit in the undo/redo log at which the buffer's on-disk
244    /// representation matches its in-memory state. After loading a
245    /// fresh file (no edits applied), this is `false`. After any
246    /// edit it becomes `true`. Undoing back to the save point flips
247    /// it back to `false` — the property under test in
248    /// `tests/semantic/undo_redo.rs::theorem_undo_to_save_point_*`.
249    fn is_modified(&self) -> bool;
250
251    /// Consume the one-shot "full hardware redraw" flag the editor's
252    /// event loop polls each frame: `Action::RedrawScreen` sets it,
253    /// the loop's tick clears it. Returns the previous value and
254    /// resets the flag to `false`.
255    ///
256    /// Exposed for `LayoutScenario`'s `expected_full_redraw_requested`
257    /// assertion — the only stable observation surface for the
258    /// "redraw screen" claim (issue #1070).
259    fn take_full_redraw_request_for_tests(&mut self) -> bool;
260
261    // ── Class F: marker observables (used by MarkerRoundtripScenario) ────
262    //
263    // Markers / line indicators track a byte position that survives
264    // edits, undo, and redo. They have no `Action` projection — the
265    // marker model is a production-internal thing — so the scenario
266    // runner needs declarative setters/getters on the test API instead
267    // of reaching into `editor.active_state_mut().margins`.
268
269    /// Seed a line indicator (margin marker) at `byte_offset` with the
270    /// given `symbol` (used as both the indicator glyph and the
271    /// namespace) and a `color` name (`"red"`, `"green"`, `"blue"`,
272    /// `"yellow"`; anything else falls back to `Color::Red`).
273    fn seed_marker(&mut self, byte_offset: usize, symbol: &str, color: &str);
274
275    /// Current byte positions of every marker whose namespace matches
276    /// `symbol`, in ascending order. Empty if no marker was seeded for
277    /// that namespace.
278    fn marker_positions(&self, symbol: &str) -> Vec<usize>;
279
280    /// Length (in events) of the active buffer's event log. Used by
281    /// `PersistenceScenario` save-point claims that check no undo
282    /// history is dropped on a no-op file-watcher notification.
283    fn active_event_log_len(&self) -> usize;
284
285    /// Notify the editor that `path` on disk has changed. Triggers the
286    /// auto-revert path; the editor reads the file and updates its
287    /// buffer (or skips when on-disk content already matches).
288    fn notify_file_changed(&mut self, path: &str);
289
290    // ── Class G: composite-buffer setup (used by diff layout scenarios) ──
291    //
292    // Side-by-side diff and unified-diff layout scenarios need a
293    // composite buffer built from two virtual buffers + a hunk-derived
294    // line alignment. Each accessor below is a stable, declarative
295    // wrapper around the equivalent `Editor` / `Window` method so the
296    // layout scenario runner can construct the composite from a data
297    // spec (`CompositeDiffSpec`) without reaching into production
298    // internals.
299
300    /// Create a side-by-side composite diff view named `name` with
301    /// mode `mode`. Builds two virtual buffers (OLD, NEW), seeds them
302    /// with `old_content` / `new_content`, computes a line alignment
303    /// from `hunks`, and switches the active buffer to the composite.
304    /// Returns a 64-bit handle to the composite for follow-up calls
305    /// (`composite_next_hunk_on`, `set_composite_initial_focus_hunk_on`).
306    fn create_side_by_side_diff(
307        &mut self,
308        name: &str,
309        mode: &str,
310        old_content: &str,
311        new_content: &str,
312        hunks: &[(usize, usize, usize, usize)],
313    ) -> usize;
314
315    /// Set the composite buffer's `initial_focus_hunk` field — the
316    /// one-shot "scroll to hunk N on first render" knob. The first
317    /// render consumes the value and resets the field to `None`.
318    fn set_composite_initial_focus_hunk_on(&mut self, composite_handle: usize, hunk_index: usize);
319
320    /// Read the composite buffer's current `initial_focus_hunk`
321    /// (after the first render this should be `None`, i.e. the field
322    /// was consumed). Returns `None` for missing or non-composite
323    /// buffers.
324    fn composite_initial_focus_hunk_on(&self, composite_handle: usize) -> Option<usize>;
325
326    /// Jump the active split's view of `composite_handle` to the next
327    /// hunk. Mirrors the `n` / `]` keybinding semantics; returns
328    /// `true` iff a next hunk existed.
329    fn composite_next_hunk_active_on(&mut self, composite_handle: usize) -> bool;
330
331    /// Jump back to the previous hunk; companion of
332    /// `composite_next_hunk_active_on`.
333    fn composite_prev_hunk_active_on(&mut self, composite_handle: usize) -> bool;
334
335    /// Force-materialize composite view state across visible splits
336    /// without performing a render. Lets a scenario reach hunk-nav
337    /// state before any frame paints.
338    fn flush_layout_for_tests(&mut self);
339
340    // ── Class H: scenario-seeding extensions for the per-row sweep ───────
341    //
342    // These give the `LayoutScenario` runner declarative knobs to
343    // inject virtual lines, margin annotations, and to read status
344    // messages / margin width without reaching into production
345    // internals or holding an `EditorTestHarness` reference.
346
347    /// Latest status message set by the editor (the same string the
348    /// status bar would display). `None` ⇒ no message has been set
349    /// since the last clear. Used by scrollbar-toggle scenarios that
350    /// assert on the "Vertical scrollbar hidden/shown" round-trip.
351    fn status_message(&self) -> Option<String>;
352
353    /// Inject a virtual line at the marker for `byte_offset` with
354    /// `text`, fg/bg colors (each as `Option<(r,g,b)>`), placement
355    /// (`"above"` or `"below"`), namespace, and priority.
356    fn seed_virtual_line(
357        &mut self,
358        byte_offset: usize,
359        text: &str,
360        fg: Option<(u8, u8, u8)>,
361        bg: Option<(u8, u8, u8)>,
362        placement: &str,
363        namespace: &str,
364        priority: i32,
365    );
366
367    /// Total count of virtual texts on the active state.
368    fn virtual_text_count(&self) -> usize;
369
370    /// Clear every virtual text belonging to `namespace`.
371    fn clear_virtual_text_namespace(&mut self, namespace: &str);
372
373    /// Inject a margin annotation (gutter symbol with optional color)
374    /// at `line` (0-indexed). `position` is `"left"` or `"right"`.
375    fn add_margin_annotation(
376        &mut self,
377        line: usize,
378        position: &str,
379        symbol: &str,
380        color: Option<(u8, u8, u8)>,
381        annotation_id: Option<&str>,
382    );
383
384    /// Remove the previously-added margin annotation with this id.
385    fn remove_margin_annotation(&mut self, annotation_id: &str);
386
387    /// `margins.left_total_width()` of the active state, in cells.
388    fn margin_left_total_width(&self) -> usize;
389
390    /// 1-indexed logical line number of the viewport's top byte —
391    /// the same observable the e2e scrollbar tests read via
392    /// `harness.top_line_number()`. Used to assert a scroll
393    /// position is (un)changed after a mouse interaction.
394    fn top_line_number(&mut self) -> usize;
395
396    /// Cached scrollbar geometry of the primary (first) split:
397    /// `(thumb_start, thumb_end, scrollbar_height, scrollbar_y)` —
398    /// thumb extent in scrollbar-row offsets, the track's height,
399    /// and the track's top terminal row. `None` ⇒ no split areas
400    /// cached (no render yet). `thumb_end > thumb_start` indicates
401    /// a non-degenerate thumb; `thumb_end - thumb_start <
402    /// scrollbar_height` indicates the content is scrollable (the
403    /// thumb does not fill the track) — the load-bearing claim of
404    /// `test_scrollbar_shows_scrollable_content_with_wrapped_lines`.
405    /// `scrollbar_y` lets a test resolve the thumb's terminal row
406    /// for a click/drag at the thumb midpoint.
407    fn primary_scrollbar_geometry(&self) -> Option<(usize, usize, u16, u16)>;
408}
409
410// ─────────────────────────────────────────────────────────────────────────
411// Implementation on Editor.
412//
413// Implementation lives in this file (rather than next to Editor) so that
414// the entire test-facing surface — trait + impl + projection types — is
415// reviewable as one unit.
416// ─────────────────────────────────────────────────────────────────────────
417
418impl EditorTestApi for crate::app::Editor {
419    fn dispatch(&mut self, action: Action) {
420        // Routes through the same handle_action path the input layer
421        // uses; dispatch_action_for_tests is the existing pub shim.
422        self.dispatch_action_for_tests(action);
423        let _ = self.process_async_messages();
424    }
425
426    fn dispatch_seq(&mut self, actions: &[Action]) {
427        for a in actions {
428            self.dispatch_action_for_tests(a.clone());
429        }
430        let _ = self.process_async_messages();
431    }
432
433    fn buffer_text(&self) -> String {
434        self.active_state()
435            .buffer
436            .to_string()
437            .expect("buffer_text(): buffer has unloaded regions; semantic tests do not support large-file mode")
438    }
439
440    fn primary_caret(&self) -> Caret {
441        let c = self.active_cursors().primary();
442        Caret {
443            position: c.position,
444            anchor: c.anchor,
445        }
446    }
447
448    fn carets(&self) -> Vec<Caret> {
449        let mut out: Vec<Caret> = self
450            .active_cursors()
451            .iter()
452            .map(|(_, c)| Caret {
453                position: c.position,
454                anchor: c.anchor,
455            })
456            .collect();
457        out.sort_by_key(|c| c.position);
458        out
459    }
460
461    fn selection_text(&mut self) -> String {
462        // Collect ranges first to avoid holding an immutable borrow of
463        // `active_cursors` across the mutable `get_text_range` call.
464        let mut ranges: Vec<std::ops::Range<usize>> = self
465            .active_cursors()
466            .iter()
467            .filter_map(|(_, c)| c.selection_range())
468            .collect();
469        if ranges.is_empty() {
470            return String::new();
471        }
472        ranges.sort_by_key(|r| r.start);
473
474        let state = self.active_state_mut();
475        let parts: Vec<String> = ranges
476            .into_iter()
477            .map(|r| state.get_text_range(r.start, r.end))
478            .collect();
479        parts.join("\n")
480    }
481
482    fn viewport_top_byte(&self) -> usize {
483        self.active_viewport().top_byte
484    }
485
486    fn terminal_width(&self) -> u16 {
487        self.active_viewport().width
488    }
489
490    fn terminal_height(&self) -> u16 {
491        self.active_viewport().height
492    }
493
494    fn gutter_width(&self) -> u16 {
495        let buffer = &self.active_state().buffer;
496        u16::try_from(self.active_viewport().gutter_width(buffer)).unwrap_or(u16::MAX)
497    }
498
499    fn hardware_cursor_position(&mut self) -> Option<(u16, u16)> {
500        // The viewport's `cursor_screen_position` requires
501        // `&mut Buffer`. Cloning the viewport (cheap; mostly
502        // primitives) lets us drop the immutable viewport borrow
503        // before taking the mutable buffer borrow on the next
504        // accessor call.
505        let cursor = *self.active_cursors().primary();
506        let viewport = self.active_viewport().clone();
507        let viewport_height = viewport.height;
508        let viewport_width = viewport.width;
509        let buffer = &mut self.active_state_mut().buffer;
510        let (col, row) = viewport.cursor_screen_position(buffer, &cursor);
511        if row >= viewport_height || col >= viewport_width {
512            None
513        } else {
514            Some((col, row))
515        }
516    }
517
518    fn visible_byte_range(&self) -> Option<(usize, usize)> {
519        // Viewport tracks `top_byte` exactly but the bottom of the
520        // visible region depends on the wrapped view-line layout,
521        // which only the renderer knows. Today we conservatively
522        // return None until a future expansion plumbs the
523        // last-visible byte through the test API.
524        None
525    }
526
527    fn is_modified(&self) -> bool {
528        self.active_state().buffer.is_modified()
529    }
530
531    fn take_full_redraw_request_for_tests(&mut self) -> bool {
532        self.take_full_redraw_request()
533    }
534
535    fn modal_snapshot(&self) -> ModalSnapshot {
536        // Two popup stacks live on the editor:
537        // - `global_popups`: editor-wide modals (palette, file open, …)
538        // - `active_state().popups`: per-buffer popups (completion, hover, …)
539        // We return the topmost across both, choosing global first
540        // since modal scenarios target the foreground stack.
541        use crate::view::popup::{Popup, PopupContent, PopupKind};
542
543        fn kind_name(kind: PopupKind) -> &'static str {
544            match kind {
545                PopupKind::Completion => "completion",
546                PopupKind::Hover => "hover",
547                PopupKind::Action => "action",
548                PopupKind::List => "list",
549                PopupKind::Text => "text",
550            }
551        }
552
553        fn project(p: &Popup) -> PopupView {
554            let (items, selected_index) = match &p.content {
555                PopupContent::List { items, selected } => (
556                    items.iter().map(|i| i.text.clone()).collect(),
557                    Some(*selected),
558                ),
559                _ => (Vec::new(), None),
560            };
561            PopupView {
562                kind: kind_name(p.kind).to_string(),
563                title: p.title.clone(),
564                items,
565                selected_index,
566            }
567        }
568
569        let global = self.global_popups.all();
570        let local = &self.active_state().popups;
571        let depth = global.len() + local.all().len();
572
573        // `top()` of the global stack is highest-priority. Fall back
574        // to per-buffer top if global is empty.
575        let top = self
576            .global_popups
577            .top()
578            .or_else(|| local.top())
579            .map(project);
580
581        // Project the active prompt (minibuffer / floating overlay).
582        // Lives on `active_window().prompt`, not on the popup stacks.
583        let prompt = self.active_window().prompt.as_ref().map(|p| PromptView {
584            prompt_type: format!("{:?}", p.prompt_type),
585            input: p.input.clone(),
586            cursor_pos: p.cursor_pos,
587            suggestions: p.suggestions.iter().map(|s| s.text.clone()).collect(),
588            selected_suggestion: p.selected_suggestion,
589        });
590
591        ModalSnapshot {
592            top_popup: top,
593            depth,
594            prompt,
595        }
596    }
597
598    fn buffer_count(&self) -> usize {
599        // `Editor::buffers` is the per-tab map; that's the count
600        // the workspace surface advertises.
601        self.buffer_count_for_tests()
602    }
603
604    fn active_buffer_path(&self) -> Option<String> {
605        let id = self.active_buffer();
606        let name = self.get_buffer_display_name(id);
607        if name.is_empty() {
608            None
609        } else {
610            Some(name)
611        }
612    }
613
614    fn buffer_paths(&self) -> Vec<String> {
615        self.all_buffer_ids_for_tests()
616            .into_iter()
617            .map(|id| {
618                let name = self.get_buffer_display_name(id);
619                if name.is_empty() {
620                    format!("<unnamed:{}>", id.0)
621                } else {
622                    name
623                }
624            })
625            .collect()
626    }
627
628    fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool {
629        use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
630        let down = MouseEvent {
631            kind: MouseEventKind::Down(MouseButton::Left),
632            column: col,
633            row,
634            modifiers: KeyModifiers::NONE,
635        };
636        // Discard the down result; we only act on the up — but
637        // explicitly use the value so clippy's
638        // `let_underscore_must_use` is satisfied.
639        if let Err(e) = self.handle_mouse(down) {
640            tracing::trace!("mouse down errored in test dispatch: {e}");
641        }
642        let up = MouseEvent {
643            kind: MouseEventKind::Up(MouseButton::Left),
644            column: col,
645            row,
646            modifiers: KeyModifiers::NONE,
647        };
648        self.handle_mouse(up).unwrap_or(false)
649    }
650
651    fn seed_marker(&mut self, byte_offset: usize, symbol: &str, color: &str) {
652        use crate::view::margin::LineIndicator;
653        use ratatui::style::Color;
654        let color = match color.to_ascii_lowercase().as_str() {
655            "red" => Color::Red,
656            "green" => Color::Green,
657            "blue" => Color::Blue,
658            "yellow" => Color::Yellow,
659            "cyan" => Color::Cyan,
660            "magenta" => Color::Magenta,
661            "white" => Color::White,
662            "black" => Color::Black,
663            _ => Color::Red,
664        };
665        let indicator = LineIndicator::new(symbol, color, 10);
666        let state = self.active_state_mut();
667        let _ = state
668            .margins
669            .set_line_indicator(byte_offset, symbol.to_string(), indicator);
670    }
671
672    fn marker_positions(&self, symbol: &str) -> Vec<usize> {
673        let margins = &self.active_state().margins;
674        // Collect every marker whose namespace map contains `symbol`.
675        // Iterate the indicator markers in the byte range covering the
676        // whole buffer so positions come back sorted.
677        let max = self
678            .active_state()
679            .buffer
680            .to_string()
681            .map(|s| s.len())
682            .unwrap_or(usize::MAX);
683        let mut out: Vec<usize> = margins
684            .query_indicator_range(0, max.saturating_add(1))
685            .into_iter()
686            .filter_map(|(id, start, _end)| {
687                // `id` is the MarkerId; look up whether `symbol` is one
688                // of its namespaces via `get_indicator_position` for the
689                // existence check plus a namespace-membership test.
690                if margins.get_indicator_position(id).is_some()
691                    && margins
692                        .namespaces_for_marker(id)
693                        .iter()
694                        .any(|n| n == symbol)
695                {
696                    Some(start)
697                } else {
698                    None
699                }
700            })
701            .collect();
702        out.sort_unstable();
703        out
704    }
705
706    fn active_event_log_len(&self) -> usize {
707        self.active_event_log().len()
708    }
709
710    fn notify_file_changed(&mut self, path: &str) {
711        self.handle_file_changed(path);
712    }
713
714    fn create_side_by_side_diff(
715        &mut self,
716        name: &str,
717        mode: &str,
718        old_content: &str,
719        new_content: &str,
720        hunks: &[(usize, usize, usize, usize)],
721    ) -> usize {
722        use crate::model::composite_buffer::{
723            CompositeLayout, DiffHunk, LineAlignment, PaneStyle, SourcePane,
724        };
725        use crate::primitives::text_property::TextPropertyEntry;
726
727        let old_buffer_id = self.active_window_mut().create_virtual_buffer(
728            "OLD".to_string(),
729            "text".to_string(),
730            true,
731        );
732        self.set_virtual_buffer_content(old_buffer_id, vec![TextPropertyEntry::text(old_content)])
733            .expect("seed OLD virtual buffer");
734
735        let new_buffer_id = self.active_window_mut().create_virtual_buffer(
736            "NEW".to_string(),
737            "text".to_string(),
738            true,
739        );
740        self.set_virtual_buffer_content(new_buffer_id, vec![TextPropertyEntry::text(new_content)])
741            .expect("seed NEW virtual buffer");
742
743        let sources = vec![
744            SourcePane::new(old_buffer_id, "OLD", false).with_style(PaneStyle::old_diff()),
745            SourcePane::new(new_buffer_id, "NEW", false).with_style(PaneStyle::new_diff()),
746        ];
747        let layout = CompositeLayout::SideBySide {
748            ratios: vec![0.5, 0.5],
749            show_separator: true,
750        };
751        let composite_id =
752            self.create_composite_buffer(name.to_string(), mode.to_string(), layout, sources);
753
754        let hunk_vec: Vec<DiffHunk> = hunks
755            .iter()
756            .map(|(os, oc, ns, nc)| DiffHunk::new(*os, *oc, *ns, *nc))
757            .collect();
758        let old_line_count = old_content.lines().count();
759        let new_line_count = new_content.lines().count();
760        let alignment = LineAlignment::from_hunks(&hunk_vec, old_line_count, new_line_count);
761        self.active_window_mut()
762            .set_composite_alignment(composite_id, alignment);
763
764        self.switch_buffer(composite_id);
765
766        composite_id.0
767    }
768
769    fn set_composite_initial_focus_hunk_on(&mut self, composite_handle: usize, hunk_index: usize) {
770        use crate::model::event::BufferId;
771        let id = BufferId(composite_handle);
772        if let Some(c) = self.active_window_mut().get_composite_mut(id) {
773            c.initial_focus_hunk = Some(hunk_index);
774        }
775    }
776
777    fn composite_initial_focus_hunk_on(&self, composite_handle: usize) -> Option<usize> {
778        use crate::model::event::BufferId;
779        let id = BufferId(composite_handle);
780        self.active_window()
781            .get_composite(id)
782            .and_then(|c| c.initial_focus_hunk)
783    }
784
785    fn composite_next_hunk_active_on(&mut self, composite_handle: usize) -> bool {
786        use crate::model::event::BufferId;
787        let id = BufferId(composite_handle);
788        self.active_window_mut().composite_next_hunk_active(id)
789    }
790
791    fn composite_prev_hunk_active_on(&mut self, composite_handle: usize) -> bool {
792        use crate::model::event::BufferId;
793        let id = BufferId(composite_handle);
794        self.active_window_mut().composite_prev_hunk_active(id)
795    }
796
797    fn flush_layout_for_tests(&mut self) {
798        self.flush_layout();
799    }
800
801    fn status_message(&self) -> Option<String> {
802        self.get_status_message().cloned()
803    }
804
805    fn seed_virtual_line(
806        &mut self,
807        byte_offset: usize,
808        text: &str,
809        fg: Option<(u8, u8, u8)>,
810        bg: Option<(u8, u8, u8)>,
811        placement: &str,
812        namespace: &str,
813        priority: i32,
814    ) {
815        use crate::view::virtual_text::{VirtualTextNamespace, VirtualTextPosition};
816        use ratatui::style::{Color, Style};
817        let pos = match placement {
818            "above" | "Above" | "LineAbove" => VirtualTextPosition::LineAbove,
819            "below" | "Below" | "LineBelow" => VirtualTextPosition::LineBelow,
820            other => panic!(
821                "seed_virtual_line: unsupported placement {other:?}; want 'above' or 'below'"
822            ),
823        };
824        let mut style = Style::default();
825        if let Some((r, g, b)) = fg {
826            style = style.fg(Color::Rgb(r, g, b));
827        } else {
828            style = style.fg(Color::DarkGray);
829        }
830        if let Some((r, g, b)) = bg {
831            style = style.bg(Color::Rgb(r, g, b));
832        }
833        let ns = VirtualTextNamespace::from_string(namespace.to_string());
834        let state = self.active_state_mut();
835        state.virtual_texts.add_line(
836            &mut state.marker_list,
837            byte_offset,
838            text.to_string(),
839            style,
840            pos,
841            ns,
842            priority,
843        );
844    }
845
846    fn virtual_text_count(&self) -> usize {
847        self.active_state().virtual_texts.len()
848    }
849
850    fn clear_virtual_text_namespace(&mut self, namespace: &str) {
851        use crate::view::virtual_text::VirtualTextNamespace;
852        let ns = VirtualTextNamespace::from_string(namespace.to_string());
853        let state = self.active_state_mut();
854        state
855            .virtual_texts
856            .clear_namespace(&mut state.marker_list, &ns);
857    }
858
859    fn add_margin_annotation(
860        &mut self,
861        line: usize,
862        position: &str,
863        symbol: &str,
864        color: Option<(u8, u8, u8)>,
865        annotation_id: Option<&str>,
866    ) {
867        use crate::model::event::{Event, MarginContentData, MarginPositionData};
868        let pos = match position {
869            "left" | "Left" => MarginPositionData::Left,
870            "right" | "Right" => MarginPositionData::Right,
871            other => panic!("add_margin_annotation: unsupported position {other:?}"),
872        };
873        let event = Event::AddMarginAnnotation {
874            line,
875            position: pos,
876            content: MarginContentData::Symbol {
877                text: symbol.to_string(),
878                color,
879            },
880            annotation_id: annotation_id.map(|s| s.to_string()),
881        };
882        self.apply_event_to_active_buffer(&event);
883    }
884
885    fn remove_margin_annotation(&mut self, annotation_id: &str) {
886        use crate::model::event::Event;
887        let event = Event::RemoveMarginAnnotation {
888            annotation_id: annotation_id.to_string(),
889        };
890        self.apply_event_to_active_buffer(&event);
891    }
892
893    fn margin_left_total_width(&self) -> usize {
894        self.active_state().margins.left_total_width()
895    }
896
897    fn top_line_number(&mut self) -> usize {
898        let top_byte = self.active_viewport().top_byte;
899        self.active_state_mut().buffer.get_line_number(top_byte)
900    }
901
902    fn primary_scrollbar_geometry(&self) -> Option<(usize, usize, u16, u16)> {
903        let areas = self.get_split_areas();
904        let (_split, _buf, _content, scrollbar_rect, thumb_start, thumb_end) = areas.first()?;
905        Some((
906            *thumb_start,
907            *thumb_end,
908            scrollbar_rect.height,
909            scrollbar_rect.y,
910        ))
911    }
912}