5e9b5d3cfb
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>
275 lines
12 KiB
Rust
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: ¬escraft_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(())
|
|
}
|