diff --git a/docs/active-work.md b/docs/active-work.md index 1a50b2f..2c0ac1c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -366,19 +366,33 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. needed a retained `geometry_epoch` because nothing else the producer holds moves with it); **detach** cancels in the dispatcher before any teardown. - - **Witnessed: G5b(a)–(d), G5m, G5j**, each reading the CHILD'S - STREAM or the document's selection rather than the latch. The - mutations discriminate exactly: dropping the window half of the - identity check fails only the buffer leg, dropping the buffer - half fails only the window leg, dropping the geometry check fails - only that leg, and dropping detach's cancel fails only detach. - - **G5m survives all four**, correctly: two coincident causes take - the same latch, so one release, and removing either cause still - leaves one. + - **Witnessed by a TABLE-DRIVEN matrix**: four transitions × two + families × two targets, sixteen quadrants, each **draining + explicitly** and asserting the effect — the exact release bytes + for a reporting terminal, the cleared empty selection for a + document, and an empty slot afterwards. An earlier version + stopped at `has_pending_release()` and would have passed while + delivery was broken; the mutation that parks a release and never + delivers it now fails every row. + - **Mutation labels, stated correctly this time**: dropping the + BUFFER comparison misses the buffer transition; dropping the + WINDOW comparison misses the window transition; dropping the + geometry comparison misses that one; dropping detach's cancel + misses detach. Each fails the matrix. + - **G5m takes both composites the framing names** — changed-size + geometry (epoch **and** mapping generation) and a buffer + replacement that also moves the mapping — and reads the child's + stream rather than a cancellation count, because a count of one + proves the latch was taken once, not that one release went out. - **G5j has two legs and they differ**: an empty selection is cleared without moving point, a REAL dragged region survives anchor-and-cursor exact. Clearing every selection fails the second. + - **One quadrant asserts less, and says so**: for window + replacement on a document, the window the gesture belonged to is + gone, so the completion has nothing left to clear and the + gesture ending is the whole effect. Written into the row rather + than left as a silently absent assertion. - **REMAINING:** Q5's acceptance-shaped row, then the full head-exact gate and the PR. - **Two test seams added for this:** an opt-in child-input tap diff --git a/src/daemon.rs b/src/daemon.rs index b6187c4..3b750a1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7948,10 +7948,23 @@ mod tests { ); fn terminal_panel_session(fid: FrontendId, reporting: bool) -> TerminalPanelFixture { + terminal_panel_session_at(fid, reporting, LEGACY_PANEL_VERSION) + } + + /// The same fixture at a chosen negotiated version. + /// + /// G5b's matrix runs every transition across BOTH families, so the + /// version has to be a parameter rather than the legacy default the + /// earlier rows could assume. + fn terminal_panel_session_at( + fid: FrontendId, + reporting: bool, + version: u32, + ) -> TerminalPanelFixture { use crate::terminal::TerminalSpec; let (editor, mut states, render, _document, panel, _epochs) = - panel_session_at(LEGACY_PANEL_VERSION, fid); + panel_session_at(version, fid); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec!["-c".into(), "sleep 30".into()]; spec.rows = 4; @@ -8801,61 +8814,109 @@ mod tests { } // ----------------------------------------------------------------- - // G5b — the common authority-loss matrix. + // G5b — the common authority-loss matrix, table-driven. // - // A live gesture belongs to the presentation it was pressed on. - // Five transitions end that presentation; §5b wired `Absent` and - // left the other four armed, inert while nothing consumed the - // latch. They became defects the moment cancellation gained an - // effect, and each has its own cause even though they share one - // consequence. + // Four transitions x two families x two targets. §5b wired `Absent` + // and left these armed --- inert while nothing consumed the latch, + // defects the moment cancellation gained an effect. // - // Every row reads the CHILD'S STREAM: the release is the effect, - // and a latch that empties without one is the exact failure §5b's - // round four named. + // Every quadrant asserts the EFFECT, drained explicitly: the exact + // release bytes for a reporting terminal, the cleared empty + // selection for a document, and an empty pending slot afterwards. + // Stopping at `has_pending_release()` would pass while delivery or + // recorded-domain routing was broken, which is what an earlier + // version of these rows did. // ----------------------------------------------------------------- - /// Render one frame, which is where the producer notices an - /// authority loss, and re-declare so a later gesture can be sent. - fn render_and_redeclare( - editor: &crate::editor::EditorState, - states: &mut HashMap, - fid: FrontendId, - ) { - let sem = states.get_mut(&fid).expect("projection"); - let _ = sem.render_frame(editor); + /// Which authority loss a matrix row drives. + #[derive(Clone, Copy, Debug)] + enum LossCause { + WindowReplaced, + BufferReplaced, + GeometrySameSize, + Detach, } - /// G5b(a) — the side WINDOW is replaced: a close/reopen of the same - /// buffer takes a fresh identity, and the old gesture ends. - #[test] - fn g5b_a_a_window_replacement_cancels_and_pays() { - let fid = FrontendId(820); - let (mut editor, mut states, mut render, panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); + /// Which target the gesture was pressed on. + #[derive(Clone, Copy, Debug)] + enum LossTarget { + Document, + ReportingTerminal, + } + + /// A panel session of the requested target kind and family. + fn loss_fixture(fid: FrontendId, version: u32, target: LossTarget) -> TerminalPanelFixture { + match target { + LossTarget::ReportingTerminal => terminal_panel_session_at(fid, true, version), + LossTarget::Document => { + let (editor, mut states, render, _document, panel, _epochs) = + panel_session_at(version, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + // A press must ANCHOR, or the row never arms. + foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); + let epochs = shipped_declaration(&editor, fid, &mut states); + (editor, states, render, panel, buffer_id, epochs) + } + } + } + + /// Send one press in whichever family `version` negotiated. + #[expect( + clippy::too_many_arguments, + reason = "one call shape for every matrix quadrant" + )] + fn send_press( + editor: &mut crate::editor::EditorState, + states: &mut HashMap, + render: &mut HashMap, + fid: FrontendId, + version: u32, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + ) { let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); let none = pmacs_protocol::Modifiers::default(); + if version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION { + let generation = live_generation(PanelArm::Mapped, editor, states, fid); + let event = arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + coord, + press, + ); + dispatch_panel_event(editor, fid, version, states, render, event); + } else { + send_panel( + editor, states, render, fid, epochs, buffer_id, coord, press, none, + ); + } + } - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - assert_eq!( - child_stream(&editor), - vec![SGR_PRESS_1_2.to_vec()], - "fixture: the press reached the child" - ); + /// Replace the side window's buffer, keeping the window. + fn replace_panel_buffer(editor: &crate::editor::EditorState, panel: crate::window::WindowId) { + let mut core = editor.core.borrow_mut(); + let replacement = core.registry.borrow_mut().create("*replacement*"); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = replacement; + window.text_view = view; + } - // Close and reopen the SAME buffer: a different window, so a - // fresh panel identity. + /// Close and reopen the panel on the same buffer: a new window. + fn replace_panel_window( + editor: &crate::editor::EditorState, + fid: FrontendId, + panel: crate::window::WindowId, + buffer_id: crate::buffer::BufferId, + ) { { let mut core = editor.core.borrow_mut(); core.active_frontend = fid; @@ -8877,211 +8938,194 @@ mod tests { panel, "fixture: the successor really is a different window" ); - - render_and_redeclare(&editor, &mut states, fid); - - assert!( - !states[&fid].has_accepted_gesture(), - "the gesture must END: it belongs to a presentation that no \ - longer exists" - ); - assert!( - states[&fid].has_pending_release() - || child_stream(&editor) == vec![SGR_RELEASE_1_2.to_vec()], - "and its release must be owed or already paid, never dropped" - ); } - /// G5b(b) — the side window's BUFFER is replaced. - /// - /// The release still goes to the buffer the gesture was pressed on, - /// because the domain is recorded --- delivering it to whatever is on - /// screen now would tell the wrong child to lift a button. + /// G5b — every transition, every family, every target. + #[expect( + clippy::too_many_lines, + reason = "one table whose quadrants read together; splitting it \ + hides which combinations are covered" + )] #[test] - fn g5b_b_a_buffer_replacement_cancels_and_pays_the_old_child() { - let fid = FrontendId(821); - let (mut editor, mut states, mut render, panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); + fn g5b_the_authority_loss_matrix() { + use LossCause::{BufferReplaced, Detach, GeometrySameSize, WindowReplaced}; + use LossTarget::{Document, ReportingTerminal}; - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = child_stream(&editor); + let mut next_fid = 830u64; + for cause in [WindowReplaced, BufferReplaced, GeometrySameSize, Detach] { + for target in [Document, ReportingTerminal] { + for version in [LEGACY_PANEL_VERSION, PROTOCOL_VERSION] { + let fid = FrontendId(next_fid); + next_fid += 1; + let label = format!("{cause:?}/{target:?}/v{version}"); - // Same window, different buffer. - { - let mut core = editor.core.borrow_mut(); - let replacement = core.registry.borrow_mut().create("*replacement*"); - let view = { - let registry = core.registry.clone(); - let registry = registry.borrow(); - crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) - }; - let window = core.windows.get_mut(&panel).expect("panel window"); - window.buffer_id = replacement; - window.text_view = view; + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + loss_fixture(fid, version, target); + // The terminal legs use (1, 2), whose exact SGR + // bytes the constants above pin. A document panel + // uses row 0: `foreign_edit` replaces the buffer's + // contents without refreshing the window's cached + // line index, so only the first display row resolves + // to a byte --- and a press that anchors nothing does + // not arm, which would make every document leg + // vacuous. + let cell = match target { + ReportingTerminal => pmacs_protocol::CellCoord::new(1, 2), + Document => pmacs_protocol::CellCoord::new(0, 2), + }; + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + version, + epochs, + buffer_id, + cell, + ); + assert!( + states[&fid].has_accepted_gesture(), + "{label}: fixture --- the press must arm, or the row \ + proves nothing" + ); + if matches!(target, ReportingTerminal) { + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "{label}: fixture --- the press reached the child" + ); + } + let selection_before = editor.core.borrow().windows[&panel].selection; + + match cause { + WindowReplaced => { + replace_panel_window(&editor, fid, panel, buffer_id); + render_and_redeclare(&editor, &mut states, fid); + } + BufferReplaced => { + replace_panel_buffer(&editor, panel); + render_and_redeclare(&editor, &mut states, fid); + } + GeometrySameSize => { + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + } + Detach => { + detach_session(&mut editor, &mut states, &mut render, fid); + } + } + // Detach drains inside its own teardown; the producer + // transitions park and are paid at the next drain. + if !matches!(cause, Detach) { + assert!( + !states[&fid].has_accepted_gesture(), + "{label}: the gesture must END --- it belongs to \ + a presentation that no longer exists" + ); + drain_pending_release(&mut editor, &mut states, fid); + assert!( + !states[&fid].has_pending_release(), + "{label}: and the slot must be empty afterwards" + ); + } + + match target { + ReportingTerminal => assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "{label}: EXACTLY the recorded release, to the \ + child the gesture was pressed on --- routing it \ + by what occupies the panel now would tell the \ + wrong child to lift a button" + ), + Document => { + assert!( + selection_before.is_some(), + "{label}: fixture --- the press anchored an \ + empty selection for the cancellation to clear" + ); + let core = editor.core.borrow(); + match core.windows.get(&panel) { + Some(window) => assert!( + window.selection.is_none(), + "{label}: the empty selection must be \ + cleared, or its stale anchor captures \ + the next shift-motion" + ), + // WINDOW REPLACED: the window the gesture + // belonged to is gone, so the completion + // has nothing left to clear and the + // gesture ENDING is the whole of the + // effect --- already asserted above. Said + // out loud rather than skipped, because a + // silently absent assertion is how a + // quadrant stops testing anything. + None => assert!( + matches!(cause, WindowReplaced), + "{label}: the panel window vanished for \ + a cause that should not remove it" + ), + } + } + } + } + } } - - render_and_redeclare(&editor, &mut states, fid); - - assert!(!states[&fid].has_accepted_gesture(), "the gesture must END"); - assert!( - states[&fid].has_pending_release(), - "with its release owed to the buffer it was PRESSED on, not \ - to whatever occupies the panel now" - ); } - /// G5b(c) — a geometry change at an UNCHANGED cell size. + /// G5m — coincident invalidations produce ONE completion effect. /// - /// Nothing else the producer holds moves: not the panel epoch, not - /// the identity, and on a legacy peer not a mapping key either. The - /// retained geometry epoch is the only thing that can see it. + /// Both composites the framing names, and each reads the effect + /// rather than the cancellation count: a count of one proves the + /// latch was taken once, not that exactly one release went out. #[test] - fn g5b_c_a_same_size_geometry_change_cancels_and_pays() { - let fid = FrontendId(822); - let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); - let size_before = editor.core.borrow().panel_grid_size(fid).expect("grid"); + fn g5m_coincident_invalidations_emit_one_release() { + // (a) changed-size geometry, which moves the geometry epoch AND + // the mapping generation; (b) buffer replacement, which + // moves the identity AND the mapping. + for (label, changed_size) in [("changed-size geometry", true), ("buffer replaced", false)] { + let fid = FrontendId(if changed_size { 850 } else { 851 }); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session_at(fid, true, PROTOCOL_VERSION); + let cell = pmacs_protocol::CellCoord::new(1, 2); - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = child_stream(&editor); + send_press( + &mut editor, + &mut states, + &mut render, + fid, + PROTOCOL_VERSION, + epochs, + buffer_id, + cell, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "{label}: fixture --- the press reached the child" + ); - // A new geometry epoch at the SAME total size --- a font or scale - // change that leaves `CellSize` identical. - editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); - render_and_redeclare(&editor, &mut states, fid); - assert_eq!( - editor.core.borrow().panel_grid_size(fid).expect("grid"), - size_before, - "fixture: the SIZE must be unchanged, or this row is testing \ - a resize instead" - ); + if changed_size { + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(20, 60)); + } else { + replace_panel_buffer(&editor, panel); + } + render_and_redeclare(&editor, &mut states, fid); + drain_pending_release(&mut editor, &mut states, fid); - assert!( - !states[&fid].has_accepted_gesture(), - "the gesture must END: the same cells now sit on a different \ - grid, and the epochs the frontend echoes have moved under it" - ); - assert!( - states[&fid].has_pending_release(), - "and its release is owed" - ); - } - - /// G5b(d) — DETACH ends a live gesture and pays it before teardown. - /// - /// Q4 proved detach pays a release that was ALREADY owed. This one - /// starts from a live gesture, which is the case with no later - /// opportunity of any kind. - #[test] - fn g5b_d_detach_cancels_a_live_gesture_and_pays_it() { - let fid = FrontendId(823); - let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); - - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - assert!( - states[&fid].has_accepted_gesture(), - "fixture: the gesture is LIVE, not merely owed" - ); - let _ = child_stream(&editor); - - detach_session(&mut editor, &mut states, &mut render, fid); - - assert_eq!( - child_stream(&editor), - vec![SGR_RELEASE_1_2.to_vec()], - "detach must end the gesture AND pay it --- the frontend is \ - gone, so a release not sent here is never sent" - ); - } - - /// G5m — coincident causes cancel ONCE. - /// - /// Identity and geometry both change in one transition. Taking the - /// shared latch is what makes this one release rather than two. - #[test] - fn g5m_coincident_causes_emit_one_release() { - let fid = FrontendId(824); - let (mut editor, mut states, mut render, panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); - - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = child_stream(&editor); - - // BOTH at once: a replaced buffer and a moved geometry epoch. - { - let mut core = editor.core.borrow_mut(); - let replacement = core.registry.borrow_mut().create("*both*"); - let view = { - let registry = core.registry.clone(); - let registry = registry.borrow(); - crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) - }; - let window = core.windows.get_mut(&panel).expect("panel window"); - window.buffer_id = replacement; - window.text_view = view; + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "{label}: TWO causes, ONE release --- they take the same \ + latch, and a per-cause flag would send the child two \ + releases for one press" + ); + assert!( + !states[&fid].has_pending_release(), + "{label}: with nothing left owed" + ); } - editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); - render_and_redeclare(&editor, &mut states, fid); - - assert_eq!( - states[&fid].panel_gesture_cancellations(), - 1, - "TWO causes, ONE cancellation: they take the same latch, and \ - counting per cause would emit a duplicate release" - ); } /// G5j — document cancellation has two legs, and they differ. @@ -9093,25 +9137,21 @@ mod tests { #[test] fn g5j_document_cancellation_clears_only_an_empty_selection() { for dragged in [false, true] { - let fid = FrontendId(if dragged { 826 } else { 825 }); - let (mut editor, mut states, mut render, _document, panel, epochs) = - panel_session_at(LEGACY_PANEL_VERSION, fid); - let buffer_id = editor.core.borrow().windows[&panel].buffer_id; - foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let fid = FrontendId(if dragged { 861 } else { 860 }); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + loss_fixture(fid, LEGACY_PANEL_VERSION, LossTarget::Document); let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left); let none = pmacs_protocol::Modifiers::default(); - send_panel( + send_press( &mut editor, &mut states, &mut render, fid, + LEGACY_PANEL_VERSION, epochs, buffer_id, pmacs_protocol::CellCoord::new(0, 0), - press, - none, ); if dragged { send_panel( @@ -9163,7 +9203,18 @@ mod tests { } } - /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// Render one frame, which is where the producer notices an + /// authority loss. + fn render_and_redeclare( + editor: &crate::editor::EditorState, + states: &mut HashMap, + fid: FrontendId, + ) { + let sem = states.get_mut(&fid).expect("projection"); + let _ = sem.render_frame(editor); + } + + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY /// caps (Bet B5'): a 4K surface at a small font is legitimately diff --git a/src/semantic_render.rs b/src/semantic_render.rs index f7f7f81..2484002 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -1876,14 +1876,12 @@ impl SemanticRenderState { // wire work but must still leave no live gesture behind — the // clears below are idempotent for the same reason. // - // Only the producer half lands here. The framing's G5b matrix — - // the panel-epoch, buffer-replacement, same-size geometry and - // detach transitions, each with its v24 and v25 legs and its - // document and terminal effects — is `panel-pointer-replay`'s per - // §5b's split table. Those transitions leave the latch armed on - // this branch, which is inert here (nothing consumes it) and - // becomes a defect only once replay gives it effects, in the - // branch that owns the row. + // `Absent` is one of G5b's five transitions. THE OTHER FOUR ARE + // NOW WIRED TOO, on this branch: window replacement, buffer + // replacement and a geometry-epoch change are handled where the + // declaration is built above, and detach in the dispatcher's + // teardown. This comment used to say they were left armed — true + // of §5b, false here since the authority-loss matrix landed. self.cancel_accepted_gesture(); self.panel_presentation = None; // `Absent` also clears the peer's retained mode line. A later