From 180343e6e3594fe957e328cdfe4de288f41979ba Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 10 Jul 2026 15:46:36 -0400 Subject: [PATCH] =?UTF-8?q?fix(edit):=20PR=20#109=20round=201=20=E2=80=94?= =?UTF-8?q?=20shared=20search=20invalidation,=20daemon=20anchor=20clear,?= =?UTF-8?q?=20bounded=20indent=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: the empty-anchor optimistic residual was never GPU-only (the TUI mirror tracks no selection state; its gate checks cursor freshness/EOL only). The fix moves daemon-side: handle_remote_crdt_op clears a selection whose anchor equals the pre-edit cursor (= empty) before applying the source cursor update; nonempty selections stand. Covers both frontends. The TUI gate's missing type-over check (nonempty selection at EOL) is a named deferral. Finding 2: Q#AI8 invalidation is one helper (search_invalidate_for_edit) invoked from all four edit paths -- apply_active_edit, notify_buffer_edit, and now undo/redo, which received precise Edits but invalidated nothing. rebuild_views_for is named as a lower-frequency bypass (deferral). Finding 3: acceptance matrix trued up -- added active-search fail-closed + retype recovery, delete translation on both paths, undo/redo staleness + origin tests; modal contexts narrowed to what this suite pins (query-replace/menu/completion ride their own suites). Finding 4: indent extraction is a forward-chunked scan stopping at the first non-whitespace byte -- Enter at the end of a giant minified line no longer materializes the line. Functional pin at 64 KiB. Both medium fixes are bite-verified (tests fail with the fix disabled). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6 --- builtin/runtime/indent.lua | 21 +++- docs/auto-indent-framing.md | 107 +++++++++++--------- src/daemon.rs | 121 +++++++++++++++++++++++ src/editor_core.rs | 168 +++++++++++++++++++++++++++++--- tests/auto_indent_acceptance.rs | 21 ++++ 5 files changed, 377 insertions(+), 61 deletions(-) diff --git a/builtin/runtime/indent.lua b/builtin/runtime/indent.lua index c8763ef..ef66f84 100644 --- a/builtin/runtime/indent.lua +++ b/builtin/runtime/indent.lua @@ -33,14 +33,25 @@ local function line_start_before(buf, pos) end -- The indent to carry over a split at `split` (Q#AI3): --- bytes[line_start .. min(first_non_ws, split)]. Slicing the line head --- up to the split point and taking its leading `[ \t]*` run IS that --- clip — the match cannot run past the slice's end. `[ \t]` rather +-- bytes[line_start .. min(first_non_ws, split)]. Forward chunked scan +-- from the line start, stopping at the first non-whitespace byte — +-- never materializing more of the line than the indent itself plus +-- one chunk (Enter at the end of a giant minified line must not copy +-- the whole line just to produce an empty indent). `[ \t]` rather -- than `%s` so a CR on a CRLF line never counts as indent. local function indent_before(buf, split) local start = line_start_before(buf, split) - if split <= start then return "" end - return buf:slice(start, split):match("^[ \t]*") + local parts = {} + local p = start + while p < split do + local chunk_to = math.min(p + 4096, split) + local chunk = buf:slice(p, chunk_to) + local ws = chunk:match("^[ \t]*") + table.insert(parts, ws) + if #ws < #chunk then break end + p = chunk_to + end + return table.concat(parts) end -- Right-gravity translation of `pos` through the effective edit diff --git a/docs/auto-indent-framing.md b/docs/auto-indent-framing.md index 9b6a723..2d613cf 100644 --- a/docs/auto-indent-framing.md +++ b/docs/auto-indent-framing.md @@ -7,12 +7,14 @@ language-agnostic, as one undoable edit. Second-to-last Arc 2 item; auto-pairing follows as its own framing + PR. Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-indent on newline"). -Revision 5: Q#AI9 is success-gated (`insert_char` reports success; -clear only on Ok — a rejected edit must not mutate selection state), -and Q#AI2 names the one deliberate behavior change `buffer.newline` -inherits through the shared primitive. Earlier: live-origin -translation (Q#AI8), M6.4 same-kind intercept contract (Q#AI5), -empty-selection core fix with the GPU residual named (Q#AI9). +Revision 6 (PR #109 round 1): Q#AI8's invalidation is one helper +invoked from all four edit paths — dispatch, direct notification, +undo, redo — and the empty-anchor clear moved daemon-side, closing +the optimistic case for BOTH frontends (the residual was never +GPU-only: the TUI mirror tracks no selection state). The acceptance +matrix now matches what the suites actually pin. Earlier revisions: +success-gated Q#AI9, live-origin translation, M6.4 same-kind +intercept contract, modal-context corrections. ## Ground truth (as of `efa41cb`) @@ -310,14 +312,17 @@ anyway. Three parts, all required: -1. **Mark**: `notify_buffer_edit` gains the same - `SearchStore::mark_stale` call `apply_active_edit` already makes. - One place, all three callers fixed: applied CRDT ops - (`src/daemon.rs:2133`), general Lua mutator edits - (`src/lua_bindings/mod.rs:1390-1395`), and the errors-buffer - append (`src/lua.rs:442` — normally a no-op, unless the *errors* - buffer itself carries accepted search state, in which case - marking it stale is exactly right). +1. **Mark**: one helper, `search_invalidate_for_edit` (mark stale + + translate the origin), invoked from **all four edit paths**: + `apply_active_edit` (dispatch), `notify_buffer_edit` (applied CRDT + ops `src/daemon.rs:2133`, general Lua mutator edits + `src/lua_bindings/mod.rs:1390-1395`, and the errors-buffer append + `src/lua.rs:442` — normally a no-op, unless the *errors* buffer + itself carries accepted search state, in which case marking it + stale is exactly right), and — round-1 finding — `undo` / `redo`, + which receive precise `Edit` values but previously invalidated + nothing. Generated-buffer rebuilds via `rebuild_views_for` remain + a named lower-frequency bypass (deferral). 2. **Honor (fail closed)**: `SearchStore::step` returns `None` while stale — C-s / `search.next` stops navigating byte ranges that no longer exist instead of teleporting the cursor to them — and @@ -363,14 +368,22 @@ input, on both frontends' round-trip paths, and makes `insert_char_over_region`'s existing "any selection is cleared" doc claim true. -**Out of scope, named**: the GPU optimistic path inherits the bug -independently — its eligibility gate reads Selection *decorations*, -which an empty selection never paints (ground truth), and the -daemon's CRDT-apply arm clears no anchors. Optimistic typing over an -empty anchor therefore still arms a surprise selection that the next -round-tripped key can consume. Fixing that means teaching the CRDT -arm about anchors — deferred alongside the substrate window-state -reconciliation work, explicitly, not silently. +**Also in scope (round 1: the residual was never GPU-only)**: the +TUI attach mirror tracks buffers and cursors but no selection state +(`src/buffer_mirror.rs:113`), and its optimistic gate checks cursor +freshness and EOL only (`src/optimistic.rs:250`) — so both +frontends' optimistic paths could re-arm the type-over. The fix +lives in the daemon's CRDT source arm (`handle_remote_crdt_op`): +before applying the source cursor update, a selection whose anchor +equals the pre-edit cursor — i.e. one that was EMPTY — is cleared. +Nonempty selections stand untouched. + +**Out of scope, named**: the TUI optimistic gate performs no +type-over check at all — a NONEMPTY selection ending at EOL +optimistically inserts instead of consuming the region (the GPU +gates on Selection decorations, which nonempty selections do paint, +so it round-trips correctly there). That is a pre-existing TUI gate +gap, deferred with the substrate reconciliation work. ## Bets @@ -409,10 +422,12 @@ reconciliation work, explicitly, not silently. per-command — including windows other than the acting one), and aligning comment.lua's transformed-intercept fix-up with Q#AI5's translate-and-clamp discipline. -- **GPU-optimistic empty-anchor residual** (Q#AI9): the daemon CRDT - arm clears no selection anchors, and an empty anchor paints no - Selection decoration to gate on — optimistic typing can still arm - a type-over one keystroke later. +- **TUI optimistic type-over gate gap** (Q#AI9 round 1): the TUI + attach gate consults no selection state, so a NONEMPTY selection + ending at EOL optimistically inserts instead of type-over. (The + empty-anchor half was fixed daemon-side in round 1.) +- **Generated-buffer rebuilds** (`rebuild_views_for`) bypass search + invalidation — a lower-frequency Q#AI8 gap, named not handled. - Auto-recompute of a stale search from the stored query on step (Q#AI8 fails closed instead). - Unifying the five hardcoded tab-width sites (4× core `TAB_WIDTH=8`, @@ -459,26 +474,30 @@ reconciliation work, explicitly, not silently. untouched — the original window's validity after such an intercept belongs to the substrate-reconciliation deferral, and the test does not claim it. -- **Search staleness** (Q#AI8): - - isearch, accept, RET → accepted highlights marked stale; a - direct `buf:insert` case pins the notify-path level. - - Direct/remote edit during **active** isearch → `C-s` is a no-op - (cursor unmoved, no jump to obsolete offsets) and the n/m summary - reads `(None, 0)`; typing the next pattern char refreshes and - stepping resumes. - - **Origin translation**: during a live search, an external insert - and a delete strictly before the origin → the next pattern - character focuses relative to the translated origin, and cancel - restores the cursor to the translated position — exercised via - both the notify path (Lua mutator/CRDT) and the other-frontend - dispatch path. - - Post-accept edit → `search.next` no-op instead of stale - navigation. +- **Search staleness** (Q#AI8; acceptance + lib split, per what each + seam can reach): + - Acceptance: isearch, accept, RET → post-accept `search_step` + no-op; the same via a direct `buf:insert` (notify path). + - Lib (editor_core): edit during **active** isearch → step no-op + and summary `(None, 0)`, then the next pattern keystroke + refreshes and stepping resumes; origin translation through + **inserts and deletes on both edit paths** (dispatch and + notify), pinning recompute focus and cancel restore; **undo and + redo** stale accepted matches and translate the live origin. + - Lib (search store): stale `step` fails closed; a fresh `set` + un-sticks it. + - Lib (daemon, `--features crdt`): the optimistic source arm + clears an EMPTY anchor and leaves a NONEMPTY selection alone. - `after-edit` fires exactly once per RET (keybound and `M-x`). - Kill-chain break: `C-k`, RET, `C-k` → two ring entries. -- Modal contexts unaffected: isearch accept, query-replace prompt, - context-menu invoke, minibuffer accept (the `m_x` helper exercises - it), completion-popup accept. +- Modal contexts: isearch accept and minibuffer accept pinned here + (plus buffer-list RET below). Query-replace, context-menu, and + completion-popup RET are pinned by their own suites + (`tests/query_replace_acceptance.rs`, the menu/completion + acceptance suites), which run in the gates — not re-pinned here. +- Giant minified line (64 KiB, unindented and indented): splits + correctly — pins the forward-chunked indent scan's behavior + (boundedness is by construction). - Buffer-list RET still visits via dispatch (now also reachable from the GPU frontend). - `this_command()` after RET is `edit.newline-and-indent`. diff --git a/src/daemon.rs b/src/daemon.rs index 0a87188..dea3493 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2111,6 +2111,19 @@ fn handle_remote_crdt_op( continue; } if Some(*wid) == source_active_window_id { + // Q#AI9 (PR #109 round 1): an empty anchor armed at + // the pre-edit cursor must not survive the cursor + // moving off it — otherwise the optimistic paths + // (GPU always; TUI mirror, which tracks no selection + // state) re-arm the type-over that + // `insert_char_over_region`'s no-region clear fixed + // on the dispatch path. Nonempty selections stand: + // the TUI gate's missing type-over check is a named + // deferral, and guessing here would destroy a real + // selection. + if win.selection.map(|sel| sel.anchor) == Some(win.cursor) { + win.selection = None; + } // Source window: set directly to optimistic post-edit // position (matches the source frontend's mirror // cursor after advance/retreat). @@ -2551,6 +2564,114 @@ mod tests { ); } + /// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an + /// EMPTY anchor on the source window — the GPU always takes this + /// path, and the TUI attach mirror tracks no selection state, so + /// neither frontend's gate stops an armed-empty-anchor sequence + /// from re-creating the type-over that + /// `insert_char_over_region`'s no-region clear fixed on the + /// dispatch path. A NONEMPTY selection must survive untouched + /// (the TUI gate's missing type-over check is a named deferral). + #[cfg(feature = "crdt")] + #[test] + fn handle_remote_crdt_op_clears_only_an_empty_source_anchor() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use crate::window::Selection; + + // Shared fixture: CRDT-backed active buffer + a peer doc that + // produces the optimistic op, sourced from LOCAL (which has a + // registered view, so the source-window arm runs). + fn apply_peer_insert(editor: &mut EditorState, buffer_id: crate::buffer::BufferId) { + let snapshot_bytes = { + let core = editor.core.borrow(); + let reg = core.registry.borrow(); + let buf = reg.get(buffer_id).expect("buffer"); + buf.crdt_state() + .expect("crdt-backed") + .export_snapshot() + .expect("export snapshot") + }; + let peer = loro::LoroDoc::new(); + peer.set_peer_id(u64::from(FrontendId::LOCAL.0)) + .expect("set peer id"); + peer.import(&snapshot_bytes).expect("import snapshot"); + let v_before = peer.oplog_vv(); + peer.get_text("body").insert(0, "x").expect("peer insert"); + let op_bytes = peer + .export(loro::ExportMode::updates(&v_before)) + .expect("export op"); + super::handle_remote_crdt_op( + editor, + FrontendId::LOCAL, + buffer_id, + crate::rope::CrdtOp { + peer_id: FrontendId::LOCAL.0, + bytes: op_bytes, + }, + ); + } + + // Case 1: empty anchor at the cursor (S-Left-at-BOF shape) — + // cleared by the optimistic apply. + let mut editor = EditorState::new(); + let buffer_id = editor.core.borrow().active_window().buffer_id; + { + let core = editor.core.borrow(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("active buffer") + .upgrade_to_crdt(2) + .expect("upgrade to crdt"); + } + { + let mut core = editor.core.borrow_mut(); + let at = core.active_window().cursor; + core.active_window_mut().selection = Some(Selection { anchor: at }); + } + apply_peer_insert(&mut editor, buffer_id); + { + let core = editor.core.borrow(); + assert!( + core.active_window().selection.is_none(), + "an empty anchor must not survive an optimistic source edit" + ); + assert_eq!( + core.active_window().cursor, + 1, + "cursor at post-edit position" + ); + } + + // Case 2: nonempty selection — the arm must not touch it. + let mut editor = EditorState::new(); + let buffer_id = editor.core.borrow().active_window().buffer_id; + editor.core.borrow_mut().insert_char('a'); + editor.core.borrow_mut().insert_char('b'); + { + let core = editor.core.borrow(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("active buffer") + .upgrade_to_crdt(2) + .expect("upgrade to crdt"); + } + { + let mut core = editor.core.borrow_mut(); + core.active_window_mut().selection = Some(Selection { anchor: 0 }); + // cursor is at 2 after the two inserts: nonempty region. + } + apply_peer_insert(&mut editor, buffer_id); + { + let core = editor.core.borrow(); + assert_eq!( + core.active_window().selection, + Some(Selection { anchor: 0 }), + "a nonempty selection survives the optimistic source edit" + ); + } + } + /// Kill ring Q#KR2 — GPU typing arrives here without touching /// dispatch_key, so it must update the source frontend's command /// boundary or `C-k x C-k` on the GPU would append across the typed diff --git a/src/editor_core.rs b/src/editor_core.rs index 5633735..8a69c40 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -1192,17 +1192,28 @@ impl EditorCore { .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone())); } // Search matches were computed against the pre-edit text, so - // their byte positions are now wrong. Mark the buffer's matches - // stale (M11.8): the producer / TUI overlay suppress them until - // a fresh search re-runs against the current content. No-op for - // a buffer with no search state. The headline isearch bet — - // "stale-after-edit linger" — is closed here. + // their byte positions are now wrong (M11.8): the producer / + // TUI overlay suppress them until a fresh search re-runs. The + // headline isearch bet — "stale-after-edit linger" — is + // closed here. + self.search_invalidate_for_edit(buffer_id, &edit); + Ok(edit.new_rope.len()) + } + + /// Q#AI8 search invalidation for a landed edit: mark the buffer's + /// matches stale (no-op without search state) and right-gravity- + /// translate the live session origin. ONE helper so every edit + /// path — dispatch ([`Self::apply_active_edit`]), direct + /// notification ([`Self::notify_buffer_edit`]), and history + /// ([`Self::undo`] / [`Self::redo`]) — invalidates identically; + /// a path that skips this leaves highlights, step targets, and + /// the n/m count pointing at pre-edit offsets. + fn search_invalidate_for_edit(&mut self, buffer_id: BufferId, edit: &Edit) { self.search_store .lock() .expect("search store mutex poisoned") .mark_stale(buffer_id); - self.translate_search_origin(buffer_id, &edit); - Ok(edit.new_rope.len()) + self.translate_search_origin(buffer_id, edit); } /// Right-gravity-translate the live search origin through an edit @@ -1247,11 +1258,7 @@ impl EditorCore { /// highlights and the session origin survive at pre-edit offsets /// for every Lua mutator edit and applied CRDT op. pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) { - self.search_store - .lock() - .expect("search store mutex poisoned") - .mark_stale(buffer_id); - self.translate_search_origin(buffer_id, edit); + self.search_invalidate_for_edit(buffer_id, edit); let reg = self.registry.borrow(); let Ok(buffer) = reg.get(buffer_id) else { return; @@ -1897,6 +1904,9 @@ impl EditorCore { } } drop(reg); + // Q#AI8 (PR #109 round 1): history edits move bytes + // like any other edit — invalidate search state. + self.search_invalidate_for_edit(buffer_id, &edit); // Post-audit-round-5 F27: undo on a CRDT-backed // buffer produces a crdt_op that must broadcast to // every replica frontend (including the one whose @@ -1935,6 +1945,8 @@ impl EditorCore { } } drop(reg); + // Q#AI8 — same as undo above. + self.search_invalidate_for_edit(buffer_id, &edit); // Post-audit-round-5 F27 — same as undo above. self.queue_daemon_origin_crdt_op(buffer_id, &edit); } @@ -3826,6 +3838,138 @@ mod tests { ); } + #[test] + fn undo_and_redo_stale_accepted_search_navigation() { + // Q#AI8 (PR #109 round 1): history edits move bytes like any + // other edit — undo/redo must invalidate search state. + let mut s = from_bytes(b"foo bar foo"); + s.active_window_mut().cursor = 0; + assert!(s.insert_char('x')); // the history entry: "xfoo bar foo" + s.search_begin(true, false); + type_query(&mut s, "foo"); + s.search_finish(true); // accept + assert_eq!(s.search_match_summary(), (Some(0), 2)); + s.undo(); // back to "foo bar foo": offsets moved + assert_eq!( + s.search_match_summary(), + (None, 0), + "undo stales the accepted matches" + ); + let before = s.cursor(); + s.search_step(true); + assert_eq!(s.cursor(), before, "stale step is a no-op after undo"); + + // Redo the same way: refresh the matches first (a fresh set + // clears staleness), then redo must stale them again. + s.search_begin(true, false); + type_query(&mut s, "foo"); + s.search_finish(true); + assert_eq!(s.search_match_summary().1, 2); + s.redo(); // forward to "xfoo bar foo" again + assert_eq!( + s.search_match_summary(), + (None, 0), + "redo stales the accepted matches" + ); + } + + #[test] + fn undo_translates_the_live_search_origin() { + let mut s = from_bytes(b"foo bar foo"); + s.active_window_mut().cursor = 0; + assert!(s.insert_char('x')); // "xfoo bar foo" + s.active_window_mut().cursor = 5; + s.search_begin(true, false); // origin 5 ('b' of "bar") + type_query(&mut s, "foo"); + s.undo(); // removes the 'x' at 0: origin must shift to 4 + s.search_finish(false); // cancel + assert_eq!( + s.cursor(), + 4, + "cancel lands on the origin translated through the undo" + ); + } + + #[test] + fn origin_translates_through_deletes_on_both_paths() { + // Dispatch path (apply_active_edit): backspace before the + // origin. + let mut s = from_bytes(b"foo bar foo"); + s.active_window_mut().cursor = 5; + s.search_begin(true, false); // origin 5 + type_query(&mut s, "foo"); + s.active_window_mut().cursor = 2; + s.backspace(); // deletes byte 1: origin 5 -> 4 + s.search_finish(false); + assert_eq!(s.cursor(), 4, "origin shifted left by the deleted byte"); + + // Direct-notification path: a delete edit through + // notify_buffer_edit. + let mut s = from_bytes(b"foo bar foo"); + let bid = s.active_buffer_id(); + s.active_window_mut().cursor = 5; + s.search_begin(true, false); // origin 5 + type_query(&mut s, "foo"); + let edit = { + let mut reg = s.registry.borrow_mut(); + let buffer = reg.get_mut(bid).expect("buffer"); + buffer + .apply_edit(crate::buffer::EditOp::Delete { + range: Range::new(0, 2), + }) + .expect("direct delete") + }; + s.notify_buffer_edit(bid, &edit); + s.search_finish(false); + assert_eq!( + s.cursor(), + 3, + "origin shifted left by the two directly deleted bytes" + ); + } + + #[test] + fn active_search_fails_closed_while_stale_and_recovers_on_retype() { + // Q#AI8 during a LIVE session: an external edit mid-search + // makes step and summary fail closed; the next pattern + // keystroke recomputes (set clears staleness) and resumes. + let mut s = from_bytes(b"foo bar foo"); + let bid = s.active_buffer_id(); + s.active_window_mut().cursor = 0; + s.search_begin(true, false); + type_query(&mut s, "fo"); + assert_eq!(s.search_match_summary().1, 2); + let edit = { + let mut reg = s.registry.borrow_mut(); + let buffer = reg.get_mut(bid).expect("buffer"); + buffer + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"zz", + }) + .expect("direct insert") + }; + s.notify_buffer_edit(bid, &edit); + assert_eq!( + s.search_match_summary(), + (None, 0), + "summary fails closed mid-search" + ); + let before = s.cursor(); + s.search_step(true); + assert_eq!(s.cursor(), before, "step fails closed mid-search"); + // Growing the query recomputes against the current text. + type_query(&mut s, "o"); + assert_eq!( + s.search_match_summary().1, + 2, + "the next pattern keystroke refreshes the match set" + ); + assert_eq!(s.cursor(), 2, "focus lands from the translated origin"); + s.search_step(true); + assert_eq!(s.cursor(), 10, "stepping resumes after the refresh"); + } + #[test] fn search_backspace_widens_the_match_set() { let mut s = from_bytes(b"fo foo food"); diff --git a/tests/auto_indent_acceptance.rs b/tests/auto_indent_acceptance.rs index 4241f21..5d40590 100644 --- a/tests/auto_indent_acceptance.rs +++ b/tests/auto_indent_acceptance.rs @@ -149,6 +149,27 @@ fn zero_indent_and_empty_buffer_match_plain_newline() { assert_eq!(cursor(&s), 4); } +#[test] +fn giant_minified_line_splits_correctly() { + // PR #109 round 1 finding 4: the indent scan is forward-chunked + // and stops at the first non-whitespace byte, so Enter at the end + // of a huge unindented line never materializes the line. This + // pins the behavior; boundedness is by construction. + let long = "x".repeat(64 * 1024); + let mut s = editor_with(&long); + exec(&s, &format!("pmacs.editor.goto_byte({})", long.len())); + press(&mut s, KeyCode::Enter); + assert_eq!(buffer_text(&s), format!("{long}\n")); + assert_eq!(cursor(&s) as usize, long.len() + 1); + + // And an indented giant line still carries exactly its indent. + let body = format!(" {long}"); + let mut s = editor_with(&body); + exec(&s, &format!("pmacs.editor.goto_byte({})", body.len())); + press(&mut s, KeyCode::Enter); + assert_eq!(buffer_text(&s), format!("{body}\n ")); +} + #[test] fn whitespace_only_line_copies_and_the_abandoned_line_keeps_its_whitespace() { // Named non-goal (Q#AI3): no trailing-whitespace cleanup on the