Files
Marcos Paulo 59d5182799 feat(backlinks): wave 3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:45:22 -03:00

178 lines
7.5 KiB
Rust

//! Integration tests over a hand-built fixture vault (SPEC 6 item 4, 12).
use std::collections::BTreeMap;
use notescraft_backlinks::{ContentSource, MAX_MENTIONS, MAX_NOTE_BYTES, UnlinkedMention, group_by_note, link_mention, linked_mentions, unlinked_mentions};
use notescraft_index::Index;
/// Paths and bodies of the fixture vault, target note first.
fn fixture() -> Vec<(&'static str, &'static str)> {
vec![
("Plan.md", "---\naliases: [Roadmap]\n---\n# Q1\nbody of the plan\n"),
("todo.md", "todo [[Plan]] now\n"),
("alias.md", "see [[Roadmap]] for it\n"),
("heading.md", "[[Plan#Q1]] check\n"),
("embed.md", "![[Plan]]!\n"),
("marklink.md", "[good Plan label](plan.md)\n"),
("plain.md", "read the Plan today\n"),
("case.md", "the PLAN is here\n"),
("plural.md", "many Plans today\n"),
("code.md", "```\nPlan inside\n```\nspan `Plan` end\n"),
("fm.md", "---\nrelated: Plan\n---\nnothing here\n"),
("Café.md", "unicode title note\n"),
("uni.md", "café and cafés\n"),
("empty.md", "no mentions at all\n"),
]
}
/// The fixture as an index and a `BTreeMap` content source.
fn vault() -> (Index, BTreeMap<String, String>) {
let mut index = Index::new();
let mut map = BTreeMap::new();
for (path, body) in fixture() {
index.update_note(path, body).unwrap();
map.insert(String::from(path), String::from(body));
}
(index, map)
}
#[test]
fn linked_mentions_cover_wikilink_alias_heading_embed_and_markdown_link() {
let (index, map) = vault();
let mentions = linked_mentions(&index, &map, "Plan.md");
assert_eq!(mentions.iter().map(|m| m.from.as_str()).collect::<Vec<_>>(), vec!["alias.md", "embed.md", "heading.md", "marklink.md", "todo.md"]);
assert_eq!(
mentions.iter().map(|m| m.matched.as_str()).collect::<Vec<_>>(),
vec!["[[Roadmap]]", "![[Plan]]", "[[Plan#Q1]]", "[good Plan label](plan.md)", "[[Plan]]"]
);
for mention in &mentions {
assert_eq!(mention.line, 1, "every fixture link sits on line 1");
assert!(mention.snippet.chars().count() <= 200);
let body = map.content(&mention.from).unwrap_or_default();
assert_eq!(body.get(mention.range.clone()), Some(mention.matched.as_str()));
}
}
#[test]
fn linked_mentions_are_grouped_by_note() {
let (index, map) = vault();
let grouped = group_by_note(linked_mentions(&index, &map, "Plan.md"));
assert_eq!(grouped.len(), 5);
assert_eq!(grouped["todo.md"][0].matched, "[[Plan]]");
assert_eq!(grouped.keys().next(), Some(&String::from("alias.md")));
}
#[test]
fn unlinked_mentions_cover_plain_case_and_skip_plural_code_and_frontmatter() {
let (index, map) = vault();
let report = unlinked_mentions(&index, &map, "Plan.md");
assert_eq!(report.skipped_large, 0);
assert!(!report.truncated);
assert_eq!(report.mentions.iter().map(|m| m.from.as_str()).collect::<Vec<_>>(), vec!["case.md", "plain.md"]);
assert_eq!(report.mentions[0].matched, "PLAN");
assert_eq!(report.mentions[0].name, "Plan");
assert_eq!(report.mentions[1].matched, "Plan");
for mention in &report.mentions {
let body = map.content(&mention.from).unwrap_or_default();
assert_eq!(body.get(mention.range.clone()), Some(mention.matched.as_str()));
let at = body.find(&mention.matched).unwrap_or(0);
assert_eq!(mention.line, 1);
assert_eq!(mention.range.start, at);
}
}
#[test]
fn unicode_words_match_case_insensitively_and_reject_grammar() {
let (index, map) = vault();
let report = unlinked_mentions(&index, &map, "Café.md");
assert_eq!(report.mentions.len(), 1, "`cafés` is a different word");
assert_eq!(report.mentions[0].from, "uni.md");
assert_eq!(report.mentions[0].matched, "café");
assert_eq!(report.mentions[0].name, "Café");
}
#[test]
fn link_mention_plain_form_bare_wikilink() {
let (index, map) = vault_with(&[("plain2.md", "read the Plan today\n")]);
let report = unlinked_mentions(&index, &map, "Plan.md");
let Some(mention) = report.mentions.iter().find(|m| m.from == "plain2.md") else { panic!("no mention in plain2.md") };
let updated = link_mention(&map.content("plain2.md").unwrap_or_default(), mention, "Plan").unwrap_or_default();
assert_eq!(updated, "read the [[Plan]] today\n");
}
#[test]
fn link_mention_keepcase_form_is_aliased_wikilink() {
let (index, map) = vault_with(&[("case2.md", "the PLAN is here\n")]);
let report = unlinked_mentions(&index, &map, "Plan.md");
let Some(mention) = report.mentions.iter().find(|m| m.from == "case2.md") else { panic!("no mention in case2.md") };
let updated = link_mention(&map.content("case2.md").unwrap_or_default(), mention, "Plan").unwrap_or_default();
assert_eq!(updated, "the [[Plan|PLAN]] is here\n");
}
#[test]
fn link_mention_rejects_stale_out_of_bounds_and_broken_chars() {
let mention =
UnlinkedMention { from: String::from("a.md"), range: 4..8, line: 1, snippet: String::new(), matched: String::from("Plan"), name: String::from("Plan") };
// the text at the range changed
assert_eq!(
link_mention("read the Roadmap now", &mention, "Plan").err().map(|e| e.to_string()),
Some(String::from("the note changed since the mention was found"))
);
// the range is outside the content
assert!(link_mention("short", &mention, "Plan").is_err(), "range 4..8 does not fit `short`");
// the range splits a char: `é` is two bytes
let unicode = UnlinkedMention { range: 3..4, matched: String::from("é"), ..mention.clone() };
assert!(link_mention("café", &unicode, "Plan").is_err(), "3..4 splits `é`");
// empty title
assert!(link_mention("read the Plan", &mention, "").is_err(), "no title, nothing to link");
}
#[test]
fn large_notes_are_skipped_and_counted() {
let (mut index, mut map) = vault();
let mut body = String::from("Plan mention\n");
while body.len() <= MAX_NOTE_BYTES {
body.push_str("filler filler filler filler\n");
}
body.push_str("Plan again\n");
index.update_note("big.md", &body).unwrap();
map.insert(String::from("big.md"), body);
let report = unlinked_mentions(&index, &map, "Plan.md");
assert_eq!(report.skipped_large, 1);
assert!(!report.mentions.iter().any(|m| m.from == "big.md"), "the large note is not scanned");
}
#[test]
fn the_scan_stops_at_ten_thousand_mentions() {
let mut index = Index::new();
let mut body = String::new();
for _ in 0..12_000 {
body.push_str("Plan ");
}
index.update_note("many.md", &body).unwrap();
index.update_note("Plan.md", "target\n").unwrap();
let mut map = BTreeMap::new();
map.insert(String::from("many.md"), body);
map.insert(String::from("Plan.md"), String::from("target\n"));
let report = unlinked_mentions(&index, &map, "Plan.md");
assert!(report.truncated);
assert_eq!(report.mentions.len(), MAX_MENTIONS);
}
/// Build the fixture with extra notes (indexed and stored) on top.
fn vault_with(extra: &[(&str, &str)]) -> (Index, BTreeMap<String, String>) {
let (mut index, mut map) = vault();
for (path, body) in extra {
index.update_note(path, body).unwrap();
map.insert(String::from(*path), String::from(*body));
}
(index, map)
}
#[test]
fn alias_occurrences_are_found_too() {
let (index, map) = vault_with(&[("roadmap-note.md", "the Roadmap says so\n")]);
let report = unlinked_mentions(&index, &map, "Plan.md");
assert!(report.mentions.iter().any(|m| m.from == "roadmap-note.md" && m.name == "Roadmap"));
}