pmacs/tests/m8_9_acceptance.rs

1077 lines
33 KiB
Rust

// tests/m8_9_acceptance.rs --- T M8.9 outline-class structure parser & view.
//! Acceptance for the M8.9 deliverable: a foldable outline view
//! built on a two-buffer projection (source + visible), with an
//! org-shaped headline parser whose lazy-incremental cache is
//! invalidated by an intercept observer. Folding visually replaces
//! a folded subtree's body bytes with a `...` marker; selective
//! rendering derives from the parsed structure.
//!
//! The package source-of-record is `tests/fixtures/pmacs-outline/`.
//!
//! The four spec acceptance bullets:
//!
//! 1. Outline buffer with five levels of nested headlines and 100
//! entries renders within 100 ms (parse + projection).
//! 2. Edits update the parsed structure incrementally; the cache
//! invalidation hook does not reparse the whole buffer for
//! every keystroke.
//! 3. Properties on headlines (`:tag:`, key-value pairs) parse and
//! are queryable.
//! 4. Navigation commands (next-headline, parent-headline,
//! fold-subtree) work.
//!
//! Plus regressions:
//!
//! * Selective rendering: fold collapses subtree bytes to a marker.
//! * Source edits propagate to the visible buffer (lazy repaint).
//! * Property drawer survives an in-body edit (Pass-2 finding 2).
//! * Visible buffer is read-only (intercept rejects user edits).
//! * Initial parse populates entries with correct byte ranges.
//! * Edit followed by no query is free (zero parse-call increments).
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();
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)
}
#[test]
fn public_pmacs_outline_query_returns_matching_entries() {
let (mut state, _cache, _user_root) = editor_with_outline();
state
.lua_host
.eval(
Some("public-outline-query"),
r#"
_G.SRC = pmacs.buffer.create("*outline-query-src*")
_G.SRC:replace(0, 0,
"* TODO alpha :todo:\nbody\n" ..
"* DONE beta :done:\nbody\n" ..
"* TODO gamma :todo:\nbody\n")
local hits = pmacs.outline.query(_G.SRC, function(e)
return e.tagset and e.tagset.todo
end)
_G.HIT_COUNT = #hits
_G.FIRST_TITLE = hits[1].title
_G.SECOND_TITLE = hits[2].title
"#,
)
.expect("public outline query");
let count: i64 = state.lua_host.lua().globals().get("HIT_COUNT").unwrap();
assert_eq!(count, 2);
let first: String = state.lua_host.lua().globals().get("FIRST_TITLE").unwrap();
let second: String = state.lua_host.lua().globals().get("SECOND_TITLE").unwrap();
assert_eq!(first, "TODO alpha");
assert_eq!(second, "TODO gamma");
}
/// Boot an outline view over a fresh source buffer pre-populated
/// with `text`. After this call the visible projection buffer is
/// active in the window. Globals stashed for test access:
///
/// _G.H --- outline handle
/// _G.SRC --- source buffer (editable, holds outline text)
/// _G.VIS --- visible buffer (read-only projection)
/// _G.PARSER --- parser module (test-seam helpers)
/// _G.PH --- parser handle (passed to PARSER.* functions)
///
/// Uses Lua long-bracket strings so embedded newlines / quotes /
/// backslashes need no escaping; texts containing `]==]` would
/// break this, but no acceptance corpus does.
fn open_outline_with(state: &mut EditorState, text: &str) {
assert!(!text.contains("]==]"), "test text must not contain ]==]");
let script = format!(
r#"
local outline = require("pmacs-outline")
local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
te.parser.__pmacs_outline_test_reset_parse_count()
local src = pmacs.buffer.create("*outline-src*")
local payload = [==[{text}]==]
src:replace(0, src:len(), payload)
local h = outline.open(src)
_G.H = h
_G.SRC = src
_G.VIS = h.visible
_G.PARSER = te.parser
_G.PH = h.parser_handle
"#
);
state
.lua_host
.eval(Some("outline-open"), &script)
.unwrap_or_else(|e| panic!("open outline view failed: {e}"));
}
fn parse_count(state: &mut EditorState) -> i64 {
state
.lua_host
.lua()
.load(r"return PARSER.__pmacs_outline_test_parse_count()")
.eval::<i64>()
.expect("read parse count")
}
fn cursor_line(state: &mut EditorState) -> i64 {
state
.lua_host
.lua()
.load(r"return pmacs.editor.cursor_line()")
.eval()
.expect("cursor line")
}
fn visible_text(state: &mut EditorState) -> String {
state
.lua_host
.lua()
.load(r"return VIS:slice(0, VIS:len())")
.eval()
.expect("visible text")
}
// ---------------------------------------------------------------------------
// Bullet 1 --- render within 100ms (parse + projection)
// ---------------------------------------------------------------------------
fn build_5_level_100_entry_text() -> String {
use std::fmt::Write as _;
let mut s = String::new();
let mut count = 0;
let mut level: usize = 1;
while count < 100 {
let stars = "*".repeat(level);
writeln!(s, "{stars} entry-{count}").expect("write");
writeln!(s, "body-{count}-line-1\nbody-{count}-line-2").expect("write");
count += 1;
level = if level == 5 { 1 } else { level + 1 };
}
s
}
#[test]
fn outline_5_level_100_entry_renders_within_100ms() {
let (mut state, _c, _u) = editor_with_outline();
let text = build_5_level_100_entry_text();
let start = Instant::now();
open_outline_with(&mut state, &text);
let elapsed = start.elapsed();
// Sanity: parse picked up 100 entries.
let count: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("entries count");
assert_eq!(count, 100, "must parse 100 headlines; got {count}");
// Visible buffer has been painted (initial render is synchronous in
// open()). Length must be >0; without folds, projection equals source.
let vis_len: i64 = state
.lua_host
.lua()
.load(r"return VIS:len()")
.eval()
.expect("vis len");
assert!(vis_len > 0, "visible buffer must be populated by open()");
assert!(
elapsed < Duration::from_millis(100),
"open() (parse + render) took {elapsed:?}; spec budget is 100ms"
);
}
// ---------------------------------------------------------------------------
// Bullet 2 --- incremental parse cache (lazy-reparse-on-query)
// ---------------------------------------------------------------------------
const SAMPLE_TEXT: &str =
"* A\nbody-A-1\nbody-A-2\n** A1\nbody-A1\n* B\nbody-B\n** B1\nbody-B1\n* C\nbody-C\n";
#[test]
fn outline_initial_parse_populates_entries_with_byte_ranges() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
let count: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("entries");
assert_eq!(count, 5, "expected 5 headlines; got {count}");
let titles: Vec<String> = (1..=5)
.map(|i| {
state
.lua_host
.lua()
.load(format!("return PARSER.entries(PH)[{i}].title"))
.eval::<String>()
.expect("title")
})
.collect();
assert_eq!(titles, vec!["A", "A1", "B", "B1", "C"]);
}
#[test]
fn outline_edit_without_query_is_free() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
// Initial open() ran a synchronous repaint, which queried entries
// (1 parse). Capture as baseline.
let after_seed = parse_count(&mut state);
assert_eq!(
after_seed, 1,
"initial parse from open() must be exactly 1 call; got {after_seed}"
);
// Twenty edits to the source, no queries / commands between them.
state
.lua_host
.eval(
Some("outline-edit-burst"),
r#"
for _ = 1, 20 do
SRC:insert(0, "x")
end
"#,
)
.expect("edit burst");
let after_edits = parse_count(&mut state);
assert_eq!(
after_edits, after_seed,
"edits without intervening queries must not reparse; \
expected count to stay at {after_seed}, got {after_edits}"
);
}
#[test]
fn outline_edit_in_subtree_content_one_reparse_per_query() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
let baseline = parse_count(&mut state);
// Edit body-A-1 (inside A's body, no headline change).
state
.lua_host
.eval(Some("edit-A-body"), r#"SRC:insert(4, "X")"#)
.expect("edit");
let _: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("query");
let after_query = parse_count(&mut state);
assert_eq!(
after_query - baseline,
1,
"edit in subtree content + 1 query must be exactly 1 new reparse; delta {}",
after_query - baseline
);
}
#[test]
fn outline_edit_to_headline_level_one_reparse_per_query() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
let baseline = parse_count(&mut state);
// Insert a `*` to bump A1's level from 2 to 3.
state
.lua_host
.eval(Some("edit-level"), r#"SRC:insert(22, "*")"#)
.expect("edit");
let _: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("query");
let after_query = parse_count(&mut state);
assert_eq!(
after_query - baseline,
1,
"headline-level change + 1 query must be exactly 1 reparse; delta {}",
after_query - baseline
);
}
#[test]
fn outline_edit_introduces_new_headline_one_reparse_per_query() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
let baseline = parse_count(&mut state);
state
.lua_host
.eval(Some("introduce-headline"), r#"SRC:insert(13, "** New\n")"#)
.expect("introduce");
let count: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("query");
let after_query = parse_count(&mut state);
assert_eq!(
after_query - baseline,
1,
"new-headline insertion + 1 query must be exactly 1 reparse; delta {}",
after_query - baseline
);
assert_eq!(
count, 6,
"entry count after insertion must be 6; got {count}"
);
}
#[test]
fn outline_edit_in_a_does_not_reparse_b_when_querying() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
let baseline = parse_count(&mut state);
state
.lua_host
.eval(Some("edit-A"), r#"SRC:insert(4, "Y")"#)
.expect("edit");
// Query for B specifically.
let count_b: i64 = state
.lua_host
.lua()
.load(
r#"
local hits = PARSER.query(PH, function(e) return e.title == "B" end)
return #hits
"#,
)
.eval()
.expect("query for B");
assert_eq!(count_b, 1, "B should still match; got {count_b}");
let after_query = parse_count(&mut state);
assert_eq!(
after_query - baseline,
1,
"edit-A + query-for-B must produce exactly 1 reparse (A's region only); delta {}",
after_query - baseline
);
}
// Pass-2 finding 2 regression: deleting inside a property drawer
// must not lose the enclosing entry's properties.
const PROPS_DRAWER_TEXT: &str = "* A\n\
:PROPERTIES:\n\
:OWNER: alice\n\
:DUE: 2026-12-31\n\
:END:\n\
body of A\n\
* B\nbody-B\n";
#[test]
fn outline_replace_inside_property_drawer_preserves_enclosing_properties() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, PROPS_DRAWER_TEXT);
// Sanity: A's OWNER is "alice" before the edit.
let owner_before: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].properties.OWNER")
.eval()
.expect("owner before");
assert_eq!(owner_before, "alice");
// Replace "alice" with "carol" --- the replacement is inside A's
// property drawer (in body, after headline_byte_end). After the
// edit, A still has properties; specifically OWNER is now "carol"
// and DUE is still "2026-12-31".
//
// SAMPLE: "* A\n" (4) + ":PROPERTIES:\n" (13) = byte 17 is start
// of ":OWNER: alice\n". The "alice" run is bytes 25..30 ("alice"),
// so replace [25, 30) with "carol".
state
.lua_host
.eval(Some("edit-prop-value"), r#"SRC:replace(25, 30, "carol")"#)
.expect("replace");
let owner_after: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].properties.OWNER")
.eval()
.expect("owner after");
assert_eq!(
owner_after, "carol",
"OWNER property must reflect the replacement"
);
// The DUE property (later in the same drawer) must still be present
// --- this is the regression the Pass-2 finding flagged.
let due_after: String = state
.lua_host
.lua()
.load(r#"return PARSER.entries(PH)[1].properties.DUE or "MISSING""#)
.eval()
.expect("due after");
assert_eq!(
due_after, "2026-12-31",
"DUE property must survive an in-drawer edit; \
lost properties indicate the reparse range got truncated"
);
}
// ---------------------------------------------------------------------------
// Bullet 3 --- properties parse and are queryable
// ---------------------------------------------------------------------------
const PROPS_TEXT: &str = "* Headline with tags :work:urgent:\n\
:PROPERTIES:\n\
:OWNER: alice\n\
:DUE: 2026-12-31\n\
:END:\nsome body\n\
* Another :work:\n:PROPERTIES:\n:OWNER: bob\n:END:\n\
* Untagged\nbody\n";
#[test]
fn outline_tags_parse_and_are_queryable() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, PROPS_TEXT);
let count: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("entries");
assert_eq!(count, 3);
let work_count: i64 = state
.lua_host
.lua()
.load(
r"
local hits = PARSER.query(PH, function(e)
return e.tagset and e.tagset.work
end)
return #hits
",
)
.eval()
.expect("query work");
assert_eq!(work_count, 2);
let first_title: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].title")
.eval()
.expect("first title");
assert_eq!(first_title, "Headline with tags");
}
#[test]
fn outline_properties_drawer_parses_into_table() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, PROPS_TEXT);
let owner: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].properties.OWNER")
.eval()
.expect("OWNER");
assert_eq!(owner, "alice");
let due: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].properties.DUE")
.eval()
.expect("DUE");
assert_eq!(due, "2026-12-31");
let second_owner: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[2].properties.OWNER")
.eval()
.expect("second OWNER");
assert_eq!(second_owner, "bob");
let third_props_n: i64 = state
.lua_host
.lua()
.load(
r"
local props = PARSER.entries(PH)[3].properties
local n = 0
for _ in pairs(props) do n = n + 1 end
return n
",
)
.eval()
.expect("third props count");
assert_eq!(third_props_n, 0);
}
#[test]
fn outline_query_by_property_value() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, PROPS_TEXT);
let alice_count: i64 = state
.lua_host
.lua()
.load(
r#"
local hits = PARSER.query(PH, function(e)
return e.properties.OWNER == "alice"
end)
return #hits
"#,
)
.eval()
.expect("alice");
assert_eq!(alice_count, 1);
}
// ---------------------------------------------------------------------------
// Bullet 4 --- selective rendering + folding + navigation
// ---------------------------------------------------------------------------
const NAV_TEXT: &str = "* A\nbody-A\n** A1\nbody-A1\n** A2\nbody-A2\n* B\nbody-B\n";
#[test]
fn outline_initial_render_equals_source_when_no_folds() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
let vis = visible_text(&mut state);
assert_eq!(
vis, NAV_TEXT,
"with no folds the projection must equal source verbatim"
);
}
#[test]
fn outline_fold_subtree_collapses_body_to_marker() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Toggle fold for entry "A" (byte 0).
state
.lua_host
.eval(
Some("fold-A"),
r#"
require("pmacs-outline").toggle_fold(H, 0)
"#,
)
.expect("fold A");
let vis = visible_text(&mut state);
// A's body, A1, A1's body, A2, A2's body --- all replaced with marker.
// B and its body remain.
assert!(
vis.contains("* A\n"),
"A's headline must remain visible after fold; got: {vis:?}"
);
assert!(
!vis.contains("body-A1") && !vis.contains("body-A2"),
"folded subtree's body lines must be hidden; got: {vis:?}"
);
assert!(
!vis.contains("** A1") && !vis.contains("** A2"),
"folded subtree's child headlines must be hidden; got: {vis:?}"
);
assert!(
vis.contains("...\n"),
"folded subtree must show a `...` marker; got: {vis:?}"
);
assert!(
vis.contains("* B\n") && vis.contains("body-B"),
"non-folded sibling B must remain visible; got: {vis:?}"
);
}
#[test]
fn outline_unfold_restores_subtree() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
state
.lua_host
.eval(
Some("fold-then-unfold"),
r#"
local outline = require("pmacs-outline")
outline.toggle_fold(H, 0)
outline.toggle_fold(H, 0)
"#,
)
.expect("toggle twice");
let vis = visible_text(&mut state);
assert_eq!(
vis, NAV_TEXT,
"two toggles must restore the original projection"
);
}
#[test]
fn outline_visible_buffer_rejects_user_edits() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
let result = state
.lua_host
.eval(Some("attempt-edit-visible"), r#"VIS:insert(0, "X")"#);
assert!(
result.is_err(),
"visible buffer must reject user edits; insert succeeded unexpectedly"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("read-only") || msg.contains("source buffer instead"),
"rejection message must point at the source buffer; got: {msg}"
);
}
#[test]
fn outline_source_edit_propagates_to_visible_after_repaint() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Mutate the source.
state
.lua_host
.eval(Some("mutate-src"), r#"SRC:insert(0, "* New top\n")"#)
.expect("mutate");
// Trigger a repaint via the test seam (the same path commands use).
state
.lua_host
.eval(
Some("force-repaint"),
r#"
local te = require("pmacs-outline").__pmacs_outline_test_seam_DO_NOT_USE
te.repaint(H)
"#,
)
.expect("repaint");
let vis = visible_text(&mut state);
assert!(
vis.starts_with("* New top\n"),
"repaint must show the source's new prefix; got: {vis:?}"
);
}
#[test]
fn outline_next_headline_jumps_to_following_headline() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Cursor starts at the visible buffer's beginning. Source byte 0
// corresponds to visible line 0 (`* A`).
state
.lua_host
.eval(
Some("nav-next"),
r#"pmacs.command.invoke("pmacs-outline.next-headline")"#,
)
.expect("next-headline");
// Without folds, visible == source. Visible line 2 is `** A1`.
let line = cursor_line(&mut state);
assert_eq!(
line, 2,
"next-headline must land on `** A1` (visible line 2); got {line}"
);
}
#[test]
fn outline_parent_headline_walks_up_one_level() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Walk down to `** A1`'s line.
state
.lua_host
.eval(
Some("walk-to-A1"),
r"
pmacs.editor.move_down()
pmacs.editor.move_down()
",
)
.expect("walk down");
let pre_line = cursor_line(&mut state);
assert_eq!(pre_line, 2, "must be on `** A1` line; got {pre_line}");
state
.lua_host
.eval(
Some("nav-parent"),
r#"pmacs.command.invoke("pmacs-outline.parent-headline")"#,
)
.expect("parent-headline");
let line = cursor_line(&mut state);
assert_eq!(line, 0, "parent of A1 is A on visible line 0; got {line}");
}
#[test]
fn outline_fold_subtree_command_collapses_subtree() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Cursor at `* A` (line 0). Run fold-subtree.
state
.lua_host
.eval(
Some("fold-subtree-cmd"),
r#"pmacs.command.invoke("pmacs-outline.fold-subtree")"#,
)
.expect("fold-subtree");
let vis = visible_text(&mut state);
assert!(
vis.contains("* A\n ...\n* B\n"),
"fold-subtree must collapse A's subtree to marker; got: {vis:?}"
);
}
// ---------------------------------------------------------------------------
// Pass-3 regressions: navigation skips folded entries; structural-edit
// invalidation; fold state survives byte-shift edits.
// ---------------------------------------------------------------------------
#[test]
fn outline_next_headline_skips_hidden_descendants_of_folded_subtree() {
// Pass-3 finding 1. With A folded, next-headline from `* A` must
// land on `* B` (the next *visible* headline), not on hidden A1.
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Fold A.
state
.lua_host
.eval(
Some("fold-A"),
r"
require('pmacs-outline').toggle_fold(H, 0)
",
)
.expect("fold A");
// Cursor at the visible buffer's first line (`* A`). Run next-headline.
state
.lua_host
.eval(
Some("nav-next-from-folded-A"),
r#"pmacs.command.invoke("pmacs-outline.next-headline")"#,
)
.expect("next");
// Visible buffer with A folded is "* A\n ...\n* B\nbody-B\n".
// `* B` is on visible line 2.
let line = cursor_line(&mut state);
assert_eq!(
line, 2,
"next-headline must skip hidden A1/A2 and land on `* B` (visible line 2); \
got line {line}"
);
}
#[test]
fn outline_delete_newline_before_headline_invalidates_merged_headline() {
// Pass-3 finding 2. Deleting the `\n` between two adjacent
// headlines merges their lines: `* A\n* B\n` becomes `* A* B\n`.
// The cache must reflect that there's now one headline (with
// weird title), not two stale ones at the old byte_starts.
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, "* A\n* B\n");
let n_before: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("before");
assert_eq!(n_before, 2);
// Delete the `\n` at byte 3 (between `* A` and `* B`).
state
.lua_host
.eval(Some("delete-nl"), r"SRC:delete(3, 4)")
.expect("delete nl");
let entries_n: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("after");
assert_eq!(
entries_n, 1,
"merged headlines must produce exactly one cached entry; got {entries_n}"
);
let title: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].title")
.eval()
.expect("title");
assert_eq!(
title, "A* B",
"the merged headline's title must reflect the merged line text; got {title:?}"
);
let byte_start: i64 = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].byte_start")
.eval()
.expect("byte_start");
assert_eq!(
byte_start, 0,
"merged headline starts at byte 0; got {byte_start}"
);
}
#[test]
fn outline_replace_newline_before_headline_invalidates_merged_headline() {
// Same shape as the delete case but via replace. Replacing the
// `\n` with `X` similarly merges the lines.
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, "* A\n* B\n");
state
.lua_host
.eval(Some("replace-nl"), r#"SRC:replace(3, 4, "X")"#)
.expect("replace nl");
let entries_n: i64 = state
.lua_host
.lua()
.load(r"return #PARSER.entries(PH)")
.eval()
.expect("after");
assert_eq!(
entries_n, 1,
"merged headlines must produce one cached entry; got {entries_n}"
);
let title: String = state
.lua_host
.lua()
.load(r"return PARSER.entries(PH)[1].title")
.eval()
.expect("title");
assert_eq!(
title, "AX* B",
"merged title must reflect new bytes; got {title:?}"
);
}
#[test]
fn outline_fold_state_survives_insert_before_folded_entry() {
// Pass-3 finding 3. Fold an entry, then insert text before it
// in the source. After repaint, the entry must still be folded.
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, NAV_TEXT);
// Fold B (which is at byte_start = source byte 25 originally:
// "* A\nbody-A\n** A1\nbody-A1\n** A2\nbody-A2\n* B\nbody-B\n"
// 4 + 7 + 6 + 8 + 6 + 8 = 39).
let b_byte: i64 = state
.lua_host
.lua()
.load(
r#"
local entries = PARSER.entries(PH)
for _, e in ipairs(entries) do
if e.title == "B" then return e.byte_start end
end
return -1
"#,
)
.eval()
.expect("find B");
assert!(b_byte > 0, "must locate B; got {b_byte}");
state
.lua_host
.eval(
Some("fold-B"),
&format!("require('pmacs-outline').toggle_fold(H, {b_byte})"),
)
.expect("fold B");
// Confirm B's body is hidden in the projection.
let vis_before = visible_text(&mut state);
assert!(
!vis_before.contains("body-B"),
"before insertion, B's body should be hidden; got: {vis_before:?}"
);
// Insert content at byte 0 of source (well before B).
state
.lua_host
.eval(
Some("insert-before-B"),
r#"SRC:insert(0, "* New top\nstuff\n")"#,
)
.expect("insert");
// Force a repaint via the test seam.
state
.lua_host
.eval(
Some("force-repaint"),
r#"
local te = require("pmacs-outline").__pmacs_outline_test_seam_DO_NOT_USE
te.repaint(H)
"#,
)
.expect("repaint");
let vis_after = visible_text(&mut state);
assert!(
vis_after.contains("* New top\n"),
"new top headline must appear; got: {vis_after:?}"
);
assert!(
!vis_after.contains("body-B"),
"B's fold must survive the prefix insertion; got: {vis_after:?}"
);
assert!(
vis_after.contains("...\n"),
"fold marker must still be present; got: {vis_after:?}"
);
}
// ---------------------------------------------------------------------------
// Earlier regressions (kept).
// ---------------------------------------------------------------------------
#[test]
fn outline_close_removes_intercepts_and_drops_visible() {
let (mut state, _c, _u) = editor_with_outline();
open_outline_with(&mut state, SAMPLE_TEXT);
state
.lua_host
.eval(
Some("close"),
r#"
require("pmacs-outline").close(H)
_G.SRC_AFTER_CLOSE_VALID = SRC:is_valid()
"#,
)
.expect("close");
// Source remains valid (caller-owned).
let src_valid: bool = state
.lua_host
.lua()
.globals()
.get("SRC_AFTER_CLOSE_VALID")
.expect("src valid");
assert!(src_valid, "close() must leave the source buffer alive");
// Edits to source after close should not panic and should not bump
// parse counter (parser intercept removed).
let before = parse_count(&mut state);
state
.lua_host
.eval(Some("edit-after-close"), r#"SRC:insert(0, "Z")"#)
.expect("edit after close");
let after = parse_count(&mut state);
assert_eq!(
after, before,
"after close, parser must not run any new parse_region calls"
);
}
#[test]
fn outline_parser_unit_parses_org_subset() {
let (mut state, _c, _u) = editor_with_outline();
state
.lua_host
.eval(
Some("parser-unit-bootstrap"),
r#"
local outline = require("pmacs-outline")
local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
_G.PR = te.parser.__pmacs_outline_test_parse_region
"#,
)
.expect("bootstrap");
let level: i64 = state
.lua_host
.lua()
.load(
r#"
local entries = PR("* Hello :tag:\n:PROPERTIES:\n:K: v\n:END:\n", 0)
return entries[1].level
"#,
)
.eval()
.expect("level");
assert_eq!(level, 1);
let title: String = state
.lua_host
.lua()
.load(r#"return PR("* Hello :tag:\n", 0)[1].title"#)
.eval()
.expect("title");
assert_eq!(title, "Hello");
let n_tags: i64 = state
.lua_host
.lua()
.load(r#"return #PR("* Hello :tag1:tag2:\n", 0)[1].tags"#)
.eval()
.expect("tags");
assert_eq!(n_tags, 2);
}