test(gui-1b): step 3 witnesses the panel viewport, not the emitted event

The framing says it in as many words: "Not 'a PanelPointer was emitted'
--- the observable effect on the panel's viewport." The row I wrote
filtered and counted panel-pointer events, which is the blind spot the
framing exists to close rather than the defect it guards against. It
would have passed if the vertical axis emitted a horizontal gesture, if
the receiver dropped what arrived, or if some other event accompanied a
sub-threshold delta.

Both halves now run in one row. The PRODUCER is this frontend's
apply_wheel, reached through dispatch_window_event. The RECEIVER is a
real pmacs::editor::EditorState with a live panel window, driven through
classify_panel_pointer + apply_panel_pointer --- the pair the daemon
itself calls --- and the assertion is the panel window's (view_top,
view_left). Per axis: a sub-threshold delta puts nothing on the wire and
moves the viewport by nothing; the delta that completes the notch moves
it by exactly one step, on that axis and not the other.

That needs the editor crate, so pmacs-gpu gains a DEV-dependency on
pmacs --- test-only, never in the shipped graph --- and pmacs gains three
#[doc(hidden)] test-support methods beside the ones already there:
install_panel_view_for_test (which daemon.rs's own semantic_panel_view
now delegates to, so there is one fixture rather than two),
seed_window_buffer_for_test, and window_view_origin_for_test.

Three things the row failed on before passing, each now a named setup
fact rather than a silent dependency. The panel buffer starts empty and
scroll_window clamps to line_count - 1, so an unseeded panel cannot
scroll at all. Seeding it is not enough either: TextView caches the line
partition it was built with, so the window has to be handed a rebuilt
view. And the receiver re-derives the panel grid from an accepted
geometry declaration --- without one every coordinate is outside a grid
that does not exist and the gesture is Refused before it can do
anything.

The mutation that matters is the one no emission count could see:
dropping PKind::ScrollLeft/ScrollRight from the daemon's panel arm ---
the receiver half, the axis whose arm did not exist before B2 --- fires
this row. So do rounding instead of banking, and collapsing the two
axes into one accumulator.

Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 322;
--lib 2009; --lib --features crdt 2202; git diff --check.
This commit is contained in:
Levi Neuwirth 2026-09-02 13:47:18 +02:00
parent a7006faf47
commit e50f38ae20
No known key found for this signature in database
5 changed files with 311 additions and 125 deletions

1
Cargo.lock generated
View File

@ -2627,6 +2627,7 @@ dependencies = [
"env_logger", "env_logger",
"glyphon", "glyphon",
"loro", "loro",
"pmacs",
"pmacs-protocol", "pmacs-protocol",
"pollster", "pollster",
"sys-locale", "sys-locale",

View File

@ -70,3 +70,8 @@ unicode-width = "0.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
# TEST-ONLY, and never a runtime dependency: step 3's panel-wheel
# witness has to observe the RECEIVER's effect, not the event this
# frontend emits, because the defect it exists for is precisely
# "the frontend emits and the receiver discards".
pmacs = { path = ".." }

View File

@ -21972,6 +21972,18 @@ mod tests {
// Bottom panel Stage 2B-3 — the GPU band // Bottom panel Stage 2B-3 — the GPU band
// =================================================================== // ===================================================================
fn panel_frame_of_buffer(
buffer_id: BufferId,
rows: u32,
cols: u32,
geometry_epoch: u64,
panel_epoch: u64,
) -> PanelFrame {
let mut frame = panel_frame_of(rows, cols, geometry_epoch, panel_epoch);
frame.buffer_id = buffer_id;
frame
}
fn panel_frame_of(rows: u32, cols: u32, geometry_epoch: u64, panel_epoch: u64) -> PanelFrame { fn panel_frame_of(rows: u32, cols: u32, geometry_epoch: u64, panel_epoch: u64) -> PanelFrame {
let cells = (0..(rows as usize * cols as usize)) let cells = (0..(rows as usize * cols as usize))
.map(|_| terminal_cell(pmacs_protocol::Glyph::Char('x'), CellStyle::default())) .map(|_| terminal_cell(pmacs_protocol::Glyph::Char('x'), CellStyle::default()))
@ -22012,13 +22024,17 @@ mod tests {
/// `Metrics`; a second surface declaration is suppressed by design /// `Metrics`; a second surface declaration is suppressed by design
/// and returns `None`. /// and returns `None`.
fn present_panel_in_harness(h: &mut EffectHarness, panel_epoch: u64) { fn present_panel_in_harness(h: &mut EffectHarness, panel_epoch: u64) {
present_panel_of_buffer(h, BufferId::from_raw(77), panel_epoch);
}
fn present_panel_of_buffer(h: &mut EffectHarness, buffer_id: BufferId, panel_epoch: u64) {
{ {
let state = h.app.state.as_mut().expect("harness state"); let state = h.app.state.as_mut().expect("harness state");
state.set_panel_wire(PANEL_MIN_VERSION); state.set_panel_wire(PANEL_MIN_VERSION);
let (epoch, total) = state let (epoch, total) = state
.next_geometry_declaration(GeometryTrigger::Metrics) .next_geometry_declaration(GeometryTrigger::Metrics)
.expect("metrics always advance the panel geometry epoch"); .expect("metrics always advance the panel geometry epoch");
let frame = panel_frame_of(4, total.cols.max(1), epoch, panel_epoch); let frame = panel_frame_of_buffer(buffer_id, 4, total.cols.max(1), epoch, panel_epoch);
let _ = state.apply_attach_message(InstanceMessage::PanelFrame( let _ = state.apply_attach_message(InstanceMessage::PanelFrame(
PanelFramePayload::Present(frame), PanelFramePayload::Present(frame),
)); ));
@ -22031,36 +22047,51 @@ mod tests {
let _ = h.read_until_sentinel(); let _ = h.read_until_sentinel();
} }
/// Step 3 — **the panel's fractional wheel, end to end, per axis.** /// The RECEIVER half of step 3: a real editor with a bottom panel,
/// seeded with something to scroll in both directions, and with a
/// frame geometry accepted.
/// ///
/// Revision 20 sharpened this witness because its earlier form was /// Two things here are load-bearing, and each was found by the row
/// satisfiable with the mechanism it protects entirely broken: a /// failing without it. The panel buffer starts **empty**, and
/// whole tick passes straight through #243's vertical receiver even /// `scroll_window` clamps to `line_count - 1`, so an unseeded panel
/// if B1's accumulator discards every sub-tick it is given. So the /// cannot scroll at all. And the receiver re-derives the panel grid
/// row requires **fractional input** — a first sub-threshold delta /// from an accepted geometry declaration — without one, every
/// produces **no** gesture, and accumulated same-panel deltas /// coordinate is outside a grid that does not exist and the gesture
/// produce **exactly one** — and it requires that on **each axis** /// is `Refused` before it can have any effect.
/// separately, because a single accumulator fed by both axes passes fn panel_receiver() -> (
/// any one-axis row. pmacs::editor::EditorState,
pmacs::protocol::FrontendId,
pmacs::window::WindowId,
BufferId,
) {
let editor = pmacs::editor::EditorState::new();
let fid = pmacs::protocol::FrontendId(4242);
let (_document, panel) = editor.install_panel_view_for_test(fid, true);
let panel = panel.expect("the fixture installs a panel window");
let wide_line = "w".repeat(400);
editor.seed_window_buffer_for_test(panel, &format!("{wide_line}\n").repeat(50));
let panel_buffer = editor
.window_buffer_for_test(panel)
.expect("the panel window has a buffer");
let _ =
editor.accept_semantic_frame_geometry(fid, 1, pmacs_protocol::CellSize::new(24, 80));
(editor, fid, panel, panel_buffer)
}
/// The PRODUCER half of step 3: this frontend, presenting a panel
/// for the very buffer the receiver is showing — so the gestures it
/// emits are about the same window the assertions read — with the
/// pointer parked on a panel **cell**.
/// ///
/// Driven through `dispatch_window_event` and observed on the wire, /// Panel chrome banks nothing at all, and a probe that drifted onto
/// which is where a panel gesture actually goes. /// it would satisfy every "nothing moved" assertion for entirely
/// /// the wrong reason, so the target is asserted here.
/// *Mutations: round the notch instead of banking it → the fn panel_producer(panel_buffer: BufferId) -> EffectHarness {
/// sub-threshold legs (a 0.6 delta becomes a whole tick); bank into
/// one accumulator per surface instead of per (surface, axis) → the
/// second axis's first leg, which the first axis's leftover
/// completes.*
#[test]
fn step3_a_panel_wheel_needs_a_whole_notch_on_each_axis() {
use winit::dpi::PhysicalPosition; use winit::dpi::PhysicalPosition;
use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; use winit::event::DeviceId;
let mut h = EffectHarness::new(); let mut h = EffectHarness::new();
present_panel_in_harness(&mut h, 1); present_panel_of_buffer(&mut h, panel_buffer, 1);
// A pixel inside the panel's content, so the wheel target is a
// cell rather than the band's chrome.
let (px, py, _, ph) = h let (px, py, _, ph) = h
.app .app
.state .state
@ -22078,63 +22109,145 @@ mod tests {
h.app.classify_wheel_target(point.0, point.1), h.app.classify_wheel_target(point.0, point.1),
WheelTarget::PanelCell { .. } WheelTarget::PanelCell { .. }
), ),
"setup: the probe must be a panel CELL — panel chrome banks \ "setup: the probe must be a panel CELL"
nothing at all, and would satisfy every 'no gesture' \
assertion below for the wrong reason"
); );
h
}
let mut wheel = |dx: f32, dy: f32| { /// Step 3 — **the panel wheel's END-TO-END effect, on both axes,
h.feed(&WindowEvent::MouseWheel { /// driven by fractional input.**
device_id: DeviceId::dummy(), ///
delta: MouseScrollDelta::LineDelta(dx, -dy), /// The framing is explicit that an emission-only witness will not
phase: TouchPhase::Moved, /// do: *"Not 'a `PanelPointer` was emitted' — the observable effect
}) /// on the panel's viewport."* The defect it guards is exactly "the
/// frontend emits and the receiver discards", and a row that counts
/// emitted events reproduces that blind spot rather than catching
/// it — it would pass if the vertical axis emitted a horizontal
/// gesture, if the receiver dropped it, or if some other event
/// accompanied a sub-threshold delta.
///
/// So this row runs both halves. The **producer** is this
/// frontend's `apply_wheel`, reached through
/// `dispatch_window_event`. The **receiver** is a real
/// `pmacs::editor::EditorState` with a live panel window, driven
/// through `classify_panel_pointer` + `apply_panel_pointer`, the
/// pair the daemon itself calls. The assertion is the panel
/// window's `(view_top, view_left)`.
///
/// Per axis: a first sub-threshold delta moves the viewport by
/// **nothing** and puts **nothing** on the wire, and the delta that
/// completes the notch moves it by **exactly one step** — once, not
/// twice, and not the sum of everything banked.
///
/// *Mutations: round the notch instead of banking it → the
/// sub-threshold legs; bank into one accumulator per surface rather
/// than per (surface, axis) → the second axis's first leg, which
/// the first axis's leftover completes; drop `PKind::ScrollLeft` /
/// `ScrollRight` from the daemon's panel arm → the horizontal
/// completion, which no emission count can see.*
#[test]
fn step3_a_panel_wheel_moves_the_panel_viewport_once_per_notch_per_axis() {
use winit::event::{DeviceId, MouseScrollDelta, TouchPhase};
let (mut editor, fid, panel, panel_buffer) = panel_receiver();
let origin = |editor: &pmacs::editor::EditorState| {
editor
.window_view_origin_for_test(panel)
.expect("the panel window is live")
}; };
let gestures = |step: &Step| { assert_eq!(origin(&editor), (0, 0), "setup: the panel starts home");
let mut h = panel_producer(panel_buffer);
// One turn of the wheel, carried all the way through: the
// frontend's events are replayed into the editor exactly as the
// daemon replays them.
let turn =
|h: &mut EffectHarness, editor: &mut pmacs::editor::EditorState, dx: f32, dy: f32| {
let step = h.feed(&WindowEvent::MouseWheel {
device_id: DeviceId::dummy(),
delta: MouseScrollDelta::LineDelta(dx, -dy),
phase: TouchPhase::Moved,
});
let mut replayed = 0usize;
for event in &step.outbound {
if let pmacs_protocol::FrontendEvent::PanelPointer {
buffer_id,
coord,
kind,
mods,
..
} = event
{
let disposition =
editor.classify_panel_pointer(fid, *buffer_id, *coord, *kind);
editor.apply_panel_pointer(fid, &disposition, *coord, *kind, *mods);
replayed += 1;
}
}
(step, replayed)
};
// Vertical, sub-threshold: nothing on the wire, nothing moves.
let (step, replayed) = turn(&mut h, &mut editor, 0.0, 0.6);
assert!(
step.outbound.is_empty(),
"a sub-threshold vertical delta must put NOTHING on the \
wire, got {:?}",
step.outbound step.outbound
.iter()
.filter(|e| {
matches!(
e,
pmacs_protocol::FrontendEvent::PanelPointer { .. }
| pmacs_protocol::FrontendEvent::PanelPointerMapped { .. }
)
})
.count()
};
// Vertical: 0.6 of a notch is not a notch.
let step = wheel(0.0, 0.6);
assert_eq!(
gestures(&step),
0,
"a sub-threshold vertical delta must reach the panel as \
nothing at all"
); );
// Horizontal, before the vertical bank is completed: if the two assert_eq!(replayed, 0);
// axes shared one accumulator, this 0.6 would finish the
// vertical 0.6 and fire here.
let step = wheel(0.6, 0.0);
assert_eq!( assert_eq!(
gestures(&step), origin(&editor),
0, (0, 0),
"and a sub-threshold horizontal delta must not be completed \ "and the panel viewport must not move"
by the vertical one banked before it"
); );
// Completing each axis produces exactly one gesture, not two, // Horizontal, sub-threshold, with the vertical bank still
// and not the sum of everything banked so far. // standing: one accumulator fed by both axes would complete
let step = wheel(0.0, 0.6); // here and scroll.
assert_eq!( let (step, _) = turn(&mut h, &mut editor, 0.6, 0.0);
gestures(&step), assert!(
1, step.outbound.is_empty(),
"0.6 + 0.6 is one vertical notch, and exactly one" "a sub-threshold horizontal delta must not be completed by \
the vertical one banked before it, got {:?}",
step.outbound
); );
let step = wheel(0.6, 0.0);
assert_eq!( assert_eq!(
gestures(&step), origin(&editor),
1, (0, 0),
"and the horizontal axis completes on its own count" "and still nothing has moved on either axis"
);
// Completing the vertical notch: the viewport moves ONE step
// down, and the horizontal origin stays put.
let (_, replayed) = turn(&mut h, &mut editor, 0.0, 0.6);
assert_eq!(replayed, 1, "one notch is one gesture");
let after_vertical = origin(&editor);
assert!(
after_vertical.0 > 0,
"the completed vertical notch must scroll the panel"
);
assert_eq!(
after_vertical.1, 0,
"and must not move it sideways: a vertical notch that \
emitted a horizontal gesture would show up exactly here"
);
// Completing the horizontal notch: sideways this time, and the
// vertical origin does not move again.
let (_, replayed) = turn(&mut h, &mut editor, 0.6, 0.0);
assert_eq!(replayed, 1, "one notch is one gesture");
let after_horizontal = origin(&editor);
assert!(
after_horizontal.1 > 0,
"the completed horizontal notch must scroll the panel \
sideways — the axis whose receiver arm did not exist before \
B2, and which no emission count can see"
);
assert_eq!(
after_horizontal.0, after_vertical.0,
"and must not scroll it vertically a second time"
); );
} }

View File

@ -6416,58 +6416,9 @@ mod tests {
fid: FrontendId, fid: FrontendId,
with_panel: bool, with_panel: bool,
) -> (crate::window::WindowId, Option<crate::window::WindowId>) { ) -> (crate::window::WindowId, Option<crate::window::WindowId>) {
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; // One fixture, shared with `pmacs-gpu`'s step-3 effect witness,
// which needs a real panel window to observe an effect on.
let mut core = editor.core.borrow_mut(); editor.install_panel_view_for_test(fid, with_panel)
let doc_buf = core.active_window().buffer_id;
let document = crate::window::WindowId::next();
let doc_view = {
let reg = core.registry.borrow();
crate::text_view::TextView::new(reg.get(doc_buf).expect("doc"))
};
core.windows
.insert(document, Window::new(document, doc_buf, doc_view));
let panel = with_panel.then(|| {
let panel_buf = core.registry.borrow_mut().create("*panel*");
let panel_id = crate::window::WindowId::next();
let panel_view = {
let reg = core.registry.borrow();
crate::text_view::TextView::new(reg.get(panel_buf).expect("panel"))
};
let mut window = Window::new(panel_id, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(crate::window::Side::Bottom);
params.fixed_rows = Some(4);
window.params = params;
core.windows.insert(panel_id, window);
panel_id
});
let layout = match panel {
Some(panel) => Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)],
weights: vec![1, 1],
},
},
None => Layout::single(document),
};
core.register_frontend_view(
fid,
FrontendView {
layout,
active: document,
fold_projection: false,
// Stage 2B-2 is dark: production negotiation still sets
// this `false` for every semantic session, so the
// projection is exercised through a test-only view (the
// framing's §7.2.2 posture).
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(document, panel)
} }
fn session(version: u32, semantic: bool) -> crate::presence::SessionState { fn session(version: u32, semantic: bool) -> crate::presence::SessionState {

View File

@ -2956,6 +2956,122 @@ impl EditorState {
} }
} }
/// Install a frontend view with a bottom panel, for tests that need
/// a real panel window to observe an effect on.
///
/// **Test support, not production.** It exists because the panel
/// receiver's effect is only observable against a live side window,
/// and `pmacs-gpu`'s step-3 witness has to see that effect rather
/// than the event it emits — the defect it guards is precisely
/// "the frontend emits and the receiver discards", which an
/// emission-only row reproduces instead of catching.
///
/// Returns `(document, panel)`.
#[doc(hidden)]
pub fn install_panel_view_for_test(
&self,
frontend_id: FrontendId,
with_panel: bool,
) -> (WindowId, Option<WindowId>) {
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
let mut core = self.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let document = WindowId::next();
let doc_view = {
let reg = core.registry.borrow();
crate::text_view::TextView::new(reg.get(doc_buf).expect("doc"))
};
core.windows
.insert(document, Window::new(document, doc_buf, doc_view));
let panel = with_panel.then(|| {
let panel_buf = core.registry.borrow_mut().create("*panel*");
let panel_id = WindowId::next();
let panel_view = {
let reg = core.registry.borrow();
crate::text_view::TextView::new(reg.get(panel_buf).expect("panel"))
};
let mut window = Window::new(panel_id, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(crate::window::Side::Bottom);
params.fixed_rows = Some(4);
window.params = params;
core.windows.insert(panel_id, window);
panel_id
});
let layout = match panel {
Some(panel) => Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)],
weights: vec![1, 1],
},
},
None => Layout::single(document),
};
core.register_frontend_view(
frontend_id,
FrontendView {
layout,
active: document,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(document, panel)
}
/// Replace a window's buffer contents, so a test can give a panel
/// something to scroll. Without it the `*panel*` buffer is empty
/// and every scroll clamps to zero — a viewport row against it
/// would measure nothing.
#[doc(hidden)]
pub fn seed_window_buffer_for_test(&self, win_id: WindowId, text: &str) {
let core = self.core.borrow();
let Some(buffer_id) = core.windows.get(&win_id).map(|w| w.buffer_id) else {
return;
};
let registry = core.registry.clone();
drop(core);
{
let mut reg = registry.borrow_mut();
if let Ok(buf) = reg.get_mut(buffer_id) {
let _ = buf.set_generated_contents(text.as_bytes());
}
}
// **And rebuild the window's view.** `TextView` caches the line
// partition it was built with, and `scroll_window` reads its
// `line_count` — so a window left holding the pre-seed view
// clamps every scroll to zero and a viewport row against it
// measures nothing.
let view = {
let reg = registry.borrow();
reg.get(buffer_id).ok().map(crate::text_view::TextView::new)
};
if let Some(view) = view
&& let Some(window) = self.core.borrow_mut().windows.get_mut(&win_id)
{
window.text_view = view;
}
}
/// A window's viewport origin, `(view_top, view_left)` — the pair a
/// panel-wheel effect moves.
#[doc(hidden)]
pub fn window_view_origin_for_test(&self, win_id: WindowId) -> Option<(usize, u32)> {
let core = self.core.borrow();
let window = core.windows.get(&win_id)?;
Some((window.view_top, window.view_left))
}
/// The buffer a window is showing.
#[doc(hidden)]
pub fn window_buffer_for_test(&self, win_id: WindowId) -> Option<crate::buffer::BufferId> {
Some(self.core.borrow().windows.get(&win_id)?.buffer_id)
}
/// Classify an authenticated panel gesture, WITHOUT applying it /// Classify an authenticated panel gesture, WITHOUT applying it
/// (Q#BP-R4). /// (Q#BP-R4).
/// ///