feat(gpu): 1-pre P2 --- the effect harness, outbound AND local
Review round 1's blocker: P2 requires outbound events plus local
effects, and the harness recorded `Route` classifications only. The
wheel is the proof it was not enough --- a wheel route carries a delta,
and whether that becomes a viewport update, a panel event, a terminal
event or nothing at all is `State`'s to decide. A route names the
family; only running the body names the effect.
**`App::dispatch_window_event` is what makes P2 reachable, and it is the
substantive change here.** Left inside `window_event`, the dispatch
would force a harness to re-implement it --- and a harness that
re-implements the thing it tests witnesses its own copy. `window_event`
is now four lines: call dispatch, exit if it asks. **P3 narrows from a
33-line match to a single `if`.**
`EffectHarness` drives production code end to end:
* a REAL `AttachClient` over a `socketpair`, through the real
handshake, outbox, writer thread and encoder, so what is recorded is
the wire and not a mock's idea of it (`connect_stream_for_test` in
attach.rs exists only because the constructor is private to that
module; it adds no behaviour);
* a REAL windowless `State`, so the bodies take their real branches;
* `dispatch_window_event` itself.
Local effects have no wire trace, so each is read where it lands: exit
from the returned `EventOutcome`, redraw from a test-only
`State::render_calls`, resize from the surface config, the modifier
mutation from `App::modifiers`, and the scroll from `scroll_top`.
**Steps are delimited by a sentinel key, not a sleep.** "This step sent
nothing" is otherwise undecidable without waiting, and a fixed-duration
wait against a writer thread is the core-count assumption PR #235's CI
red was made of. The sentinel is not coalesceable (only viewport and
drag kinds are), so it can neither replace nor be replaced by a recorded
event. It does sit between steps, so cross-step coalescing that
production would perform is absent here --- stated in the harness doc,
since it makes the transcript per-step rather than as-coalesced.
**Never skips.** Per the ruling, a missing wgpu adapter is an assertion
failure and not a skip: this project has twice recorded a suite that
returned `ok` without running. Mutation M21 makes `new_headless` return
`None` and all NINE effect rows fail loudly while the thirteen pure
routing rows, which need no GPU, stay green --- the two tiers behaving
exactly as intended.
TWO ROWS WERE WRONG AND THE MUTATIONS FOUND THEM, WHICH IS THE POINT:
* the wheel row asserted `.all(|e| matches!(e, Viewport))` over the
transcript --- VACUOUSLY TRUE ON AN EMPTY ONE, so an outbound-blind
harness passed it. Now asserts non-empty first.
* with that fixed it still failed, for a second reason: the fixture
was two lines and could not scroll, and a headless `State` has no
attached buffer, so `scroll_by_lines` returned `None` and withheld
every send. Both are absences the harness manufactured itself ---
the same shape as the panel wire, below.
The panel wire is the third of those. `resumed` sets the frontend id and
the session version on the state before any geometry flush; the harness
did not, so `flush_panel_geometry` silently withheld the declaration and
the resize row failed against an absence of its own making. The harness
now mirrors that wiring and drains the attach-time declaration, so each
row's transcript holds only what its own event produced.
Evidence --- 22 rows (13 routing, 9 effect), 6 further mutations:
M18 exit effect discarded -> the close row
M19 redraw effect discarded -> the redraw row
M20 apply_resize stops declaring -> the resize row
M21 no wgpu adapter -> all NINE effect rows, loudly
M22 harness blind to OUTBOUND -> resize + wheel
M23 harness blind to LOCAL -> six rows
M22 and M23 together are P2's contract made executable: blind the
harness to either half and rows fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
1f8d194908
commit
f976dc1042
|
|
@ -1169,6 +1169,24 @@ impl AttachClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build a real [`AttachClient`] over an already-connected stream, for
|
||||
/// the GUI 1-pre effect harness in `main.rs`.
|
||||
///
|
||||
/// The harness needs to observe the protocol messages a dispatch
|
||||
/// actually produces. Faking the client would only witness the fake, so
|
||||
/// it drives the **real** handshake, outbox, writer thread and encoder
|
||||
/// over a `socketpair`, and reads the encoded `FrontendEvent`s off the
|
||||
/// other end. This wrapper exists solely because
|
||||
/// [`connect_stream_with_sink`] is private to this module and the
|
||||
/// harness is a sibling; it adds no behaviour of its own.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn connect_stream_for_test(
|
||||
stream: UnixStream,
|
||||
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
|
||||
) -> Result<AttachClient, AttachClientError> {
|
||||
connect_stream_with_sink(stream, None, sink)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -1579,6 +1579,11 @@ type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
|
|||
reason = "independent render/input state flags, not a config bitset"
|
||||
)]
|
||||
struct State {
|
||||
/// GUI 1-pre P2 — how many times [`State::render`] has been called.
|
||||
/// The redraw arm's only local effect, and invisible without this
|
||||
/// on a windowless `State`. Test-only.
|
||||
#[cfg(test)]
|
||||
render_calls: u64,
|
||||
// `None` in the headless render-test path (F-014): a windowless State
|
||||
// that renders to an offscreen texture instead of a surface.
|
||||
window: Option<Arc<Window>>,
|
||||
|
|
@ -3135,6 +3140,49 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Route one window event and perform it — **the whole of
|
||||
/// `window_event` except the exit itself**, which is returned rather
|
||||
/// than performed.
|
||||
///
|
||||
/// This split is what makes P2 reachable at all. Left inside
|
||||
/// `window_event`, the dispatch would force a harness to
|
||||
/// re-implement it, and a harness that re-implements the thing it
|
||||
/// tests witnesses its own copy. Here the harness drives production
|
||||
/// code, and `window_event` keeps only the one statement no headless
|
||||
/// test can reach — which narrows P3 from a 33-line match to a
|
||||
/// single `if`.
|
||||
fn dispatch_window_event(&mut self, event: &WindowEvent) -> EventOutcome {
|
||||
match route_event(event) {
|
||||
Route::Lifecycle(LifecycleRoute::Exit) => return EventOutcome::Exit,
|
||||
Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods,
|
||||
Route::Lifecycle(LifecycleRoute::Resize { width, height }) => {
|
||||
self.apply_resize(width, height);
|
||||
}
|
||||
Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(),
|
||||
Route::Keyboard {
|
||||
action: KeyAction::Press,
|
||||
key,
|
||||
} => return self.apply_keyboard(key),
|
||||
Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y),
|
||||
Route::Pointer(PointerRoute::Left(button_state)) => {
|
||||
self.apply_left_button(button_state);
|
||||
}
|
||||
Route::Pointer(PointerRoute::RightPress) => self.apply_right_press(),
|
||||
Route::Pointer(PointerRoute::Wheel(delta)) => self.apply_wheel(delta),
|
||||
// Three different facts, merged only because all three are
|
||||
// today nothing to do: a key-up the keyboard family claimed
|
||||
// and dropped, a button the pointer family has no semantics
|
||||
// for, and an event no family claims at all.
|
||||
Route::Keyboard {
|
||||
action: KeyAction::Release,
|
||||
..
|
||||
}
|
||||
| Route::Pointer(PointerRoute::UnusedButton)
|
||||
| Route::Unrouted => {}
|
||||
}
|
||||
EventOutcome::Continue
|
||||
}
|
||||
|
||||
/// Perform [`KeyAction::Press`]. The router has already
|
||||
/// discarded key-ups, so `key` is always a press.
|
||||
#[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order.
|
||||
|
|
@ -3566,6 +3614,295 @@ impl<'a> RoutingHarness<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// GUI Stage 1-pre — the headless EFFECT harness (P2).
|
||||
///
|
||||
/// [`RoutingHarness`] above answers "where did this event go?".
|
||||
/// This one answers **"what did it do?"** — the other half of P2, and
|
||||
/// the half a route cannot supply on its own. A wheel route carries a
|
||||
/// delta; whether that delta becomes a panel event, a terminal event, a
|
||||
/// viewport update, or nothing at all depends on `State`, so only
|
||||
/// running the body can say.
|
||||
///
|
||||
/// It drives production code end to end:
|
||||
///
|
||||
/// * **A real `AttachClient` over a `socketpair`**, through the real
|
||||
/// handshake, outbox, writer thread and encoder. The harness reads
|
||||
/// encoded `pmacs_protocol::FrontendEvent`s off the daemon end, so what it records is
|
||||
/// the wire, not a mock's idea of it.
|
||||
/// * **A real windowless `State`**, so the bodies take their real
|
||||
/// branches.
|
||||
/// * **`App::dispatch_window_event`**, the production dispatch — not a
|
||||
/// re-implementation of it, which would witness only itself.
|
||||
///
|
||||
/// **Local effects** have no wire trace, so each is read from the place
|
||||
/// it actually lands: exit from the returned [`EventOutcome`], redraw
|
||||
/// from `State::render_calls`, resize from the surface config, and the
|
||||
/// modifier mutation from `App::modifiers`.
|
||||
///
|
||||
/// **Two honest limits, both structural.**
|
||||
///
|
||||
/// 1. `window_event` itself is still unreachable (P3) — the harness
|
||||
/// calls `dispatch_window_event`, which is everything `window_event`
|
||||
/// does except the `event_loop.exit()` it cannot construct.
|
||||
/// 2. A step is delimited by a **sentinel key** pushed through the same
|
||||
/// outbox, so "this step sent nothing" is decidable without a sleep.
|
||||
/// The sentinel is not coalesceable (only viewport and drag kinds
|
||||
/// are), so it can neither replace nor be replaced by a recorded
|
||||
/// event — but it does sit between steps, so consecutive same-kind
|
||||
/// events that production would coalesce are recorded separately
|
||||
/// here. That makes the transcript per-step rather than as-coalesced,
|
||||
/// which is what a per-step contract wants.
|
||||
#[cfg(test)]
|
||||
struct EffectHarness {
|
||||
app: App,
|
||||
daemon: std::os::unix::net::UnixStream,
|
||||
/// Distinguishes one sentinel from the next, so a step cannot end on
|
||||
/// a stale one left behind by an earlier read.
|
||||
sentinel_seq: u32,
|
||||
}
|
||||
|
||||
/// A local effect — something a dispatch did that leaves no wire trace.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LocalEffect {
|
||||
/// The dispatch asked the event loop to exit.
|
||||
Exit,
|
||||
/// `State::render` was called.
|
||||
Redraw,
|
||||
/// The surface was reconfigured to this extent.
|
||||
Resize { width: u32, height: u32 },
|
||||
/// The document scrolled to this top line.
|
||||
Scroll { top: usize },
|
||||
/// `App::modifiers` changed to this value.
|
||||
Modifiers(winit::keyboard::ModifiersState),
|
||||
}
|
||||
|
||||
/// What one dispatched event did.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct Step {
|
||||
local: Vec<LocalEffect>,
|
||||
outbound: Vec<pmacs_protocol::FrontendEvent>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl EffectHarness {
|
||||
/// The sentinel key. A private-use scalar, so it cannot collide with
|
||||
/// anything a body would legitimately send.
|
||||
const SENTINEL: char = '\u{e000}';
|
||||
|
||||
/// Build the harness, or fail loudly.
|
||||
///
|
||||
/// **Never skips.** `State::new_headless` returns `None` when no
|
||||
/// wgpu adapter exists, and a suite that quietly returns `ok` in
|
||||
/// that case proves nothing — this project has recorded that failure
|
||||
/// mode twice. Under `PMACS_REQUIRE_GPU` the absence is an assertion
|
||||
/// failure; without it the harness still panics rather than skips,
|
||||
/// because these rows are the whole of P2.
|
||||
fn new() -> Self {
|
||||
let (client_stream, mut daemon) =
|
||||
std::os::unix::net::UnixStream::pair().expect("socketpair");
|
||||
|
||||
// The daemon half of the handshake, synchronous and inline: the
|
||||
// client blocks reading `Hello` before it writes anything, so a
|
||||
// thread would only add a join.
|
||||
let handshake = std::thread::spawn(move || {
|
||||
pmacs_protocol::write_message(&mut daemon, &test_hello()).expect("write Hello");
|
||||
let _: pmacs_protocol::AttachRequest =
|
||||
pmacs_protocol::read_message(&mut daemon).expect("read AttachRequest");
|
||||
let _: pmacs_protocol::SessionBootstrapRequest =
|
||||
pmacs_protocol::read_message(&mut daemon).expect("read bootstrap");
|
||||
daemon
|
||||
});
|
||||
let client = crate::attach::connect_stream_for_test(client_stream, |_| true)
|
||||
.expect("attach over socketpair");
|
||||
let daemon = handshake.join().expect("handshake thread");
|
||||
|
||||
// A document tall enough to scroll. A two-line fixture made the
|
||||
// wheel row pass vacuously: `scroll_by_lines` returns `None`
|
||||
// when there is nothing below the fold, so the row asserted
|
||||
// "every outbound event is a Viewport" over an EMPTY transcript.
|
||||
// Mutation M22 surfaced the first half of that and this fixture
|
||||
// is the second.
|
||||
let document = "line\n".repeat(200);
|
||||
let state = State::new_headless(640, 480, &document);
|
||||
assert!(
|
||||
state.is_some(),
|
||||
"no wgpu adapter: the 1-pre effect rows are P2's only witness and must not be skipped"
|
||||
);
|
||||
|
||||
let mut harness = Self {
|
||||
app: App {
|
||||
mode: Mode::Attach {
|
||||
socket: PathBuf::from("/nonexistent-harness-socket"),
|
||||
},
|
||||
proxy: None,
|
||||
state,
|
||||
pending_events: Vec::new(),
|
||||
attach_client: Some(client),
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
},
|
||||
daemon,
|
||||
sentinel_seq: 0,
|
||||
};
|
||||
|
||||
// Mirror the post-connect wiring `resumed` performs. Without it
|
||||
// the panel wire stays below `PANEL_MIN_VERSION` and every
|
||||
// geometry declaration is silently withheld — the harness would
|
||||
// then witness an absence it created itself. Getting this wrong
|
||||
// is exactly what the first run of the resize row caught.
|
||||
{
|
||||
let client = harness.app.attach_client.as_ref().expect("harness client");
|
||||
let frontend_id = client.frontend_id();
|
||||
let session_version = client.session_protocol_version();
|
||||
let state = harness.app.state.as_mut().expect("harness state");
|
||||
state.set_frontend_id(frontend_id);
|
||||
state.set_panel_wire(session_version);
|
||||
// Stands in for the `BufferSnapshot` the daemon would send.
|
||||
// Without a current buffer `scroll_by_lines` scrolls locally
|
||||
// and returns `None`, so every viewport send is withheld and
|
||||
// the harness would witness an absence it manufactured —
|
||||
// the same trap the panel wire set above. This is the
|
||||
// pattern the file's other headless-State tests already use.
|
||||
state.current_buffer_id = Some(BufferId::from_raw(1));
|
||||
}
|
||||
harness.app.flush_panel_geometry(GeometryTrigger::Surface);
|
||||
|
||||
// Drain the attach-time declaration, so each row's transcript
|
||||
// contains only what its own event produced.
|
||||
harness.read_until_sentinel();
|
||||
harness
|
||||
}
|
||||
|
||||
/// Dispatch one event and report everything it did.
|
||||
fn feed(&mut self, event: &WindowEvent) -> Step {
|
||||
let before = self.snapshot();
|
||||
let outcome = self.app.dispatch_window_event(event);
|
||||
let after = self.snapshot();
|
||||
|
||||
let mut local = Vec::new();
|
||||
if outcome == EventOutcome::Exit {
|
||||
local.push(LocalEffect::Exit);
|
||||
}
|
||||
if after.render_calls > before.render_calls {
|
||||
local.push(LocalEffect::Redraw);
|
||||
}
|
||||
if after.extent != before.extent {
|
||||
local.push(LocalEffect::Resize {
|
||||
width: after.extent.0,
|
||||
height: after.extent.1,
|
||||
});
|
||||
}
|
||||
if after.modifiers != before.modifiers {
|
||||
local.push(LocalEffect::Modifiers(after.modifiers));
|
||||
}
|
||||
if after.scroll_top != before.scroll_top {
|
||||
local.push(LocalEffect::Scroll {
|
||||
top: after.scroll_top,
|
||||
});
|
||||
}
|
||||
|
||||
Step {
|
||||
local,
|
||||
outbound: self.read_until_sentinel(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable state the local effects are derived from.
|
||||
fn snapshot(&self) -> EffectSnapshot {
|
||||
let state = self.app.state.as_ref().expect("harness state");
|
||||
EffectSnapshot {
|
||||
render_calls: state.render_calls,
|
||||
extent: (state.config.width, state.config.height),
|
||||
modifiers: self.app.modifiers,
|
||||
scroll_top: state.scroll_top,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a sentinel through the same outbox and read until it comes
|
||||
/// back. Everything ahead of it belongs to the step just dispatched.
|
||||
///
|
||||
/// This is a **condition**, not a sleep: it blocks on the socket
|
||||
/// until the writer thread has drained past the sentinel, so it is
|
||||
/// insensitive to how many cores are free — the mistake PR #235's CI
|
||||
/// red was made of.
|
||||
fn read_until_sentinel(&mut self) -> Vec<pmacs_protocol::FrontendEvent> {
|
||||
self.sentinel_seq += 1;
|
||||
let tag = self.sentinel_seq;
|
||||
let client = self.app.attach_client.as_ref().expect("harness client");
|
||||
client
|
||||
.send_key(ProtocolKey::Char(Self::SENTINEL), sentinel_mods(tag))
|
||||
.expect("enqueue sentinel");
|
||||
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let event: pmacs_protocol::FrontendEvent =
|
||||
pmacs_protocol::read_message(&mut self.daemon).expect("read outbound");
|
||||
if is_sentinel(&event, tag) {
|
||||
return seen;
|
||||
}
|
||||
seen.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable state behind [`LocalEffect`].
|
||||
#[cfg(test)]
|
||||
struct EffectSnapshot {
|
||||
render_calls: u64,
|
||||
extent: (u32, u32),
|
||||
modifiers: winit::keyboard::ModifiersState,
|
||||
scroll_top: usize,
|
||||
}
|
||||
|
||||
/// Modifier bits carrying the sentinel's sequence number, so a stale
|
||||
/// sentinel cannot end the wrong step. Four bits is plenty: the harness
|
||||
/// reads every sentinel it writes, so the counter only has to
|
||||
/// distinguish neighbours.
|
||||
#[cfg(test)]
|
||||
fn sentinel_mods(tag: u32) -> Modifiers {
|
||||
let mut mods = Modifiers::NONE;
|
||||
if tag & 1 != 0 {
|
||||
mods |= Modifiers::CTRL;
|
||||
}
|
||||
if tag & 2 != 0 {
|
||||
mods |= Modifiers::ALT;
|
||||
}
|
||||
if tag & 4 != 0 {
|
||||
mods |= Modifiers::SHIFT;
|
||||
}
|
||||
mods
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn is_sentinel(event: &pmacs_protocol::FrontendEvent, tag: u32) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
pmacs_protocol::FrontendEvent::Key(k)
|
||||
if k.key == ProtocolKey::Char(EffectHarness::SENTINEL) && k.mods == sentinel_mods(tag)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_hello() -> pmacs_protocol::Hello {
|
||||
pmacs_protocol::Hello {
|
||||
protocol_version: pmacs_protocol::PROTOCOL_VERSION,
|
||||
assigned_frontend_id: pmacs_protocol::FrontendId(7),
|
||||
instance_identity: pmacs_protocol::InstanceIdentity {
|
||||
pmacs_version: "1-pre-harness".to_owned(),
|
||||
build_hash: None,
|
||||
instance_name: None,
|
||||
uptime_secs: 0,
|
||||
working_directory: "/".to_owned(),
|
||||
},
|
||||
instance_capabilities: pmacs_protocol::InstanceCapabilities {
|
||||
multi_frontend: true,
|
||||
crdt_replica: true,
|
||||
semantic_render: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod input_routing_tests {
|
||||
use super::*;
|
||||
|
|
@ -3800,6 +4137,183 @@ mod input_routing_tests {
|
|||
assert_eq!(route_one(&event), Route::Unrouted);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// P2 — the EFFECT rows. `EffectHarness` drives the production
|
||||
// dispatch over a real client and a real windowless `State`, and
|
||||
// records BOTH halves of P2: the outbound protocol messages and the
|
||||
// local effects that leave no wire trace.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// P2 — a redraw's whole effect is local. It sends the daemon
|
||||
/// nothing, so **the outbound half of the transcript is empty and
|
||||
/// the local half is what proves the arm ran** — which is precisely
|
||||
/// why P2 requires both.
|
||||
#[test]
|
||||
fn a_redraw_produces_a_local_effect_and_no_outbound_traffic() {
|
||||
let mut h = EffectHarness::new();
|
||||
let step = h.feed(&WindowEvent::RedrawRequested);
|
||||
assert_eq!(step.local, vec![LocalEffect::Redraw]);
|
||||
assert!(
|
||||
step.outbound.is_empty(),
|
||||
"a redraw must send the daemon nothing, got {:?}",
|
||||
step.outbound
|
||||
);
|
||||
}
|
||||
|
||||
/// P2 — `CloseRequested` is the other silent arm: one local effect,
|
||||
/// no traffic.
|
||||
#[test]
|
||||
fn a_close_request_exits_locally_and_sends_nothing() {
|
||||
let mut h = EffectHarness::new();
|
||||
let step = h.feed(&WindowEvent::CloseRequested);
|
||||
assert_eq!(step.local, vec![LocalEffect::Exit]);
|
||||
assert!(step.outbound.is_empty(), "{:?}", step.outbound);
|
||||
}
|
||||
|
||||
/// P2 — a modifier change mutates `App` and sends nothing. The
|
||||
/// mutation is the effect.
|
||||
#[test]
|
||||
fn a_modifier_change_mutates_state_and_sends_nothing() {
|
||||
let mut h = EffectHarness::new();
|
||||
let mods = ModifiersState::CONTROL;
|
||||
let step = h.feed(&modifiers_changed(mods));
|
||||
assert_eq!(step.local, vec![LocalEffect::Modifiers(mods)]);
|
||||
assert!(step.outbound.is_empty(), "{:?}", step.outbound);
|
||||
assert_eq!(h.app.modifiers, mods);
|
||||
}
|
||||
|
||||
/// P2 — a resize is the one lifecycle arm with **both** halves: it
|
||||
/// reconfigures the surface locally and declares the new cell
|
||||
/// geometry to the daemon.
|
||||
#[test]
|
||||
fn a_resize_reconfigures_locally_and_declares_geometry() {
|
||||
let mut h = EffectHarness::new();
|
||||
let step = h.feed(&WindowEvent::Resized(PhysicalSize::new(900, 500)));
|
||||
assert_eq!(
|
||||
step.local,
|
||||
vec![LocalEffect::Resize {
|
||||
width: 900,
|
||||
height: 500
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
step.outbound.iter().any(|e| matches!(
|
||||
e,
|
||||
pmacs_protocol::FrontendEvent::FrontendCellGeometry { .. }
|
||||
)),
|
||||
"a resize must declare the new cell geometry, got {:?}",
|
||||
step.outbound
|
||||
);
|
||||
}
|
||||
|
||||
/// P2 — the zero-extent clamp, observed as a real surface
|
||||
/// configuration rather than as a route value. wgpu rejects a
|
||||
/// zero-extent surface, so this is the row that would fail if the
|
||||
/// clamp were only cosmetic.
|
||||
#[test]
|
||||
fn a_minimize_configures_a_nonzero_surface() {
|
||||
let mut h = EffectHarness::new();
|
||||
let step = h.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0)));
|
||||
assert_eq!(
|
||||
step.local,
|
||||
vec![LocalEffect::Resize {
|
||||
width: 1,
|
||||
height: 1
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// P2, pointer — **the row that shows a route cannot stand in for an
|
||||
/// effect.** A wheel carries only a delta; whether it becomes a
|
||||
/// viewport update, a panel event, a terminal event or nothing at
|
||||
/// all is `State`'s to decide. Here, over a plain document, it
|
||||
/// scrolls and re-declares the scoped viewport.
|
||||
#[test]
|
||||
fn a_wheel_over_a_document_declares_a_new_viewport() {
|
||||
let mut h = EffectHarness::new();
|
||||
let event = WindowEvent::MouseWheel {
|
||||
device_id: DeviceId::dummy(),
|
||||
delta: MouseScrollDelta::LineDelta(0.0, -3.0),
|
||||
phase: TouchPhase::Moved,
|
||||
};
|
||||
let step = h.feed(&event);
|
||||
assert_eq!(
|
||||
step.local,
|
||||
vec![LocalEffect::Scroll {
|
||||
top: WHEEL_LINES_PER_TICK as usize * 3
|
||||
}],
|
||||
"a wheel scrolls locally"
|
||||
);
|
||||
// NOT `.all()` alone — that is vacuously true on an empty
|
||||
// transcript, so an outbound-blind harness would pass this row.
|
||||
// Mutation M22 found exactly that.
|
||||
assert!(
|
||||
!step.outbound.is_empty(),
|
||||
"a document wheel must send something"
|
||||
);
|
||||
assert!(
|
||||
step.outbound
|
||||
.iter()
|
||||
.all(|e| matches!(e, pmacs_protocol::FrontendEvent::Viewport { .. })),
|
||||
"a document wheel sends viewport updates and nothing else, got {:?}",
|
||||
step.outbound
|
||||
);
|
||||
}
|
||||
|
||||
/// P2, pointer — a wheel that resolves to **zero lines** is dropped
|
||||
/// by the body before any send. Same route, no effect: the
|
||||
/// distinction only exists once effects are observed.
|
||||
#[test]
|
||||
fn a_subtick_wheel_sends_nothing_at_all() {
|
||||
let mut h = EffectHarness::new();
|
||||
let event = WindowEvent::MouseWheel {
|
||||
device_id: DeviceId::dummy(),
|
||||
delta: MouseScrollDelta::LineDelta(0.0, 0.0),
|
||||
phase: TouchPhase::Moved,
|
||||
};
|
||||
let step = h.feed(&event);
|
||||
assert!(step.local.is_empty(), "{:?}", step.local);
|
||||
assert!(
|
||||
step.outbound.is_empty(),
|
||||
"a zero-line wheel must send nothing, got {:?}",
|
||||
step.outbound
|
||||
);
|
||||
}
|
||||
|
||||
/// P2, pointer — motion updates the cached pointer position, which
|
||||
/// is the state mutation the drag path later reads. It is not a
|
||||
/// `LocalEffect` variant because it is `State`-internal, so the row
|
||||
/// asserts it directly.
|
||||
#[test]
|
||||
fn cursor_motion_caches_the_pointer_position() {
|
||||
let mut h = EffectHarness::new();
|
||||
let event = WindowEvent::CursorMoved {
|
||||
device_id: DeviceId::dummy(),
|
||||
position: PhysicalPosition::new(30.0, 40.0),
|
||||
};
|
||||
h.feed(&event);
|
||||
assert_eq!(
|
||||
h.app.state.as_ref().expect("harness state").pointer_pos,
|
||||
Some((30.0, 40.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// P2 — a button the frontend has no semantics for reaches no body:
|
||||
/// nothing local, nothing outbound. The counterpart to the routing
|
||||
/// row that calls it claimed-and-dropped.
|
||||
#[test]
|
||||
fn an_unused_button_produces_no_effect_of_any_kind() {
|
||||
let mut h = EffectHarness::new();
|
||||
let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle));
|
||||
assert_eq!(
|
||||
step,
|
||||
Step {
|
||||
local: Vec::new(),
|
||||
outbound: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// P2 — the harness records a transcript, and the transcript
|
||||
/// distinguishes every routed effect from the others and from an
|
||||
/// unclaimed event. **Two of these rows produce no outbound traffic
|
||||
|
|
@ -3893,41 +4407,13 @@ impl ApplicationHandler<AppEvent> for App {
|
|||
}
|
||||
|
||||
/// P3 — the thin call-through. Every decision belongs to
|
||||
/// [`route_event`] and every effect to an `apply_*` method; what is
|
||||
/// left here is `event_loop.exit()`, which exists nowhere else in
|
||||
/// the crate and is the one thing a headless test cannot reach.
|
||||
/// [`Self::dispatch_window_event`], which a headless test drives
|
||||
/// directly. What is left here is `event_loop.exit()` — the one
|
||||
/// statement no headless test can reach, because `ActiveEventLoop`
|
||||
/// cannot exist outside a live event loop.
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
match route_event(&event) {
|
||||
Route::Lifecycle(LifecycleRoute::Exit) => event_loop.exit(),
|
||||
Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods,
|
||||
Route::Lifecycle(LifecycleRoute::Resize { width, height }) => {
|
||||
self.apply_resize(width, height);
|
||||
}
|
||||
Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(),
|
||||
Route::Keyboard {
|
||||
action: KeyAction::Press,
|
||||
key,
|
||||
} => {
|
||||
if self.apply_keyboard(key) == EventOutcome::Exit {
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y),
|
||||
Route::Pointer(PointerRoute::Left(button_state)) => {
|
||||
self.apply_left_button(button_state);
|
||||
}
|
||||
Route::Pointer(PointerRoute::RightPress) => self.apply_right_press(),
|
||||
Route::Pointer(PointerRoute::Wheel(delta)) => self.apply_wheel(delta),
|
||||
// Three different facts, merged only because all three are
|
||||
// today nothing to do: a key-up the keyboard family claimed
|
||||
// and dropped, a button the pointer family has no semantics
|
||||
// for, and an event no family claims at all.
|
||||
Route::Keyboard {
|
||||
action: KeyAction::Release,
|
||||
..
|
||||
}
|
||||
| Route::Pointer(PointerRoute::UnusedButton)
|
||||
| Route::Unrouted => {}
|
||||
if self.dispatch_window_event(&event) == EventOutcome::Exit {
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4576,6 +5062,8 @@ impl State {
|
|||
let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text);
|
||||
|
||||
let mut state = Self {
|
||||
#[cfg(test)]
|
||||
render_calls: 0,
|
||||
window,
|
||||
device,
|
||||
queue,
|
||||
|
|
@ -9159,6 +9647,16 @@ impl State {
|
|||
/// windowed path. Composition lives in `render_to_view`, shared with
|
||||
/// the headless offscreen path (`render_offscreen`, F-014).
|
||||
fn render(&mut self) {
|
||||
// GUI 1-pre P2 — a repaint is a LOCAL effect with no outbound
|
||||
// trace, and headless there is no surface either: every path
|
||||
// below returns without observable consequence. The counter is
|
||||
// the only way a harness can witness that the redraw arm ran,
|
||||
// and it is incremented before the surface check for exactly
|
||||
// that reason. Test-only, so production carries nothing.
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.render_calls += 1;
|
||||
}
|
||||
let frame = {
|
||||
let Some(surface) = self.surface.as_ref() else {
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in New Issue