1046 lines
31 KiB
Rust
1046 lines
31 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)
|
|
}
|
|
|
|
/// 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);
|
|
}
|