Files
Marcos Paulo 6ecbeadcff
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Source-mode heading folding, graph timelapse, workspaces, heading and block links
Source view: chevrons fold the section under a heading (laid out at a vanishing size, one buffer).
Graph: timelapse by file creation time with play/pause and a slider. Workspaces: save and load the tabs,
active tab, pins, panel sizes and sidebar tab by name (engine workspace.* commands, files in
.notescraft/workspaces). Copy a heading or block link (adds a ^id once), Outline context menu.
Round 4 tasks 31-35 added to specs/obsidian-basics/tasks.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:22:19 -03:00

175 lines
7.4 KiB
Rust

//! Highlight tests: deterministic adversarial coverage invariant, fixture
//! colours per kind, and the oversized-document fallback.
use notescraft_editor::MAX_HIGHLIGHT_BYTES;
use notescraft_editor::colors::EditorColors;
use notescraft_editor::highlight::layout_job;
fn resolver(target: &str) -> bool {
!target.contains("missing")
}
/// Tiny deterministic xorshift generator (no rand dependency).
struct Rng(u32);
impl Rng {
fn next(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
}
#[test]
fn coverage_invariant_on_random_adversarial_strings() {
const ALPHABET: [&str; 15] = ["[", "]", "!", "#", "^", "=", "%", "$", ">", "-", "*", "`", "```", "é", "a"];
let colors = EditorColors::fallback_dark();
let mut rng = Rng(0x5EED_1234);
for case in 0..200 {
let len = (rng.next() % 120) as usize + 1;
let mut text = String::new();
for _ in 0..len {
let pick = (rng.next() % 15) as usize;
let Some(chunk) = ALPHABET.get(pick) else { continue };
text.push_str(chunk);
}
let job = layout_job(&text, &colors, 16.0, f32::INFINITY, &resolver);
assert_eq!(job.text, text, "case {case}: job text must be the source");
// Sections are ordered, contiguous and cover the whole string.
let mut next = 0usize;
for section in &job.sections {
assert_eq!(section.byte_range.start.0, next, "case {case}: gap at {next}");
next = section.byte_range.end.0;
}
assert_eq!(next, text.len(), "case {case}: coverage incomplete");
}
}
#[test]
fn fixture_colours_every_kind() {
let colors = EditorColors::fallback_dark();
let text = "---\ntags: [x]\n---\n\
# Big one\n\
body [[Existing]] [[missing-note]] #tag ==hi== `code` %%c%%\n\
- [ ] task\n\
> [!info] Title\n\
```rust\nfence\n```\n\
[link](dest.md) ^blockid";
let job = layout_job(text, &colors, 16.0, 600.0, &resolver);
let color_at = |needle: &str| -> egui::Color32 {
let start = text.find(needle).unwrap();
job.format_at_byte(egui::text::ByteIndex(start)).color
};
let bg_at = |needle: &str| -> egui::Color32 {
let start = text.find(needle).unwrap();
job.format_at_byte(egui::text::ByteIndex(start)).background
};
let size_at = |needle: &str| -> f32 {
let start = text.find(needle).unwrap();
job.format_at_byte(egui::text::ByteIndex(start)).font_id.size
};
assert_eq!(color_at("---"), colors.muted, "frontmatter muted");
assert_eq!(size_at("# Big one"), 32.0, "h1 is double the base size");
assert_eq!(color_at("# Big one"), colors.heading);
assert_eq!(color_at("[[Existing]]"), colors.link);
assert_eq!(color_at("[[missing-note]]"), colors.link_unresolved);
assert_eq!(color_at("#tag"), colors.tag);
assert_eq!(bg_at("==hi=="), colors.highlight);
assert_eq!(bg_at("`code`"), colors.code_background);
assert_eq!(color_at("`code`"), colors.code);
assert_eq!(color_at("%%c%%"), colors.muted);
assert_eq!(color_at("[ ] task"), colors.accent, "task checkbox accent");
assert_eq!(color_at("[!info]"), colors.accent, "callout header accent");
assert_eq!(bg_at("fence"), colors.code_background, "fence body monospace bg");
assert_eq!(color_at("[link](dest.md)"), colors.link, "internal-ish link via resolver");
assert_eq!(color_at("^blockid"), colors.muted);
}
#[test]
fn oversized_text_falls_back_to_plain() {
let colors = EditorColors::fallback_dark();
let text = "# heading\n".repeat((3 * 1024 * 1024) / 10 + 1);
assert!(text.len() > MAX_HIGHLIGHT_BYTES, "fixture must exceed the parse cutoff");
let job = layout_job(&text, &colors, 16.0, f32::INFINITY, &resolver);
assert_eq!(job.sections.len(), 1);
assert_eq!(job.text.len(), text.len());
}
#[test]
fn empty_and_tiny_inputs() {
let colors = EditorColors::fallback_dark();
let job = layout_job("", &colors, 16.0, 100.0, &resolver);
assert_eq!(job.text, "");
let job = layout_job("word", &colors, 16.0, 100.0, &resolver);
assert_eq!(job.text, "word");
}
#[test]
fn never_panics_on_degenerate_input() {
let colors = EditorColors::fallback_dark();
let nasty = ["[[[[]]]]", "## ## ", "```", "===", "`_`~", "*a*b*c*", "> [!", "[!^#-"];
for text in nasty {
let job = layout_job(text, &colors, 16.0, 100.0, &resolver);
assert_eq!(job.text, text);
}
}
#[test]
fn fence_tokens_take_the_theme_syntax_colours_and_cover_the_text() {
use std::time::{Duration, Instant};
// Token colours unlike every other colour, so a match means "painted as a token".
let colors = EditorColors { syntax: [0u8, 1, 2, 3, 4, 5, 6, 7].map(|n| egui::Color32::from_rgb(1, 2, 200 + n)), ..EditorColors::fallback_dark() };
let text = "# T\n\n```rust\nfn main() { let s = \"x\"; } // c\n```\n\nafter `code` here\n";
let until = Instant::now() + Duration::from_secs(10);
let job = loop {
let job = layout_job(text, &colors, 14.0, 400.0, &resolver);
if job.sections.iter().any(|s| colors.syntax.contains(&s.format.color)) || Instant::now() > until {
break job;
}
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(job.text, text, "the job covers the whole text");
let mut end = 0;
for section in &job.sections {
assert_eq!(section.byte_range.start.0, end, "sections are contiguous");
end = section.byte_range.end.0;
}
assert_eq!(end, text.len());
let coloured: Vec<&str> =
job.sections.iter().filter(|s| colors.syntax.contains(&s.format.color)).map(|s| &text[s.byte_range.start.0..s.byte_range.end.0]).collect();
assert!(coloured.contains(&"fn"), "{coloured:?}");
assert!(coloured.iter().any(|t| t.contains('"')), "{coloured:?}");
assert!(
job.sections.iter().all(|s| s.format.background != egui::Color32::TRANSPARENT || !text[s.byte_range.start.0..s.byte_range.end.0].contains("fn")),
"tokens keep the code background"
);
}
#[test]
fn hostile_fences_do_not_panic() {
let colors = EditorColors::fallback_dark();
let long = format!("```rust\n{}\n```\n", "x".repeat(50_000));
for text in ["```rust\n", "```rust\n`\n```json\n{", "```\u{0}rust\n\u{0}\n```", long.as_str(), "~~~py\nprint('é')\n~~~\n```js\n"] {
let job = layout_job(text, &colors, 14.0, 400.0, &resolver);
assert_eq!(job.text, text);
}
}
#[test]
fn hidden_ranges_keep_every_byte_in_the_job_and_shrink_only_those_bytes() {
let colors = notescraft_editor::colors::EditorColors::fallback_dark();
let text = "# One\nbody one\n## Two\nbody two\n";
let hidden = notescraft_editor::fold::hidden_ranges(text, &["## Two".to_owned()].into());
assert_eq!(hidden.len(), 1);
let job = notescraft_editor::highlight::layout_job_folded(text, &colors, 16.0, 400.0, &|_| true, &hidden);
assert_eq!(job.text, text, "no byte is added or lost");
let tiny: usize =
job.sections.iter().filter(|section| section.format.font_id.size < 1.0).map(|section| section.byte_range.end.0 - section.byte_range.start.0).sum();
assert_eq!(tiny, hidden.first().map_or(0, |range| range.end - range.start), "exactly the folded bytes are tiny");
let plain = notescraft_editor::highlight::layout_job(text, &colors, 16.0, 400.0, &|_| true);
assert!(plain.sections.iter().all(|section| section.format.font_id.size >= 1.0));
}