pmacs/tests/m8_10_acceptance.rs

1272 lines
43 KiB
Rust

// tests/m8_10_acceptance.rs --- T M8.10 outline-class aggregate buffer.
//! Acceptance for the M8.10 deliverable: an aggregate buffer kind
//! that selects matching entries from multiple outline source
//! buffers via a predicate, concatenates each match's source-byte
//! range into a single visible buffer, and routes user edits in
//! the aggregate back to source coordinates via `intercept_edit`.
//! Source-side edits propagate to the aggregate within one async
//! tick.
//!
//! The package source-of-record is `tests/fixtures/pmacs-outline/`
//! (`aggregate.lua` is the M8.10 module; M8.9's parser/view/nav
//! are unchanged and reused).
//!
//! The four spec acceptance bullets:
//!
//! 1. Aggregate query across five outline buffers returns matching
//! entries.
//! 2. Editing a matched entry in the aggregate updates the source
//! buffer's rope.
//! 3. Source buffer changes propagate to the aggregate within one
//! tick.
//! 4. Cyclic dependencies (an aggregate that includes itself) are
//! detected and rejected.
//!
//! Plus regressions:
//!
//! * close cleans up source-listener intercepts and write-back
//! intercept; sources stay alive.
//! * Cross-block edits are rejected with a clear error.
//! * Multiple aggregates over overlapping sources don't interfere.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;
fn outline_package_path() -> PathBuf {
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
here.join("tests").join("fixtures").join("pmacs-outline")
}
fn editor_with_outline() -> (EditorState, TempDir, TempDir) {
let cache = tempfile::tempdir().expect("cache tempdir");
let user_root = tempfile::tempdir().expect("user-root tempdir");
let mut state = EditorState::new_with_roots(&crate::iso::roots());
state.lua_host.reopen_init_phase_for_testing();
state.lua_host.set_package_install_override(
PackageInstallOverride::new()
.with_cache_dir(cache.path().to_path_buf())
.with_user_install_root(user_root.path().to_path_buf()),
);
let pkg = outline_package_path();
let pkg_str = pkg.display().to_string();
let install = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("pmacs-outline")
"#
);
state
.lua_host
.eval(Some("outline-install"), &install)
.unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
(state, cache, user_root)
}
fn agg_text(state: &mut EditorState) -> String {
state
.lua_host
.lua()
.load(r"return AGG.buffer:slice(0, AGG.buffer:len())")
.eval()
.expect("agg text")
}
// ---------------------------------------------------------------------------
// Bullet 1 --- aggregate over 5 sources returns matching entries
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_query_across_five_buffers_returns_matches() {
let (mut state, _c, _u) = editor_with_outline();
// Build 5 source buffers, each with three headlines: two tagged
// :todo: and one tagged :done:. Total: 10 entries match :todo:,
// 5 match :done:.
state
.lua_host
.eval(
Some("five-sources"),
r#"
local outline = require("pmacs-outline")
_G.SRCS = {}
for i = 1, 5 do
local s = pmacs.buffer.create("*src-" .. i .. "*")
s:replace(0, 0,
"* TODO-A-" .. i .. " :todo:\nbody-a\n" ..
"* TODO-B-" .. i .. " :todo:\nbody-b\n" ..
"* DONE-" .. i .. " :done:\nbody-d\n")
_G.SRCS[i] = s
end
_G.AGG = outline.aggregate(_G.SRCS, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("aggregate creation");
// The aggregate text should contain all 10 :todo: headlines (2 per source x 5).
let txt = agg_text(&mut state);
let mut count = 0;
for line in txt.lines() {
if line.starts_with("* TODO-") {
count += 1;
}
}
assert_eq!(
count, 10,
"expected 10 TODO headlines across 5 sources; got {count}; agg text: {txt:?}"
);
// No DONE entries should appear.
assert!(
!txt.contains("DONE-"),
":done: entries must NOT appear in a :todo:-filtered aggregate; got: {txt:?}"
);
}
// ---------------------------------------------------------------------------
// Bullet 2 --- editing aggregate updates source rope
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_edit_propagates_to_source() {
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("two-source-agg"),
r#"
local outline = require("pmacs-outline")
_G.S1 = pmacs.buffer.create("*s1*")
_G.S1:replace(0, 0, "* TODO 1 :todo:\nbody-1\n")
_G.S2 = pmacs.buffer.create("*s2*")
_G.S2:replace(0, 0, "* TODO 2 :todo:\nbody-2\n")
_G.AGG = outline.aggregate({_G.S1, _G.S2}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
// Aggregate now has both TODOs concatenated. Find the byte
// offset of "body-1" in the aggregate (it's inside the first
// block); insert "X" before it.
state
.lua_host
.eval(
Some("edit-agg"),
r#"
local agg_text = AGG.buffer:slice(0, AGG.buffer:len())
_G.BODY1_OFFSET = agg_text:find("body%-1") - 1 -- 0-indexed
AGG.buffer:insert(BODY1_OFFSET, "X")
"#,
)
.expect("aggregate edit");
// Source S1 must now contain "Xbody-1".
let s1_text: String = state
.lua_host
.lua()
.load(r"return S1:slice(0, S1:len())")
.eval()
.expect("s1 text");
assert!(
s1_text.contains("Xbody-1"),
"S1 must reflect the aggregate-side insertion; got: {s1_text:?}"
);
// S2 must remain untouched.
let s2_text: String = state
.lua_host
.lua()
.load(r"return S2:slice(0, S2:len())")
.eval()
.expect("s2 text");
assert_eq!(
s2_text, "* TODO 2 :todo:\nbody-2\n",
"S2 must be unchanged; got: {s2_text:?}"
);
}
// ---------------------------------------------------------------------------
// Bullet 3 --- source change propagates to aggregate within one tick
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_source_change_propagates_within_one_tick() {
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S1 = pmacs.buffer.create("*s1*")
_G.S1:replace(0, 0, "* TODO original :todo:\nbody\n")
_G.AGG = outline.aggregate({_G.S1}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
let initial = agg_text(&mut state);
assert!(
initial.contains("TODO original"),
"aggregate must show the original headline; got: {initial:?}"
);
// Edit the source directly.
state
.lua_host
.eval(
Some("edit-source"),
r#"
-- Replace "original" in the headline with "modified".
local txt = S1:slice(0, S1:len())
local s, e = txt:find("original")
S1:replace(s - 1, e, "modified")
"#,
)
.expect("source edit");
// SP-7: the source-listener intercept now schedules a deferred
// repaint via pmacs.async.yield_to_next_tick(), with no worker
// round trip. One async tick must be sufficient.
state.tick_async();
let after = agg_text(&mut state);
assert!(
after.contains("TODO modified"),
"aggregate must reflect the source's headline change; got: {after:?}"
);
assert!(
!after.contains("TODO original"),
"aggregate must not still show the old headline; got: {after:?}"
);
}
// ---------------------------------------------------------------------------
// Bullet 4 --- cyclic dependencies detected and rejected
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_cycle_rejected_via_test_seam_construction() {
// v0.1's static API can't construct a cycle: at creation time,
// every aggregate's `sources` list is fixed, and the new
// aggregate's buffer doesn't exist yet so no caller can include
// it. The cycle detector exists for v0.2 mutability and for
// sanity-checking arbitrary user-provided sources. We exercise
// it by manually patching an existing aggregate's `sources` via
// the test seam to construct a cycle, then attempting to create
// a new aggregate that triggers the check.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("two-aggregates"),
r#"
local outline = require("pmacs-outline")
local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
_G.S1 = pmacs.buffer.create("*s1*")
_G.S1:replace(0, 0, "* one :todo:\nbody\n")
_G.S2 = pmacs.buffer.create("*s2*")
_G.S2:replace(0, 0, "* two :todo:\nbody\n")
_G.A = outline.aggregate({_G.S1}, function(_) return true end)
_G.B = outline.aggregate({_G.S2}, function(_) return true end)
-- Patch A.sources to include B.buffer; patch B.sources to
-- include A.buffer. Walking either now revisits a buffer
-- (A -> B -> A), which is a cycle.
A.sources = { S1, B.buffer }
B.sources = { S2, A.buffer }
_G.WOULD_CYCLE = te.aggregate.__pmacs_outline_test_would_cycle
"#,
)
.expect("two aggregates setup");
// would_cycle on a sources list that walks through A and B
// should return true.
let cycle: bool = state
.lua_host
.lua()
.load(r"return WOULD_CYCLE({A.buffer}, nil)")
.eval()
.expect("would_cycle");
assert!(
cycle,
"cycle detector must flag the patched-up A->B->A chain"
);
// Direct creation attempt with sources that walk into the cycle
// should error. Use pcall.
let err_msg: String = state
.lua_host
.lua()
.load(
r#"
local outline = require("pmacs-outline")
local ok, err = pcall(function()
outline.aggregate({A.buffer}, function(_) return true end)
end)
if ok then return "no error" end
return tostring(err)
"#,
)
.eval()
.expect("attempt cycle creation");
assert!(
err_msg.contains("cycle"),
"creation attempt must reject with a 'cycle' message; got: {err_msg:?}"
);
}
// ---------------------------------------------------------------------------
// Regressions
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_close_removes_intercepts_keeps_sources_alive() {
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S1 = pmacs.buffer.create("*s1*")
_G.S1:replace(0, 0, "* TODO 1 :todo:\nbody\n")
_G.AGG = outline.aggregate({_G.S1}, function(e)
return e.tagset and e.tagset.todo
end)
outline.aggregate_close(_G.AGG)
"#,
)
.expect("setup + close");
// S1 should still be valid after aggregate_close.
let valid: bool = state
.lua_host
.lua()
.load(r"return S1:is_valid()")
.eval()
.expect("s1 valid");
assert!(
valid,
"source buffer must remain valid after aggregate_close"
);
// Editing S1 after close must not panic. The source-listener
// intercept has been removed.
state
.lua_host
.eval(Some("edit-after-close"), r#"S1:insert(0, "Z")"#)
.expect("edit after close");
// Trigger a tick; nothing should fail.
state.tick_async();
}
#[test]
fn outline_aggregate_cross_block_edit_rejected() {
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S1 = pmacs.buffer.create("*s1*")
_G.S1:replace(0, 0, "* one :todo:\nbody1\n")
_G.S2 = pmacs.buffer.create("*s2*")
_G.S2:replace(0, 0, "* two :todo:\nbody2\n")
_G.AGG = outline.aggregate({_G.S1, _G.S2}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
// Determine the agg-byte where block 1 ends (= block 2 starts).
let split: i64 = state
.lua_host
.lua()
.load(r"return AGG.blocks[1].agg_end")
.eval()
.expect("split byte");
assert!(split > 0, "split byte must be positive; got {split}");
// Try to delete a range that spans the boundary [split-2, split+2).
let result = state.lua_host.eval(
Some("cross-block-delete"),
&format!(r"AGG.buffer:delete({}, {})", split - 2, split + 2),
);
assert!(
result.is_err(),
"cross-block delete must be rejected; succeeded with no error"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("block boundaries") || msg.contains("block"),
"rejection must mention block boundaries; got: {msg}"
);
}
#[test]
fn outline_aggregate_multiple_aggregates_over_overlapping_sources_dont_interfere() {
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*shared*")
_G.S:replace(0, 0,
"* todo-only :todo:\nbody1\n" ..
"* done-only :done:\nbody2\n" ..
"* both :todo:done:\nbody3\n")
_G.AGG_TODO = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
_G.AGG_DONE = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.done
end)
"#,
)
.expect("setup");
let todo_text: String = state
.lua_host
.lua()
.load(r"return AGG_TODO.buffer:slice(0, AGG_TODO.buffer:len())")
.eval()
.expect("todo agg");
assert!(todo_text.contains("todo-only"));
assert!(todo_text.contains("both"));
assert!(!todo_text.contains("done-only"));
let done_text: String = state
.lua_host
.lua()
.load(r"return AGG_DONE.buffer:slice(0, AGG_DONE.buffer:len())")
.eval()
.expect("done agg");
assert!(done_text.contains("done-only"));
assert!(done_text.contains("both"));
assert!(!done_text.contains("todo-only"));
}
// ---------------------------------------------------------------------------
// Pass-4 regressions
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_close_detaches_parser_intercepts_when_unique_consumer() {
// Pass-4 finding 1. aggregate() calls parser.attach for each
// source, which adds an intercept. aggregate_close must call
// parser.detach for each, which (when refcount hits zero)
// removes the intercept. After close, edits to the source must
// not bump the parser's parse counter --- evidence the
// intercept is gone.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("aggregate-then-close"),
r#"
local outline = require("pmacs-outline")
local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
te.parser.__pmacs_outline_test_reset_parse_count()
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO :todo:\nbody\n")
_G.PARSER = te.parser
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
-- Initial render queried entries -> 1 reparse so far.
_G.BEFORE_CLOSE = PARSER.__pmacs_outline_test_parse_count()
outline.aggregate_close(_G.AGG)
"#,
)
.expect("setup + close");
let before_close: i64 = state
.lua_host
.lua()
.globals()
.get("BEFORE_CLOSE")
.expect("before close");
assert!(
before_close >= 1,
"must have parsed at least once before close"
);
// Edit S after close. With the parser intercept removed, the
// parser will not run any new parse_region calls (no dirty
// tracking, no reparse).
state
.lua_host
.eval(Some("edit-after-close"), r#"S:insert(0, "Z")"#)
.expect("edit");
let after_close: i64 = state
.lua_host
.lua()
.load(r"return PARSER.__pmacs_outline_test_parse_count()")
.eval()
.expect("after close");
assert_eq!(
after_close, before_close,
"parser must be fully detached after aggregate_close when no other \
consumer holds a ref; counts {before_close} -> {after_close}"
);
}
#[test]
fn outline_aggregate_close_keeps_parser_when_other_consumer_holds_ref() {
// Refcounting: outline.open(S) and aggregate({S, ...}) share the
// same parser handle. Closing the aggregate decrements the ref
// but the outline view still holds one, so the parser stays
// attached and its intercept keeps invalidating the cache.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("shared-source"),
r#"
local outline = require("pmacs-outline")
local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
te.parser.__pmacs_outline_test_reset_parse_count()
_G.S = pmacs.buffer.create("*shared*")
_G.S:replace(0, 0, "* TODO :todo:\nbody\n")
_G.PARSER = te.parser
_G.OUTLINE_HANDLE = outline.open(_G.S)
_G.PH = _G.OUTLINE_HANDLE.parser_handle
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
-- Refcount must be 2 (one from outline.open, one from
-- aggregate).
_G.REFCOUNT_BEFORE = te.parser.__pmacs_outline_test_refcount(_G.PH)
outline.aggregate_close(_G.AGG)
_G.REFCOUNT_AFTER = te.parser.__pmacs_outline_test_refcount(_G.PH)
"#,
)
.expect("shared setup + agg close");
let before: i64 = state
.lua_host
.lua()
.globals()
.get("REFCOUNT_BEFORE")
.expect("rc before");
let after: i64 = state
.lua_host
.lua()
.globals()
.get("REFCOUNT_AFTER")
.expect("rc after");
assert_eq!(
before, 2,
"shared source must have refcount 2; got {before}"
);
assert_eq!(
after, 1,
"after closing the aggregate, refcount must drop to 1 (outline.open still holds); got {after}"
);
// Reset parse counter, edit source, query via the parser handle:
// parser must still be active and reparse the dirty range.
state
.lua_host
.eval(
Some("verify-parser-active"),
r#"
PARSER.__pmacs_outline_test_reset_parse_count()
S:insert(0, "Z")
local _ = #PARSER.entries(PH)
"#,
)
.expect("verify");
let parses: i64 = state
.lua_host
.lua()
.load(r"return PARSER.__pmacs_outline_test_parse_count()")
.eval()
.expect("parses");
assert!(
parses >= 1,
"parser must still run reparse after aggregate close (other consumer holds ref); \
got {parses} parse calls"
);
}
#[test]
fn outline_aggregate_insert_at_end_of_last_block_appends_to_source() {
// Pass-4 finding 2. Inserting at agg_buf:len() (i.e., at the
// very end of the aggregate, which equals last_block.agg_end)
// must succeed: it maps to source_end of the last matched
// entry.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO :todo:\nbody-1\n")
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
// The aggregate currently equals the source.
let agg_text: String = state
.lua_host
.lua()
.load(r"return AGG.buffer:slice(0, AGG.buffer:len())")
.eval()
.expect("agg");
assert_eq!(agg_text, "* TODO :todo:\nbody-1\n");
// Insert at the very end of the aggregate buffer.
state
.lua_host
.eval(
Some("append"),
r#"
local n = AGG.buffer:len()
AGG.buffer:insert(n, "appended\n")
"#,
)
.expect("append");
// Source must now contain the appended bytes at end-of-entry.
let src_text: String = state
.lua_host
.lua()
.load(r"return S:slice(0, S:len())")
.eval()
.expect("src");
assert!(
src_text.contains("body-1\nappended\n"),
"appended bytes must land at end of source's last matched entry; got: {src_text:?}"
);
}
#[test]
fn outline_aggregate_consecutive_edits_without_repaint_map_correctly() {
// Pass-4 finding 3. Two aggregate edits in immediate succession
// (before any tick_async runs the deferred repaint) must each
// map correctly to source coordinates. Two SEPARATE sources are
// required to expose the bug: when both blocks share one source,
// the parser's source-byte shifts and the agg shifts move
// together and the stale block math accidentally produces the
// right answer. With two sources, edit-1 to S1 grows block 1
// (agg-side) but doesn't touch S2's bytes; the stale block 2
// mapping for edit-2 then computes the wrong S2 byte without
// writeback-time bookkeeping.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S1 = pmacs.buffer.create("*s1*")
_G.S1:replace(0, 0, "* one :todo:\nbody1\n")
_G.S2 = pmacs.buffer.create("*s2*")
_G.S2:replace(0, 0, "* two :todo:\nbody2\n")
_G.AGG = outline.aggregate({_G.S1, _G.S2}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
// Layout: each source is "* one :todo:\nbody1\n" / "* two ..." =
// 19 bytes ("body1" / "body2" is 5 chars + newline). Aggregate
// concatenates: block 1 spans agg [0, 19) for S1; block 2 spans
// agg [19, 38) for S2. body1 starts at agg byte 13 (after
// "* one :todo:\n"); body2 starts at agg byte 32 (= 19 + 13).
//
// Edit-1: insert "X" at agg byte 14, between 'b' and 'o' of body1.
// Block 1 grows by 1. With writeback-time bookkeeping, block 2's
// agg coords shift to [20, 39). Block 2's source coords stay
// [0, 19) because S2 was untouched.
//
// Edit-2: insert "Y" at agg byte 34, which under the *post*-shift
// block 2 maps to S2 byte (34 - 20) = 14 = between 'b' and 'o'
// of body2. Without the shift, stale block 2 [19, 38) maps agg
// byte 34 to S2 byte (34 - 19) = 15 = between 'o' and 'd' instead,
// producing "boYdy2".
state
.lua_host
.eval(
Some("two-edits"),
r#"
AGG.buffer:insert(14, "X")
AGG.buffer:insert(34, "Y")
"#,
)
.expect("two edits");
let s1_text: String = state
.lua_host
.lua()
.load(r"return S1:slice(0, S1:len())")
.eval()
.expect("s1");
let s2_text: String = state
.lua_host
.lua()
.load(r"return S2:slice(0, S2:len())")
.eval()
.expect("s2");
assert!(
s1_text.contains("bXody1"),
"first edit's X must land inside S1's body1; got: {s1_text:?}"
);
assert!(
s2_text.contains("bYody2"),
"second edit's Y must land between 'b' and 'o' of S2's body2; \
this is what fails without writeback-time block-shift bookkeeping \
(stale block 2 would map to S2 byte 15, producing 'boYdy2'); \
got: {s2_text:?}"
);
}
// ---------------------------------------------------------------------------
// Pass-5 regressions
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_close_then_reopen_reinstalls_parser_intercept() {
// Pass-5 finding 1. Closing an aggregate must not leave a dead
// (refcount=0, intercept=nil) parser handle in the registry.
// If it did, a subsequent attach for the same source would
// bump the dead handle's refcount and skip reinstalling the
// intercept --- the parser cache then never invalidates on
// future edits.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("aggregate-close-then-reopen"),
r#"
local outline = require("pmacs-outline")
local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
te.parser.__pmacs_outline_test_reset_parse_count()
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO :todo:\nbody\n")
_G.PARSER = te.parser
_G.PRED = function(e) return e.tagset and e.tagset.todo end
local A = outline.aggregate({_G.S}, _G.PRED)
outline.aggregate_close(A)
-- Now create a fresh aggregate over the same source.
_G.B = outline.aggregate({_G.S}, _G.PRED)
_G.PH = _G.B.parser_handles[_G.S]
"#,
)
.expect("close then reopen");
// The fresh aggregate's parser handle must be live (intercept
// installed). Reset the parse counter, edit S, query: the
// counter must increment, proving the intercept is firing
// dirty tracking and the next query reparses.
state
.lua_host
.eval(
Some("verify-fresh-parser"),
r#"
PARSER.__pmacs_outline_test_reset_parse_count()
S:insert(0, "Z")
local _ = #PARSER.entries(PH)
"#,
)
.expect("edit + query");
let parses: i64 = state
.lua_host
.lua()
.load(r"return PARSER.__pmacs_outline_test_parse_count()")
.eval()
.expect("parses");
assert!(
parses >= 1,
"fresh aggregate over a previously-closed source must have a live \
parser intercept; got {parses} parse calls"
);
}
#[test]
fn outline_aggregate_nested_overlapping_blocks_consecutive_writeback() {
// Pass-5 finding 2. When the predicate matches both a parent
// headline and its nested child, the aggregate emits two
// overlapping blocks (the parent's slice contains the child's
// bytes; the child's slice is a copy of those same bytes).
// Editing in the parent block then editing in the child block
// (before the deferred repaint) must map both edits to the
// right source byte. The original shift logic only updated
// same-source blocks whose source_start was past the edited
// block's source_end --- nested children fail that check.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup-nested"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
-- Layout (29 bytes):
-- "* P :todo:\n" bytes 0..10 (11 bytes)
-- "bb\n" bytes 11..13 (3 bytes)
-- "** C :todo:\n" bytes 14..25 (12 bytes)
-- "cc\n" bytes 26..28 (3 bytes)
_G.S:replace(0, 0, "* P :todo:\nbb\n** C :todo:\ncc\n")
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
-- Both P (bytes 0..29) and C (bytes 14..29) match.
-- Aggregate text = P's slice + C's slice = 29 + 15 = 44.
"#,
)
.expect("setup");
let agg_len: i64 = state
.lua_host
.lua()
.load(r"return AGG.buffer:len()")
.eval()
.expect("agg len");
assert_eq!(
agg_len, 44,
"expected aggregate length 44 (29 + 15); got {agg_len}"
);
// Edit-1: insert "X" at agg byte 12 (between the two 'b's of
// P's body "bb"). Maps to S byte 12.
state
.lua_host
.eval(Some("edit-parent"), r#"AGG.buffer:insert(12, "X")"#)
.expect("edit parent");
// Source after edit-1: "* P :todo:\nbXb\n** C :todo:\ncc\n"
// (30 bytes). Layout post-edit-1:
// bytes 0..10 = "* P :todo:" + '\n'
// bytes 11..14 = "bXb\n"
// bytes 15..26 = "** C :todo:\n"
// bytes 27..28 = "cc" (first c at 27, second at 28)
// byte 29 = '\n'
//
// Parser shifted C from [14, 29) to [15, 30); C's headline
// starts at byte 15 ('*' of "**").
//
// The block-shift fix moves Block C's source coords to (15, 30)
// and agg coords to (30, 45). Without the fix, source stays at
// (14, 29) with agg shifted to (30, 45) --- a 1-byte gap that
// misroutes any subsequent edit landing in C.
//
// Aggregate text post-edit-1 = P's slice (30 bytes) + C's
// slice from the original render (15 bytes, unchanged in
// memory) = 45 bytes. C's body "cc" within C's slice is at
// offsets 12 and 13; in agg coords those are bytes 42 and 43.
// To land "Y" *between* the two c's we insert at byte 43 = the
// second c.
//
// With the Pass-5 fix: src = 15 + (43-30) = 28 = second c, in
// S. Insert before -> "cYc". CORRECT.
//
// Without the fix: src = 14 + (43-30) = 27 = first c, in S.
// Insert before -> "Ycc". WRONG.
state
.lua_host
.eval(Some("edit-child"), r#"AGG.buffer:insert(43, "Y")"#)
.expect("edit child");
let s_text: String = state
.lua_host
.lua()
.load(r"return S:slice(0, S:len())")
.eval()
.expect("s text");
assert!(
s_text.contains("bXb"),
"edit-1 must land between the two b's of P's body; got: {s_text:?}"
);
assert!(
s_text.contains("cYc"),
"edit-2 must land between the two c's of C's body; without the \
Pass-5 nested-overlapping fix, stale block C source coords map \
the edit to byte 26 producing 'Ycc'; got: {s_text:?}"
);
}
#[test]
fn outline_aggregate_duplicate_source_buffers_not_a_cycle() {
// Pass-5 finding 3. would_cycle previously used one global
// visited set, so passing the same plain source twice in
// `sources` was treated as a cycle. Plain duplicate sources
// are a legitimate use (e.g., user wants the same outline
// counted twice for some predicate); cycles are about
// aggregate-to-aggregate dependency loops.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("dup-sources"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO :todo:\nbody\n")
-- Should succeed: S is a plain (non-aggregate)
-- source buffer, and duplication of plain sources
-- is not a cycle.
_G.AGG = outline.aggregate({_G.S, _G.S}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("duplicate sources should not be rejected as a cycle");
// The aggregate should contain the matching entry's content twice
// (once per duplicate source listing).
let txt: String = state
.lua_host
.lua()
.load(r"return AGG.buffer:slice(0, AGG.buffer:len())")
.eval()
.expect("agg text");
let occurrences = txt.matches("* TODO").count();
assert_eq!(
occurrences, 2,
"duplicate source must produce two block emissions; got {occurrences}: {txt:?}"
);
}
// ---------------------------------------------------------------------------
// Pass-6 regressions
// ---------------------------------------------------------------------------
#[test]
fn outline_aggregate_delete_overlapping_child_drops_invalidated_block() {
// Pass-6 finding 1. With parent + child both matching, deleting
// bytes in the parent block that span the child entry's source
// range entirely deletes the child from source. The block map
// must drop the child block; otherwise a subsequent write-back
// edit at the (zombie) child agg range would map to source
// bytes that no longer exist.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup-nested"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
-- Layout (30 bytes):
-- "* P :todo:\n" bytes 0..10
-- "** C :todo:\n" bytes 11..22
-- "ccbody\n" bytes 23..29
_G.S:replace(0, 0, "* P :todo:\n** C :todo:\nccbody\n")
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
let n_before: i64 = state
.lua_host
.lua()
.load(r"return #AGG.blocks")
.eval()
.expect("blocks before");
assert_eq!(
n_before, 2,
"aggregate must have 2 blocks (P + C); got {n_before}"
);
// Delete agg [11, 30) --- the parent block's bytes from byte 11
// through end of P's slice. This maps to source [11, 30) which
// is exactly C's source range (level-2 entry nested in P).
state
.lua_host
.eval(
Some("delete-child-via-parent"),
r"AGG.buffer:delete(11, 30)",
)
.expect("delete");
let n_after: i64 = state
.lua_host
.lua()
.load(r"return #AGG.blocks")
.eval()
.expect("blocks after");
assert_eq!(
n_after, 1,
"C's source bytes were entirely deleted; its block must be \
dropped from handle.blocks. got {n_after} blocks"
);
let surviving_source_start: i64 = state
.lua_host
.lua()
.load(r"return AGG.blocks[1].source_start")
.eval()
.expect("ss");
assert_eq!(
surviving_source_start, 0,
"surviving block must be P (source_start=0); got {surviving_source_start}"
);
// Source S now has just "* P :todo:\n" (11 bytes).
let s_text: String = state
.lua_host
.lua()
.load(r"return S:slice(0, S:len())")
.eval()
.expect("s text");
assert_eq!(
s_text, "* P :todo:\n",
"source must reflect the delete; got: {s_text:?}"
);
}
#[test]
fn outline_aggregate_delete_entire_last_block_drops_edited_block() {
// Pass-7 finding 1. Deleting a matched block's full source
// range must drop the edited block itself. Otherwise it remains
// as a zero-length stale block at aggregate EOF, and a second
// insert before deferred repaint routes into the deleted source
// coordinates instead of being rejected.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup-single-block"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO :todo:\nbody\n")
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
let len: i64 = state
.lua_host
.lua()
.load(r"return AGG.buffer:len()")
.eval()
.expect("agg len");
assert!(len > 0, "aggregate block must be non-empty");
state
.lua_host
.eval(
Some("delete-whole-block"),
&format!("AGG.buffer:delete(0, {len})"),
)
.expect("delete whole block");
let blocks_after: i64 = state
.lua_host
.lua()
.load(r"return #AGG.blocks")
.eval()
.expect("blocks after");
assert_eq!(
blocks_after, 0,
"full-block delete must drop the edited block from handle.blocks"
);
let result = state
.lua_host
.eval(Some("stale-tail-insert"), r#"AGG.buffer:insert(0, "Z")"#);
assert!(
result.is_err(),
"insert into the deleted block's old range must be rejected before repaint"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("outside any matched entry"),
"rejection should name the stale matched-entry range; got: {msg}"
);
let s_text: String = state
.lua_host
.lua()
.load(r"return S:slice(0, S:len())")
.eval()
.expect("source text");
assert_eq!(
s_text, "",
"stale aggregate insert must not write back into source; got: {s_text:?}"
);
}
#[test]
fn outline_aggregate_package_reload_closes_aggregates() {
// Pass-6 finding 2. pmacs.packages.reload triggers the
// package's on_unload, which must close all live aggregate
// handles. Otherwise source-listener intercepts and parser
// refcounts persist past the old package's discarded module
// closures, and aggregate buffers stay alive without a way to
// reach them.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup-aggregate"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO :todo:\nbody\n")
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
_G.AGG_BUF = AGG.buffer
"#,
)
.expect("aggregate setup");
// Sanity: aggregate buffer is alive before reload.
let alive_before: bool = state
.lua_host
.lua()
.load(r"return AGG_BUF:is_valid()")
.eval()
.expect("alive before");
assert!(
alive_before,
"aggregate buffer should be alive before reload"
);
// Reload the package. on_unload must close the aggregate, which
// kills its buffer.
state
.lua_host
.eval(Some("reload"), r#"pmacs.packages.reload("pmacs-outline")"#)
.expect("reload");
let alive_after: bool = state
.lua_host
.lua()
.load(r"return AGG_BUF:is_valid()")
.eval()
.expect("alive after");
assert!(
!alive_after,
"aggregate buffer must be killed by reload's on_unload calling \
aggregate.close_all_handles --- otherwise the aggregate's \
intercepts are still attached to source S after the package \
module has been re-initialized"
);
}
#[test]
fn outline_aggregate_source_change_propagates_with_tight_deadline() {
// SP-7 regression: the aggregate source-listener uses
// pmacs.async.yield_to_next_tick(), so the old worker-sleep
// timing path must not come back.
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("setup"),
r#"
local outline = require("pmacs-outline")
_G.S = pmacs.buffer.create("*s*")
_G.S:replace(0, 0, "* TODO original :todo:\nbody\n")
_G.AGG = outline.aggregate({_G.S}, function(e)
return e.tagset and e.tagset.todo
end)
"#,
)
.expect("setup");
state
.lua_host
.eval(
Some("source-edit"),
r#"
local txt = S:slice(0, S:len())
local s, e = txt:find("original")
S:replace(s - 1, e, "modified")
"#,
)
.expect("source edit");
// Tight bounded-propagation check: 200ms is much less than the
// 2s the helper allows; if the v0.1 mechanism ever regresses
// beyond worker round-trip latency, this test catches it.
let deadline = Instant::now() + Duration::from_millis(200);
loop {
if Instant::now() >= deadline {
let txt: String = state
.lua_host
.lua()
.load(r"return AGG.buffer:slice(0, AGG.buffer:len())")
.eval()
.expect("agg text");
panic!(
"source-change propagation exceeded the 200ms tight \
deadline; see SP-7 in V0.2-PREREQUISITES.md. \
Aggregate text: {txt:?}"
);
}
state.tick_async();
let txt: String = state
.lua_host
.lua()
.load(r"return AGG.buffer:slice(0, AGG.buffer:len())")
.eval()
.expect("agg text");
if txt.contains("TODO modified") {
break;
}
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn outline_aggregate_empty_sources_rejected() {
let (mut state, _c, _u) = editor_with_outline();
let result = state.lua_host.eval(
Some("empty-sources"),
r#"
local outline = require("pmacs-outline")
outline.aggregate({}, function(_) return true end)
"#,
);
assert!(result.is_err(), "empty sources list must be rejected");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("non-empty"),
"error must mention non-empty; got: {msg}"
);
}
// Isolated bootstrap storage roots (see the module docs): an
// integration test is compiled without `cfg(test)`, so a raw
// `EditorState::new()` would read the developer's real `init.lua` and
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;