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.
This commit is contained in:
Levi Neuwirth 2026-09-01 11:18:38 +02:00
parent db18261a5d
commit 951c6447b3
No known key found for this signature in database
1 changed files with 183 additions and 38 deletions

View File

@ -3965,6 +3965,13 @@ impl EditorState {
/// * `ScrollUp` / `ScrollDown` scroll the window under the /// * `ScrollUp` / `ScrollDown` scroll the window under the
/// cursor by [`SCROLL_LINES`] lines, without changing the /// cursor by [`SCROLL_LINES`] lines, without changing the
/// buffer cursor or active window. /// 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 /// Mouse moves with no buttons (`Moved`) and other buttons are
/// ignored. Clicks on a window's mode line are also ignored /// ignored. Clicks on a window's mode line are also ignored
@ -7037,15 +7044,44 @@ mod tests {
size, size,
}; };
let _ = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, 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::<std::rc::Rc<std::cell::RefCell<crate::config_registry::ConfigRegistry>>>()
.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 { fn fresh_with(content: &[u8]) -> EditorState {
@ -10426,7 +10462,7 @@ mod tests {
content.extend_from_slice(&b"w".repeat(400)); content.extend_from_slice(&b"w".repeat(400));
content.push(b'\n'); content.push(b'\n');
let mut s = fresh_with(&content); 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; let before = s.core.borrow().active_window().view_left;
s.dispatch_mouse( s.dispatch_mouse(
@ -10452,7 +10488,7 @@ mod tests {
content.extend_from_slice(&b"w".repeat(400)); content.extend_from_slice(&b"w".repeat(400));
content.push(b'\n'); content.push(b'\n');
let mut s = fresh_with(&content); let mut s = fresh_with(&content);
paint_once(&s, term_size_24x80()); paint_truncated(&s, term_size_24x80());
s.dispatch_mouse( s.dispatch_mouse(
FrontendId::LOCAL, FrontendId::LOCAL,
@ -10484,7 +10520,7 @@ mod tests {
content.extend_from_slice(&b"w".repeat(width)); content.extend_from_slice(&b"w".repeat(width));
content.push(b'\n'); content.push(b'\n');
let mut s = fresh_with(&content); 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. // Far more notches than the bound can absorb.
for _ in 0..500 { for _ in 0..500 {
@ -10502,16 +10538,42 @@ mod tests {
viewport > 0, viewport > 0,
"fixture: the window must have content columns" "fixture: the window must have content columns"
); );
assert!( // **The exact bound, not a range.** `origin + viewport >= widest`
u64::from(window.view_left) + u64::from(viewport) >= width as u64, // alone admits every origin up to `widest − 1`, which leaves
"the final display column must still be reachable: origin {} + \ // almost the whole viewport blank and still passes.
viewport {viewport} against a widest line of {width}", assert_eq!(
window.view_left 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}", /// B7's bound **saturates at zero for a buffer narrower than the
window.view_left /// 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.extend_from_slice(&b"w".repeat(400));
content.push(b'\n'); content.push(b'\n');
let mut s = fresh_with(&content); 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()); paint_once(&s, term_size_24x80());
// Scroll sideways first, so the row can see the pin rather than assert_eq!(
// an origin that merely never left zero. s.core.borrow().windows[&id].last_wrap,
s.dispatch_mouse( crate::view::WrapMode::Wrap,
FrontendId::LOCAL, "setup: the paint must have recorded wrap from ui.line-wrap"
mouse(MouseEventKind::ScrollRight, 5, 5),
term_size_24x80(),
); );
assert!( assert_eq!(
s.core.borrow().active_window().view_left > 0, s.core.borrow().active_window().view_left,
"setup: the origin must be off zero before wrap is applied" 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( s.dispatch_mouse(
FrontendId::LOCAL, FrontendId::LOCAL,
@ -10559,7 +10618,93 @@ mod tests {
assert_eq!( assert_eq!(
s.core.borrow().active_window().view_left, s.core.borrow().active_window().view_left,
0, 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<WindowId> = 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"
); );
} }