use proptest::prelude::*;
use ronin_core::{print, UndoCap, UndoEntry, UndoStack};
const WINDOW: std::time::Duration = ronin_core::undo::DEFAULT_COALESCE_WINDOW;
fn entry(src: &str, cursor: usize) -> UndoEntry<usize> {
UndoEntry::new(ronin_core::parse(src), src.to_string(), cursor)
}
#[test]
fn undo_walks_back_through_exact_prior_bytes() {
let states = [
"(a: 1)\n",
"(a: 12)\n",
"(a: 12, b: 3)\n",
"(a: 12, b: 33)\n",
];
let mut stack: UndoStack<usize> = UndoStack::new();
for (i, s) in states.iter().enumerate() {
stack.record(entry(s, i), false);
}
for s in states.iter().rev().skip(1) {
let e = stack.undo().expect("undo step");
assert_eq!(e.source_text(), *s, "undo restores exact prior bytes");
assert_eq!(print(e.cst_snapshot()), *s, "CST prints back byte-for-byte");
}
assert!(!stack.can_undo());
for s in states.iter().skip(1) {
let e = stack.redo().expect("redo step");
assert_eq!(e.source_text(), *s, "redo replays exact bytes");
}
assert!(!stack.can_redo());
}
#[test]
fn no_reflow_on_restore_preserves_comments_and_whitespace() {
let messy = "( a:1,\n // keep me\n b : 2 ,)\n";
let mut stack: UndoStack<usize> = UndoStack::new();
stack.record(entry("()\n", 0), false);
stack.record(entry(messy, 0), false);
let e = stack.undo().expect("undo");
assert_eq!(e.source_text(), "()\n");
let r = stack.redo().expect("redo");
assert_eq!(
r.source_text(),
messy,
"restore is byte-faithful, no reflow"
);
assert_eq!(print(r.cst_snapshot()), messy);
}
#[test]
fn new_edit_after_undo_invalidates_redo() {
let mut stack: UndoStack<usize> = UndoStack::new();
stack.record(entry("(a: 1)\n", 0), false);
stack.record(entry("(a: 2)\n", 0), false);
stack.record(entry("(a: 3)\n", 0), false);
let _ = stack.undo();
let _ = stack.undo();
assert!(stack.can_redo());
stack.record(entry("(a: 9)\n", 0), false);
assert!(
!stack.can_redo(),
"redo invalidated by the new edit (TR-012)"
);
}
#[test]
fn coalesced_run_collapses_to_one_unit() {
let mut stack: UndoStack<usize> = UndoStack::new();
stack.record(entry("", 0), false); let mut text = String::new();
for i in 0..20 {
text.push((b'a' + (i % 26) as u8) as char);
stack.record(entry(&text, i as usize), i != 0);
}
assert_eq!(
stack.len(),
1,
"the whole run is a single undo unit (SC-010)"
);
assert_eq!(
stack.undo().unwrap().source_text(),
"",
"one undo clears the run"
);
assert!(!stack.can_undo());
}
#[test]
fn pause_after_run_starts_new_unit() {
let mut stack: UndoStack<usize> = UndoStack::new();
stack.record(entry("a", 0), false); stack.record(entry("ab", 0), false); stack.record(entry("abc", 0), true); stack.record(entry("abc.", 0), false); assert_eq!(
stack.len(),
2,
"boundary 'a' and boundary 'abc' are retained"
);
assert_eq!(stack.undo().unwrap().source_text(), "abc");
assert_eq!(stack.undo().unwrap().source_text(), "a");
assert!(!stack.can_undo());
}
#[test]
fn exceeding_count_cap_drops_oldest() {
let cap = UndoCap::new(4, ronin_core::undo::DEFAULT_UNDO_BYTE_CAP);
let mut stack: UndoStack<usize> = UndoStack::with_config(cap, WINDOW);
for i in 0..20 {
stack.record(entry(&format!("s{i:03}"), 0), false);
}
assert_eq!(stack.len(), 4, "count cap binds at 4 boundaries");
assert_eq!(stack.undo().unwrap().source_text(), "s018");
assert_eq!(stack.undo().unwrap().source_text(), "s017");
assert_eq!(stack.undo().unwrap().source_text(), "s016");
assert_eq!(stack.undo().unwrap().source_text(), "s015");
assert!(!stack.can_undo());
}
#[test]
fn memory_bounded_independent_of_file_size() {
let byte_cap = 5 * 1024 * 1024;
let cap = UndoCap::new(10_000, byte_cap);
let mut stack: UndoStack<usize> = UndoStack::with_config(cap, WINDOW);
let big = "x".repeat(1024 * 1024);
for i in 0..30 {
stack.record(entry(&format!("{big}{i}"), 0), false);
}
assert!(
stack.retained_bytes() <= byte_cap,
"retained ring bytes ({}) exceed the byte cap ({byte_cap})",
stack.retained_bytes()
);
assert!(
stack.len() <= 6,
"≈1 MiB units under a 5 MiB cap → at most ~5–6 units"
);
}
#[test]
fn misconfigured_cap_falls_back_to_default_never_unbounded() {
let cap = UndoCap::new(0, 0);
let stack: UndoStack<usize> = UndoStack::with_config(cap, WINDOW);
assert_eq!(
stack.cap().max_count,
ronin_core::undo::DEFAULT_UNDO_COUNT_CAP
);
assert_eq!(
stack.cap().max_bytes,
ronin_core::undo::DEFAULT_UNDO_BYTE_CAP
);
}
proptest! {
#[test]
fn prop_undo_redo_round_trips_exact_bytes(
seeds in proptest::collection::vec(0u32..1000, 1..40)
) {
let states: Vec<String> = seeds.iter().map(|n| format!("(v: {n})\n")).collect();
let mut stack: UndoStack<usize> = UndoStack::new();
for (i, s) in states.iter().enumerate() {
stack.record(entry(s, i), false); }
prop_assert_eq!(stack.len(), states.len() - 1);
for s in states.iter().rev().skip(1) {
let e = stack.undo().expect("undo");
prop_assert_eq!(e.source_text(), s.as_str());
prop_assert_eq!(print(e.cst_snapshot()), s.as_str());
}
prop_assert!(!stack.can_undo());
for s in states.iter().skip(1) {
let e = stack.redo().expect("redo");
prop_assert_eq!(e.source_text(), s.as_str());
}
prop_assert!(!stack.can_redo());
}
#[test]
fn prop_ring_stays_within_count_cap_and_drops_oldest(
cap_count in 1usize..16,
edits in 1usize..120,
) {
let cap = UndoCap::new(cap_count, ronin_core::undo::DEFAULT_UNDO_BYTE_CAP);
let mut stack: UndoStack<usize> = UndoStack::with_config(cap, WINDOW);
for i in 0..edits {
stack.record(entry(&format!("(n: {i})\n"), 0), false);
}
prop_assert!(stack.len() <= cap_count);
let expect_newest_boundary = edits.saturating_sub(2); if stack.can_undo() {
let e = stack.undo().expect("undo");
let got = e.source_text().to_string();
prop_assert_eq!(got, format!("(n: {expect_newest_boundary})\n"));
}
}
#[test]
fn prop_coalesced_run_is_one_unit(run_len in 1usize..80) {
let mut stack: UndoStack<usize> = UndoStack::new();
stack.record(entry("(base: 0)\n", 0), false); for i in 1..=run_len {
stack.record(entry(&format!("(base: {i})\n"), i), i != 1);
}
prop_assert_eq!(stack.len(), 1);
let undone = stack.undo().expect("undo run");
prop_assert_eq!(undone.source_text(), "(base: 0)\n");
prop_assert!(!stack.can_undo());
}
}