Merge pull request #157 from levineuwirth/crdt-undo-repro

test(buffer): capture the CRDT undo no-op-with-op case
This commit is contained in:
Levi Neuwirth 2026-07-25 23:10:46 +00:00 committed by GitHub
commit ccf29e3eb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 82 additions and 0 deletions

View File

@ -2790,6 +2790,88 @@ mod tests {
}
}
/// Deterministic reduction of a `rope_matches_crdt_projection_
/// after_arbitrary_edits` failure found by raising the case
/// count (`PROPTEST_CASES=2000`) on `main` @ `e745068`.
///
/// **What happens.** Replacing a byte range with *identical*
/// bytes is a textual no-op but a real CRDT operation (a delete
/// plus an insert). Undoing it therefore advances the CRDT
/// version while leaving the materialized text unchanged, so
/// `undo_crdt_mode` derives an EMPTY replacement edit — and
/// still attaches the `crdt_op` that `crdt.undo()` produced.
/// That trips the proptest's `crdt_op` shape invariant, "a
/// no-op edit must have `crdt_op = None`".
///
/// **What was verified about the consequences**, so the next
/// reader does not have to redo it:
///
/// * content stays correct — rope and CRDT projection agree
/// before and after (asserted below);
/// * replicas stay converged — both `crdt_op` consumers
/// (`EditorCore::queue_daemon_origin_crdt_op` and the remote-op
/// path) read `edit.crdt_op` unconditionally and do **not**
/// short-circuit on an empty range, so the op is broadcast;
/// * the cursor does not jump — `EditorCore::undo` only clamps
/// to buffer length and never seeks `edit.range.start`.
///
/// **The open question** is therefore whether the *invariant* is
/// simply mis-scoped rather than the behavior being wrong. It
/// was written for the FORWARD `apply_edit` short-circuit, which
/// returns before ever producing an op; CRDT-mode undo/redo
/// never reach that path. One artifact is genuinely arbitrary
/// either way: `derive_replacement_edit` reports the empty range
/// at the buffer END rather than at the edit site.
///
/// Ignored, not deleted: it documents a real, reproducible
/// asymmetry that nothing else on `main` records, and un-ignoring
/// it is the first step of whichever resolution wins.
#[test]
#[ignore = "known pre-existing main behavior; see the doc comment \
for the verified consequences and the open question"]
fn crdt_undo_of_an_identity_replace_reports_a_no_op_edit_carrying_an_op() {
let mut buffer =
Buffer::new_with_crdt(BufferId::next(), "*identity-undo*", 1).expect("crdt");
buffer
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.expect("seed insert");
// Replace one byte with the SAME byte.
let forward = buffer
.apply_edit(EditOp::Replace {
range: Range::new(1, 2),
bytes: b"e",
})
.expect("identity replace");
assert_eq!(forward.range, Range::new(1, 2));
assert_eq!(forward.inserted_len, 1);
let undone = buffer.undo().expect("undo");
assert!(
undone.range.is_empty() && undone.inserted_len == 0,
"the undo produced no textual change: {:?}/{}",
undone.range,
undone.inserted_len
);
assert!(
undone.crdt_op.is_some(),
"…yet it carries a version-advancing CRDT op — the invariant \
the proptest trips on"
);
// Content is unharmed in both projections.
let mut bytes = vec![0u8; buffer.len() as usize];
buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes);
assert_eq!(String::from_utf8(bytes).expect("utf8"), "hello");
assert_eq!(
buffer.crdt_state().expect("crdt").materialize_string(),
"hello"
);
}
proptest! {
// Smaller proptest case count than the default (64) to keep
// CI overhead modest; the per-op invariant check is the