Files
Marcos Paulo 5e9b5d3cfb
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: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes
Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:31:52 -03:00

275 lines
12 KiB
Rust

//! Integration tests of math rendering (`SPEC` item 30): inline sizing, the
//! error fallback, the layout cache and the note view wiring, run headless.
use std::rc::Rc;
use egui::{Context, RawInput, Rect, Shape};
use notescraft_markdown::parse;
use notescraft_themes::{Colors, Fonts, GraphColors, Radii, Rgba, SyntaxColors, Theme, Variant};
use notescraft_ui_egui::note_view::CalloutFolds;
use notescraft_ui_egui::{fonts, math_view, theme::Tokens};
/// A compact dark test theme.
fn tokens() -> Tokens {
let c = Rgba::new(64, 64, 64, 255);
Tokens::new(Theme {
id: String::from("test"),
name: String::from("Test"),
variant: Variant::Dark,
colors: Colors {
background: c,
surface: Rgba::new(40, 40, 40, 255),
surface_2: Rgba::new(56, 56, 56, 255),
canvas: Rgba::new(24, 24, 24, 255),
border: c,
text: Rgba::new(230, 230, 230, 255),
text_muted: Rgba::new(150, 150, 150, 255),
accent: Rgba::new(180, 120, 250, 255),
on_accent: Rgba::new(20, 20, 20, 255),
link: Rgba::new(120, 160, 250, 255),
link_unresolved: Rgba::new(250, 120, 130, 255),
selection: Rgba::new(80, 80, 90, 255),
code_background: Rgba::new(20, 20, 26, 255),
highlight: Rgba::new(240, 220, 160, 100),
success: Rgba::new(120, 200, 120, 255),
warning: Rgba::new(220, 180, 100, 255),
danger: Rgba::new(230, 110, 110, 255),
},
callouts: std::collections::BTreeMap::new(),
graph: GraphColors { node: c, node_active: c, edge: c, unresolved: c },
syntax: SyntaxColors::uniform(c),
radii: Radii { small: 4.0, medium: 6.0 },
fonts: Fonts { ui: String::new(), editor: String::new(), mono: String::new() },
})
}
/// The painted text shapes of one frame: `(text, x, y)`.
fn text_labels(out: &egui::FullOutput) -> Vec<(String, f32, f32)> {
out.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(text) => Some((text.galley.text().to_owned(), text.pos.x, text.pos.y)),
_ => None,
})
.collect()
}
/// Runs `frame` twice on a 900x700 screen (the first pass warms up the fonts
/// and the caches) and returns the text labels of the second pass.
fn run_labels<T: Clone + 'static>(frame: impl Fn(&mut egui::Ui, T) + Clone + 'static, arg: T) -> Vec<(String, f32, f32)> {
let ctx = Context::default();
fonts::install(&ctx);
let input = || RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))), ..RawInput::default() };
let mut warm = ctx.run_ui(input(), |ui| frame(ui, arg.clone()));
warm.textures_delta.clear();
let mut second = ctx.run_ui(input(), |ui| frame(ui, arg.clone()));
second.textures_delta.clear();
text_labels(&second)
}
#[test]
fn inline_math_maps_the_italic_variable() {
let t = Rc::new(tokens());
let labels = run_labels(
|ui, t| {
let _ = math_view::show_inline(ui, &t, "x^2", 16.0);
},
Rc::clone(&t),
);
assert!(labels.iter().any(|(text, _, _)| text.contains('\u{1D465}')), "italic x is mapped to the math alphanumeric block: {labels:?}");
}
#[test]
fn error_fallback_shows_the_source_verbatim() {
let t = Rc::new(tokens());
let labels = run_labels(
|ui, t| {
let _ = math_view::show_inline(ui, &t, "\\frac{1}{2", 16.0);
},
Rc::clone(&t),
);
assert!(labels.iter().any(|(text, _, _)| text == "\\frac{1}{2"), "the source is shown as text: {labels:?}");
}
#[test]
fn the_same_source_hits_the_cache_within_a_frame() {
let t = Rc::new(tokens());
let labels = run_labels(
|ui, t| {
let _ = math_view::show_inline(ui, &t, "\\int_0^1 x\\,dx", 16.0);
},
Rc::clone(&t),
);
assert!(labels.iter().any(|(text, _, _)| text.contains('\u{222B}')), "the integral glyph is painted: {labels:?}");
}
#[test]
fn note_view_renders_an_inline_math_paragraph() {
let t = Rc::new(tokens());
let doc = parse("Energy: $x^2$ heat\n");
let labels = run_labels(
|ui, (t, doc)| {
notescraft_ui_egui::note_view::show_note(ui, &t, &doc, &|_: &str| true, &mut CalloutFolds::default());
},
(Rc::clone(&t), doc),
);
assert!(labels.iter().any(|(text, _, _)| text.contains('\u{1D465}')), "the math piece of the paragraph is TeX-rendered: {labels:?}");
assert!(labels.iter().any(|(text, _, _)| text.contains("Energy: ")), "the words before the math flow as text: {labels:?}");
assert!(labels.iter().any(|(text, _, _)| text.contains("heat")), "the words after the math flow as text: {labels:?}");
}
#[test]
fn display_math_is_centred() {
let t = Rc::new(tokens());
let doc = parse("$$\\frac{a}{b}$$\n");
let labels = run_labels(
|ui, (t, doc)| {
notescraft_ui_egui::note_view::show_note(ui, &t, &doc, &|_: &str| true, &mut CalloutFolds::default());
},
(Rc::clone(&t), doc),
);
let left = labels.iter().map(|(_, x, _)| *x).fold(f32::INFINITY, f32::min);
let right = labels.iter().map(|(_, x, _)| *x).fold(f32::NEG_INFINITY, f32::max);
assert!(left > 100.0, "the formula starts away from the left edge of the 900-pt card: {left}");
assert!(right < 800.0, "the formula does not run to the right edge: {right}");
}
#[test]
fn malformed_delimited_math_reaches_error_renderer() {
// Delimiters, not TeX validity, define math nodes: the second pass finds `$..$` bodies the
// CommonMark scanner gave up on, so the unbalanced body reaches the error renderer too.
let source = r"Invalid $\foo$ end and $\frac{1}{$ stays text";
let doc = parse(source);
let math: Vec<_> = doc.items.iter().filter(|item| matches!(item.kind, notescraft_markdown::ItemKind::Math { .. })).collect();
assert_eq!(math.len(), 2, "delimiters, not TeX validity, define math nodes: {doc:?}");
let bad = doc.source.get(math[1].range.clone()).map_or(String::new(), ToOwned::to_owned);
assert_eq!(bad, r"$\frac{1}{$", "the unbalanced formula is a math item");
let labels = run_labels(
|ui, (tokens, doc)| {
notescraft_ui_egui::note_view::show_note(ui, &tokens, &doc, &|_: &str| true, &mut CalloutFolds::default());
},
(Rc::new(tokens()), doc),
);
assert!(labels.iter().any(|(text, _, _)| text == r"\foo"), "{labels:?}");
assert!(labels.iter().any(|(text, _, _)| text == r"\frac{1}{"), "the unbalanced body reaches the error renderer: {labels:?}");
}
#[test]
fn display_operators_grow_and_side_scripts_clear_their_ink() {
let ctx = Context::default();
fonts::install(&ctx);
let mut output = ctx.run_ui(RawInput::default(), |ui| {
for source in [r"\sum_{i=1}^n", r"\int_0^\infty", r"\prod_k^n"] {
let inline = math_view::math_box(ui.ctx(), source, 16.0, false);
let display = math_view::math_box(ui.ctx(), source, 16.0, true);
assert!(inline.is_ok() && display.is_ok());
let (Ok(inline), Ok(display)) = (inline, display) else { continue };
let glyph = |math: &notescraft_math::MathBox| {
math.items.iter().find_map(|item| match item {
notescraft_math::Item::Glyphs { size, .. } => Some(*size),
_ => None,
})
};
assert!(matches!((glyph(&display), glyph(&inline)), (Some(big), Some(small)) if big >= small * 1.35), "{source}: {display:?}");
}
});
output.textures_delta.clear();
}
#[test]
fn inline_math_adds_at_most_two_points_to_line_height() {
let t = Rc::new(tokens());
let following_y = |source: &str| {
let labels = run_labels(
|ui, (tokens, doc)| {
notescraft_ui_egui::note_view::show_note(ui, &tokens, &doc, &|_: &str| true, &mut CalloutFolds::default());
},
(Rc::clone(&t), parse(source)),
);
labels.iter().find_map(|(text, _, y)| text.contains("Following").then_some(*y))
};
let plain = following_y("Words and more words.\n\nFollowing\n");
for formula in [r"E=mc^2", r"\sum_{i=1}^n i^2", r"\alpha_1^2 + \beta"] {
let math = following_y(&format!("Words ${formula}$ and more words.\n\nFollowing\n"));
assert!(matches!((plain, math), (Some(plain), Some(math)) if math <= plain + 2.0), "{formula}: {plain:?} -> {math:?}");
}
}
#[test]
fn math_ink_uses_theme_text_and_display_padding_at_both_scales() -> Result<(), String> {
for theme in ["catppuccin-mocha", "catppuccin-latte"] {
let tokens = Tokens::new(notescraft_themes::builtin(theme).ok_or("missing theme")?);
for scale in [1.0, 2.0] {
let ctx = Context::default();
fonts::install(&ctx);
ctx.set_pixels_per_point(scale);
let mut padded_height = 0.0;
let mut ink_height = 0.0;
let draw = |ui: &mut egui::Ui, padded: &mut f32, ink: &mut f32| {
if let Ok(math) = math_view::math_box(ui.ctx(), r"\int_0^\infty x", 16.0, true) {
*ink = math.height + math.depth;
}
*padded = math_view::show_display(ui, &tokens, r"\int_0^\infty x", 16.0).rect.height();
};
let mut warm = ctx.run_ui(RawInput::default(), |ui| draw(ui, &mut padded_height, &mut ink_height));
warm.textures_delta.clear();
let mut output = ctx.run_ui(RawInput::default(), |ui| draw(ui, &mut padded_height, &mut ink_height));
output.textures_delta.clear();
assert!((padded_height - ink_height - 16.0).abs() < 0.1);
let glyphs: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let colour = notescraft_ui_egui::theme::color(tokens.theme.colors.text);
assert!(glyphs.iter().all(|text| text.galley.job.sections.iter().all(|section| section.format.color == colour)));
let integral = glyphs.iter().find(|text| text.galley.text() == "∫").ok_or("integral missing")?;
let infinity = glyphs.iter().find(|text| text.galley.text() == "∞").ok_or("infinity missing")?;
let ink_rect = |text: &egui::epaint::TextShape| text.galley.mesh_bounds.translate(text.pos.to_vec2());
assert!(!ink_rect(integral).intersects(ink_rect(infinity)), "{theme}/{scale}: integral/infinity collide");
}
}
Ok(())
}
#[test]
fn invalid_math_is_danger_monospace_with_parser_tooltip() -> Result<(), String> {
let tokens = tokens();
let ctx = Context::default();
fonts::install(&ctx);
ctx.all_styles_mut(|style| {
style.interaction.tooltip_delay = 0.0;
style.interaction.show_tooltips_only_when_still = false;
});
let mut hit = Rect::ZERO;
let mut warm = ctx.run_ui(RawInput::default(), |ui| {
hit = math_view::show_inline(ui, &tokens, r"\foo", 16.0).rect;
});
warm.textures_delta.clear();
let mut output = ctx.run_ui(RawInput { events: vec![egui::Event::PointerMoved(hit.center())], ..Default::default() }, |ui| {
let _ = math_view::show_inline(ui, &tokens, r"\foo", 16.0);
});
output.textures_delta.clear();
output = ctx.run_ui(RawInput { time: Some(1.0), events: vec![egui::Event::PointerMoved(hit.center())], ..Default::default() }, |ui| {
let _ = math_view::show_inline(ui, &tokens, r"\foo", 16.0);
});
output.textures_delta.clear();
let source = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.text() == r"\foo" => Some(text),
_ => None,
})
.ok_or("error source missing")?;
// An unknown command is one danger-coloured atom inside the formula (the rest of a formula still draws).
assert!(source.galley.job.sections.iter().all(|section| section.format.color == notescraft_ui_egui::theme::color(tokens.theme.colors.danger)));
let message = notescraft_math::parse(r"\foo").err().ok_or("parser accepted unknown command")?.to_string();
assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Text(text) if text.galley.text().contains(&message))), "{:?}", text_labels(&output));
Ok(())
}