test(gpu): 1a --- producer reachability, and a narrowed structural exception

The first of the three remaining discriminators: **multi-scalar text
reaches the wire as `TextInput` while the daemon is INTERCEPTING** ---
the state a modal prompt or a focused terminal puts the session in, and
the state under which A7 and A8 were unreachable before review round 1.

**Getting there required narrowing the 1-pre exception, which is the
substantive part.** `apply_keyboard` took a `&KeyEvent`; `KeyEvent`
carries a `pub(crate)` field and cannot be constructed outside winit, so
the body was undrivable and only the pure classifier could be tested ---
which is precisely why the defect survived: the classifier was correct
throughout and the CALL SITE was wrong. It now takes the two fields it
actually reads, `&Key` and `Option<&str>`, both ordinary constructible
values.

The exception does not disappear, it shrinks: the router arm still
cannot be handed a `WindowEvent::KeyboardInput`, so what remains
unwitnessed is one pattern arm containing a match and a call. That is
recorded on `apply_keyboard` itself, where the next reader meets it.

**M-1a-3 reinstates the original defect** --- the selection moved back
below the intercept return --- **and fails the new row alone**, 23 of 24
still green. That is the shape the review asked for: a witness that
fails for the reason the defect existed.

Its complement is included so the pair cannot be satisfied by sending
`TextInput` for everything: a SINGLE scalar while intercepting still
travels as `Key`, which is §5 rule 4 preserving mode keymaps and typed
provenance.

The harness gains `feed_keyboard`, and the local-effect diffing it
shares with `feed` is extracted rather than copied --- two entry points
observing different effect sets by accident is the kind of divergence
that makes a transcript lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-12 17:34:56 +02:00
parent b2a273b1fd
commit 3ed37f7786
No known key found for this signature in database
1 changed files with 124 additions and 13 deletions

View File

@ -3162,7 +3162,7 @@ impl App {
Route::Keyboard {
action: KeyAction::Press,
key,
} => self.apply_keyboard(key),
} => self.apply_keyboard(&key.logical_key, key.text.as_deref()),
Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y),
Route::Pointer(PointerRoute::Left(button_state)) => {
self.apply_left_button(button_state);
@ -3183,10 +3183,25 @@ impl App {
EventOutcome::Continue
}
/// Perform [`KeyAction::Press`]. The router has already
/// discarded key-ups, so `key` is always a press.
/// Perform [`KeyAction::Press`]. The router has already discarded
/// key-ups, so this is always a press.
///
/// **Takes the two fields it reads rather than the whole
/// `KeyEvent`, deliberately.** `KeyEvent` carries a `pub(crate)`
/// field and cannot be constructed outside winit, so a body taking
/// one is undrivable by any test; `Key` and `&str` are ordinary
/// values. That distinction is not academic — 1a's first review
/// found the `TextInput` selection sited *below* the intercept
/// return, making A7 and A8 reachable only when no prompt and no
/// terminal were present. The classifier was correct throughout;
/// only the call site was wrong, so only a test that drives THIS
/// function could have caught it.
///
/// The router arm remains unwitnessable — it still cannot be handed
/// a `WindowEvent::KeyboardInput` — so 1-pre's structural exception
/// narrows to that one pattern arm rather than disappearing.
#[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order.
fn apply_keyboard(&mut self, key: &KeyEvent) {
fn apply_keyboard(&mut self, logical: &Key, text: Option<&str>) {
// While the daemon is intercepting keystrokes — an active
// incremental search (Q#SR5), or a minibuffer / pending
// prefix — every key belongs to its handler, not the
@ -3229,7 +3244,7 @@ impl App {
// behaviour left there is nothing for it to choose. (Both flags
// remain live below, for the OS-paste, round-trip and
// completion-accept paths.)
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
if matches!(*logical, Key::Named(NamedKey::Escape)) {
if let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
{
@ -3238,7 +3253,7 @@ impl App {
return;
}
let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) else {
let Some((pkey, mut pmods)) = translate_key(logical, self.modifiers) else {
return;
};
@ -3252,7 +3267,7 @@ impl App {
// being routed to the keymap. Alt alone is left intact so
// macOS Option-as-Meta still reaches the keymap; on layouts
// where AltGr isn't Ctrl+Alt this is a no-op.
if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(key.text.as_deref(), pmods) {
if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(text, pmods) {
pmods = if pmods.contains(Modifiers::SHIFT) {
Modifiers::SHIFT
} else {
@ -3307,7 +3322,7 @@ impl App {
// truncation to the first scalar — because the fallback is the
// unchanged `Key` path below. No regression, not retroactive
// correctness.
if let Some(text) = text_input_payload(&key.logical_key, key.text.as_deref(), pmods)
if let Some(text) = text_input_payload(logical, text, pmods)
&& client.session_protocol_version() >= TEXT_INPUT_MIN_VERSION
{
if let Some(state) = self.state.as_mut() {
@ -3835,12 +3850,42 @@ impl EffectHarness {
harness
}
/// Drive `apply_keyboard` directly and report what it produced.
///
/// Bypasses `route_event` because a `WindowEvent::KeyboardInput`
/// cannot be constructed outside winit — 1-pre's recorded
/// structural exception. What that exception covers is the router's
/// pattern arm; the BODY is reachable, and the body is where 1a's
/// placement defect lived.
fn feed_keyboard(&mut self, logical: &Key, text: Option<&str>) -> Step {
let before = self.snapshot();
self.app.apply_keyboard(logical, text);
let after = self.snapshot();
Step {
local: Self::diff(&before, &after, EventOutcome::Continue),
outbound: self.read_until_sentinel(),
}
}
/// 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();
Step {
local: Self::diff(&before, &after, outcome),
outbound: self.read_until_sentinel(),
}
}
/// The local effects between two snapshots. Shared by every entry
/// point so a new one cannot observe a different set by accident.
fn diff(
before: &EffectSnapshot,
after: &EffectSnapshot,
outcome: EventOutcome,
) -> Vec<LocalEffect> {
let mut local = Vec::new();
if outcome == EventOutcome::Exit {
local.push(LocalEffect::Exit);
@ -3862,11 +3907,7 @@ impl EffectHarness {
top: after.scroll_top,
});
}
Step {
local,
outbound: self.read_until_sentinel(),
}
local
}
/// Observable state the local effects are derived from.
@ -4393,6 +4434,76 @@ mod input_routing_tests {
);
}
// ---------------------------------------------------------------
// GUI arc 1a — PRODUCER REACHABILITY.
//
// These drive `App::apply_keyboard`, the real call site, not
// `text_input_payload`. 1a's first review found the classifier
// correct and the call site wrong: `TextInput` selection sat below
// the intercept return, and a modal prompt or a focused terminal is
// exactly what makes intercept true, so A7 and A8 were reachable
// only when neither was present. A classifier test stays green
// through that defect; these rows do not.
// ---------------------------------------------------------------
/// Multi-scalar text reaches the wire as `TextInput` **while the
/// daemon is intercepting** — the state a modal prompt puts the
/// session in.
#[test]
fn multi_scalar_text_is_sent_while_the_daemon_intercepts() {
let mut h = EffectHarness::new();
// A minibuffer is up: `daemon_intercepts_keys` is true.
h.app.state.as_mut().expect("harness state").dispatch_idle = false;
let step = h.feed_keyboard(&Key::Character("e\u{301}".into()), Some("e\u{301}"));
assert!(
step.outbound.iter().any(|e| matches!(
e,
pmacs_protocol::FrontendEvent::TextInput { text, .. } if text == "e\u{301}"
)),
"an intercepting session must still get the whole commit, \
not a truncated Key; got {:?}",
step.outbound
);
assert!(
!step
.outbound
.iter()
.any(|e| matches!(e, pmacs_protocol::FrontendEvent::Key(_))),
"and must NOT also get the first-scalar Key: {:?}",
step.outbound
);
}
/// The complement, so the row above cannot pass by sending
/// `TextInput` for everything: a SINGLE scalar while intercepting
/// still travels as `Key`, which is §5 rule 4 and preserves mode
/// keymaps and typed provenance.
#[test]
fn single_scalar_text_still_travels_as_key_while_intercepting() {
let mut h = EffectHarness::new();
h.app.state.as_mut().expect("harness state").dispatch_idle = false;
let step = h.feed_keyboard(&Key::Character("a".into()), Some("a"));
assert!(
step.outbound
.iter()
.any(|e| matches!(e, pmacs_protocol::FrontendEvent::Key(_))),
"a single scalar stays a Key: {:?}",
step.outbound
);
assert!(
!step
.outbound
.iter()
.any(|e| matches!(e, pmacs_protocol::FrontendEvent::TextInput { .. })),
"and must not become TextInput: {:?}",
step.outbound
);
}
/// 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