From 951c6447b3a0a903ce52e7841318d8d5d2d435be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 11:18:38 +0200 Subject: [PATCH] test(gui-1b): make B7's witnesses discriminate what they claim to Four evidence blockers. The implementation was right; the rows could not tell right from several wrongs. THE RIGHT BOUND WAS A RANGE, NOT THE CONTRACT. `origin + viewport >= widest` plus `origin < widest` admits every origin from `widest - viewport` through `widest - 1` --- almost the entire viewport blank, and passing. It now asserts the exact value, `widest.saturating_sub( viewport)`. The `widest - 1` mutant used to pass and now fires two rows. THE "SATURATES AT ZERO" ROW ONLY TESTED THE LOWER CLAMP. Its document is 400 columns wide, so it never exercised a buffer NARROWER than the viewport --- the case where `widest - viewport` underflows. The framed narrow-buffer row is added: short content, ten right notches, origin stays zero. THE FIXTURE FORGED THE WRAP CACHE. `paint_once` painted under the real default (wrap) and then wrote `last_wrap = Truncate` directly. Production fills that cache from `ui.line-wrap` during paint, so a row built on a forged value proves nothing about the path that fills it. `set_line_wrap` now sets the buffer-local setting, `paint_truncated` paints and ASSERTS the paint recorded truncate, and the wrap row paints a genuinely wrapped state at origin zero and shows a right notch is inert. Deleting the wheel guard still fires it, and only it. ALL FOUR ROWS USED ONE WINDOW. A mutant routing horizontal scroll to the ACTIVE window instead of `win_id` passed every one of them, and B2 requires the document surface under the pointer --- horizontal state is per-window. A split-window row wheels over the pane the pointer names, asserts only that pane's `view_left` moves, that the other is untouched, and that focus does not follow. The mutant fires it, and only it. `dispatch_mouse`'s documentation now covers the horizontal arms, their bound, and that the pane under the pointer is the one that moves. --- src/editor.rs | 221 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 183 insertions(+), 38 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 4cdfa90..71c441e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3965,6 +3965,13 @@ impl EditorState { /// * `ScrollUp` / `ScrollDown` scroll the window under the /// cursor by [`SCROLL_LINES`] lines, without changing the /// buffer cursor or active window. + /// * `ScrollLeft` / `ScrollRight` move that same window's + /// horizontal origin by [`SCROLL_COLUMNS`] columns (GUI Stage + /// 1b B7), likewise without changing the cursor or the active + /// window. **Horizontal state is per-window**, so the pane under + /// the pointer is the one that moves; the origin is bounded by + /// `widest − viewport`, saturating at zero, and pinned to zero + /// under wrap. /// /// Mouse moves with no buttons (`Moved`) and other buttons are /// ignored. Clicks on a window's mode line are also ignored @@ -7037,15 +7044,44 @@ mod tests { size, }; let _ = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); - // B7's bound only exists under truncate: a wrapped line has - // nothing past the right edge, and clause 5 pins the origin to - // zero. Set AFTER the paint, which writes `last_wrap` from the - // view config — a row about the bound must be a truncate row. - let mut core = s.core.borrow_mut(); - let id = core.active_window_id(); - if let Some(window) = core.windows.get_mut(&id) { - window.last_wrap = crate::view::WrapMode::Truncate; - } + } + + /// Set `ui.line-wrap` for a window's buffer, the way production does. + /// + /// **Not by writing `last_wrap` directly.** That field is a cache + /// the paint fills from this setting; forging it tests a state the + /// running editor never reaches, and a row built on a forged cache + /// proves nothing about the path that fills it. + fn set_line_wrap(s: &EditorState, win_id: WindowId, value: &str) { + let buffer_id = s.core.borrow().windows[&win_id].buffer_id; + let registry = s + .lua_host + .lua() + .app_data_ref::>>() + .expect("a config registry"); + registry + .borrow_mut() + .set_local( + buffer_id, + "ui.line-wrap", + crate::config_registry::ConfigValue::Str(value.to_owned()), + ) + .expect("ui.line-wrap is buffer-local and settable"); + } + + /// Paint with `ui.line-wrap` set to `truncate` FIRST, and assert the + /// paint recorded it — so a row about B7's bound rests on the cache + /// production actually writes. + fn paint_truncated(s: &EditorState, size: pmacs_protocol::CellSize) { + let id = s.core.borrow().active_window_id(); + set_line_wrap(s, id, "truncate"); + paint_once(s, size); + assert_eq!( + s.core.borrow().windows[&id].last_wrap, + crate::view::WrapMode::Truncate, + "the paint must have recorded truncate from ui.line-wrap; a \ + row about the right bound is meaningless under wrap" + ); } fn fresh_with(content: &[u8]) -> EditorState { @@ -10426,7 +10462,7 @@ mod tests { content.extend_from_slice(&b"w".repeat(400)); content.push(b'\n'); let mut s = fresh_with(&content); - paint_once(&s, term_size_24x80()); + paint_truncated(&s, term_size_24x80()); let before = s.core.borrow().active_window().view_left; s.dispatch_mouse( @@ -10452,7 +10488,7 @@ mod tests { content.extend_from_slice(&b"w".repeat(400)); content.push(b'\n'); let mut s = fresh_with(&content); - paint_once(&s, term_size_24x80()); + paint_truncated(&s, term_size_24x80()); s.dispatch_mouse( FrontendId::LOCAL, @@ -10484,7 +10520,7 @@ mod tests { content.extend_from_slice(&b"w".repeat(width)); content.push(b'\n'); let mut s = fresh_with(&content); - paint_once(&s, term_size_24x80()); + paint_truncated(&s, term_size_24x80()); // Far more notches than the bound can absorb. for _ in 0..500 { @@ -10502,16 +10538,42 @@ mod tests { viewport > 0, "fixture: the window must have content columns" ); - assert!( - u64::from(window.view_left) + u64::from(viewport) >= width as u64, - "the final display column must still be reachable: origin {} + \ - viewport {viewport} against a widest line of {width}", - window.view_left + // **The exact bound, not a range.** `origin + viewport >= widest` + // alone admits every origin up to `widest − 1`, which leaves + // almost the whole viewport blank and still passes. + assert_eq!( + window.view_left, + u32::try_from(width) + .unwrap_or(u32::MAX) + .saturating_sub(viewport), + "the origin rests exactly at widest − viewport" ); - assert!( - u64::from(window.view_left) < width as u64, - "and the origin must not pass every glyph: {} against {width}", - window.view_left + } + + /// B7's bound **saturates at zero for a buffer narrower than the + /// viewport**: `widest − viewport` underflows, and the origin must + /// stay at zero rather than wrapping to a huge maximum. + /// + /// The 400-column rows cannot see this — they never exercise a + /// document that fits. + #[test] + fn b7_a_document_narrower_than_the_viewport_never_scrolls() { + use crossterm::event::MouseEventKind; + let mut s = fresh_with(b"short\nalso short\n"); + paint_truncated(&s, term_size_24x80()); + + for _ in 0..10 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + } + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "a document that fits has a maximum origin of zero" ); } @@ -10530,25 +10592,22 @@ mod tests { content.extend_from_slice(&b"w".repeat(400)); content.push(b'\n'); let mut s = fresh_with(&content); + // A REAL wrapped state: `ui.line-wrap` is set to wrap and the + // paint records it. Nothing is forged, so the row exercises the + // cache production writes. + let id = s.core.borrow().active_window_id(); + set_line_wrap(&s, id, "wrap"); paint_once(&s, term_size_24x80()); - // Scroll sideways first, so the row can see the pin rather than - // an origin that merely never left zero. - s.dispatch_mouse( - FrontendId::LOCAL, - mouse(MouseEventKind::ScrollRight, 5, 5), - term_size_24x80(), + assert_eq!( + s.core.borrow().windows[&id].last_wrap, + crate::view::WrapMode::Wrap, + "setup: the paint must have recorded wrap from ui.line-wrap" ); - assert!( - s.core.borrow().active_window().view_left > 0, - "setup: the origin must be off zero before wrap is applied" + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "setup: a wrapped buffer starts pinned at zero" ); - { - let mut core = s.core.borrow_mut(); - let id = core.active_window_id(); - if let Some(window) = core.windows.get_mut(&id) { - window.last_wrap = crate::view::WrapMode::Wrap; - } - } s.dispatch_mouse( FrontendId::LOCAL, @@ -10559,7 +10618,93 @@ mod tests { assert_eq!( s.core.borrow().active_window().view_left, 0, - "wrap pins the origin to zero; a notch under wrap moves nothing" + "a right notch under wrap is inert; without the guard it \ + would move the origin off zero" + ); + } + + /// B2/B7 — a horizontal notch moves the origin of **the document + /// surface under the pointer**, not the active window's. + /// + /// Horizontal state is per-window, and the four single-window rows + /// above cannot tell `win_id` from "the active window": a mutant + /// that routes every horizontal scroll to the active pane passes + /// all of them. This row wheels over the INACTIVE pane. + /// + /// It also pins that a wheel does not focus — the pane under the + /// pointer is named, not activated. + /// + /// *Mutation: route to the active window instead of `win_id` → this + /// row, and only this row.* + #[test] + fn b7_a_notch_moves_the_pane_under_the_pointer_not_the_active_one() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + s.lua_host + .lua() + .load("pmacs.window.split_vertical()") + .exec() + .expect("a vertical split"); + + // Both panes truncate, and both painted, so each has a real + // viewport width for B7's bound. + let ids: Vec = s.core.borrow().windows.keys().copied().collect(); + assert_eq!(ids.len(), 2, "fixture: exactly two panes"); + for id in &ids { + set_line_wrap(&s, *id, "truncate"); + } + paint_once(&s, term_size_24x80()); + + let active = s.core.borrow().active_window_id(); + let other = *ids.iter().find(|id| **id != active).expect("a second pane"); + // A 50/50 vertical split: column 60 is inside the right pane. + // Whichever pane that is, it must be the one that moves. + let target_col: u16 = 60; + let under_pointer = { + let core = s.core.borrow(); + window_at_cell( + &core, + FrontendId::LOCAL, + term_size_24x80(), + 5, + u32::from(target_col), + ) + .map_or(other, |(id, _)| id) + }; + + let before_active = s.core.borrow().windows[&active].view_left; + let before_other = s.core.borrow().windows[&other].view_left; + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, target_col), + term_size_24x80(), + ); + + let after_active = s.core.borrow().windows[&active].view_left; + let after_other = s.core.borrow().windows[&other].view_left; + let (moved, still) = if under_pointer == active { + ((after_active, before_active), (after_other, before_other)) + } else { + ((after_other, before_other), (after_active, before_active)) + }; + assert_eq!( + moved.0 - moved.1, + SCROLL_COLUMNS as u32, + "the pane under the pointer moves by one notch" + ); + assert_eq!( + still.0, still.1, + "and the other pane's origin is untouched — horizontal state \ + is per-window" + ); + assert_eq!( + s.core.borrow().active_window_id(), + active, + "a wheel names a pane; it does not focus it" ); }