mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
refactor(ui-egui): move previews tests to previews/tests.rs
This commit is contained in:
@@ -674,388 +674,4 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #848: each trip through the Font box loaded every family it showed, for good. Entries are
|
||||
/// now drawn with a face read for the preview alone: installed families stay unloaded.
|
||||
#[test]
|
||||
fn font_menu_entries_leave_installed_families_unloaded() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let ink = |img: &egui::ColorImage| img.pixels.iter().filter(|p| p.r() < 128).count();
|
||||
assert!(ink(&font_entry_image("Source Sans 3", 1.0)) > 20, "the bundled face draws");
|
||||
assert!(ink(&font_entry_image("No Such Font 848", 1.0)) > 20, "an unknown font shows in the default face");
|
||||
// Tests running alongside share this database and may load a fallback (a math font for
|
||||
// an equation, a font for a script) at any time: leave the math fonts out, and allow a
|
||||
// stray load or two. Before #848 every family previewed was loaded.
|
||||
let shared = |f: &str| wordcraft_fonts::math::MATH_SUBSTITUTES.iter().any(|n| f.eq_ignore_ascii_case(n));
|
||||
let unloaded: Vec<String> = db.families().into_iter().filter(|f| !db.is_loaded(f) && !shared(f)).take(25).collect();
|
||||
let mut loaded = Vec::new();
|
||||
for f in &unloaded {
|
||||
let img = font_entry_image(f, 1.0);
|
||||
assert_eq!(img.size, [244, 22]);
|
||||
if db.is_loaded(f) {
|
||||
loaded.push(f.clone());
|
||||
}
|
||||
}
|
||||
assert!(loaded.len() <= 2, "previewing loaded {loaded:?}");
|
||||
}
|
||||
|
||||
/// A window whose Styles gallery lists `style` first: runs the gallery for a few frames and
|
||||
/// returns the painted shapes of the last one and every texture picture uploaded on the way.
|
||||
fn run_gallery(mut doc: Document, style: wordcraft_doc::styles::Style) -> (Vec<egui::epaint::ClippedShape>, Vec<egui::ColorImage>) {
|
||||
// Only the style under test is in the gallery.
|
||||
for other in &mut doc.styles.styles {
|
||||
other.quick = false;
|
||||
}
|
||||
doc.styles.upsert(style);
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
|
||||
app.ui.language = "en".into();
|
||||
let ctx = egui::Context::default();
|
||||
let mut shapes = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
for frame in 0..6 {
|
||||
let input = egui::RawInput {
|
||||
time: Some(f64::from(frame) * 0.1),
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 200.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
ui.add_space(20.0);
|
||||
style_gallery(&mut app, ui, &json!({}));
|
||||
});
|
||||
for (_, deltas) in out.textures_delta.set.drain() {
|
||||
for delta in deltas {
|
||||
if let (None, egui::ImageData::Color(img)) = (delta.pos, &delta.image) {
|
||||
images.push(egui::ColorImage::clone(img));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
shapes = out.shapes;
|
||||
}
|
||||
(shapes, images)
|
||||
}
|
||||
|
||||
fn gallery_style(name: &str) -> wordcraft_doc::styles::Style {
|
||||
wordcraft_doc::styles::Style { id: "Backfill".into(), name: name.into(), quick: true, priority: Some(0), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Regression test for #2: a style gallery label longer than its tile was painted at full width
|
||||
/// and ran into the neighbouring tiles; it is now cut with an ellipsis to fit the tile.
|
||||
#[test]
|
||||
fn a_long_style_name_is_truncated_to_its_tile() {
|
||||
let name = "An extraordinarily long custom style name that cannot fit a tile";
|
||||
let (shapes, _) = run_gallery(Document::new(), gallery_style(name));
|
||||
let label = shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) if t.galley.job.text == name => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the long style's label is painted");
|
||||
// A tile is 74 points wide.
|
||||
assert!(label.galley.size().x <= 74.0, "the label is {} points wide, wider than its 74-point tile", label.galley.size().x);
|
||||
assert!(label.galley.elided, "the label is cut short with an ellipsis");
|
||||
}
|
||||
|
||||
/// Regression test for #2: a style whose hanging indent is larger than its left indent previewed
|
||||
/// with its first line starting off the tile's left edge; the first line now starts at the edge.
|
||||
#[test]
|
||||
fn a_hanging_indent_style_previews_from_the_tile_edge() {
|
||||
let mut style = gallery_style("Hanging");
|
||||
style.para.indent_left = Some(0.0);
|
||||
style.para.indent_first = Some(-36.0);
|
||||
let (_, images) = run_gallery(Document::new(), style);
|
||||
// The style is the only one in the gallery: its preview is the picture of a tile's size (the other upload is egui's font texture).
|
||||
let preview = images.iter().find(|i| (200..300).contains(&i.size[0]) && (20..60).contains(&i.size[1])).expect("the preview is rendered");
|
||||
let [w, h] = preview.size;
|
||||
let inked: Vec<bool> = (0..w).map(|x| (0..h).any(|y| preview.pixels.get(y * w + x).is_some_and(|p| p.a() > 0 && p.r() < 160))).collect();
|
||||
let first = inked.iter().position(|&i| i).expect("the preview has text");
|
||||
let last = inked.iter().rposition(|&i| i).unwrap_or(first);
|
||||
// "AaBbCcDd" begins at the left margin (1 point) and is whole: it is about 60 points wide.
|
||||
assert!(first < 6, "the first line starts off the tile's left edge (ink from column {first})");
|
||||
assert!(last - first > 40, "the first line is cut off by the tile's left edge (ink {first}..{last})");
|
||||
}
|
||||
|
||||
/// #663: gallery previews are dimmed in the dark theme, unless they show Switch Modes' dark
|
||||
/// page; the light theme keeps a white page.
|
||||
#[test]
|
||||
fn previews_are_dimmed_in_the_dark_theme() {
|
||||
let (light, dark) = (crate::theme::Tokens::light(), crate::theme::Tokens::dark());
|
||||
assert_eq!(super::preview_tint(&light, false), egui::Color32::WHITE);
|
||||
assert_eq!(super::preview_tint(&dark, true), egui::Color32::WHITE, "the dark page is already dark");
|
||||
let dim = super::preview_tint(&dark, false);
|
||||
assert!(dim.r() < 0xE0 && dim.r() > 0xA0, "{dim:?}");
|
||||
}
|
||||
|
||||
/// Font-menu previews defer over budget, render on a later frame, and reuse cached entries
|
||||
/// without consuming budget or deadlocking.
|
||||
#[test]
|
||||
fn font_menu_previews_render_defer_and_reuse_without_deadlocking() {
|
||||
// A nested Context lock used to hang the first uncached menu entry. Bound the wait so
|
||||
// a regression fails the test instead of hanging the entire test suite.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let ctx = egui::Context::default();
|
||||
let preview = Previews::default().font_preview_fn();
|
||||
let budget_id = egui::Id::unique("font_preview_budget");
|
||||
let sans_id = egui::Id::unique("font:Source Sans 3");
|
||||
let inter_id = egui::Id::unique("font:Inter");
|
||||
ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32)));
|
||||
|
||||
for time in [1.0, 2.0, 3.0] {
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
preview(ui, "Source Sans 3");
|
||||
preview(ui, "Inter");
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
ctx.data(|d| {
|
||||
assert!(d.get_temp::<TextureHandle>(sans_id).is_some());
|
||||
if time == 1.0 {
|
||||
assert!(d.get_temp::<TextureHandle>(inter_id).is_none(), "over-budget entry should defer");
|
||||
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((1.0, 13)));
|
||||
} else {
|
||||
assert!(d.get_temp::<TextureHandle>(inter_id).is_some(), "deferred entry should render next frame");
|
||||
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((2.0, 1)), "cached entries should not consume budget");
|
||||
}
|
||||
});
|
||||
}
|
||||
tx.send(()).unwrap();
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("font menu preview rendering hung or failed");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
/// #817: equation pictures were black on white; they are now an ink mask (white, transparent paper).
|
||||
#[test]
|
||||
fn math_pictures_are_ink_masks() {
|
||||
use wordcraft_doc::math::parse_linear;
|
||||
let img = math_image(&parse_linear("x/y"), false, 12.0, 40.0, 34.0, 1.0).expect("renders");
|
||||
assert!(img.pixels.iter().any(|p| p.a() == 0), "paper is transparent");
|
||||
assert!(img.pixels.iter().any(|p| p.a() > 0), "ink is kept");
|
||||
assert!(img.pixels.iter().all(|p| *p == egui::Color32::from_white_alpha(p.a())), "ink is white, to be tinted");
|
||||
}
|
||||
|
||||
/// Every picture the Equation tab and Insert › Equation draw: (key, nodes, display, pt, tile size).
|
||||
fn equation_pictures() -> Vec<(String, wordcraft_doc::math::Arg, bool, f32, egui::Vec2)> {
|
||||
use wordcraft_doc::math::{MNode, MRun, parse_linear};
|
||||
use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS};
|
||||
let mut out = Vec::new();
|
||||
for g in STRUCTURES {
|
||||
let pic = crate::equation_tab::button_picture(g.id);
|
||||
out.push((format!("btn:{pic}"), parse_linear(pic), false, 12.0, vec2(40.0, 32.0)));
|
||||
for (_, templates) in g.sections {
|
||||
for t in templates.iter() {
|
||||
out.push((format!("tpl:{}", t.id), t.nodes(), true, 14.0, vec2(56.0, 52.0)));
|
||||
out.push((format!("tpl:{}", t.id), t.nodes(), true, 13.0, vec2(154.0, 56.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, _, lin) in BUILT_INS {
|
||||
out.push((format!("builtin:{id}"), parse_linear(lin), true, 11.0, vec2(346.0, 60.0)));
|
||||
}
|
||||
for (_, chars) in SYMBOL_SETS {
|
||||
for c in chars.chars() {
|
||||
let nodes = vec![MNode::Run(MRun { text: c.to_string(), lit: true, ..Default::default() })];
|
||||
out.push((format!("sym:{c}"), nodes, false, 11.0, vec2(22.0, 22.0)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #1002: drawn the way the Equation tab draws them (the picture tinted with the theme's text
|
||||
/// colour on an input-coloured tile), every gallery tile, button and symbol is the theme's
|
||||
/// greys only: its background is the tile colour and its ink no darker or lighter than the
|
||||
/// text, in light and dark mode, with no red or teal anywhere.
|
||||
#[test]
|
||||
fn equation_pictures_follow_the_theme() {
|
||||
let mix = |fg: u8, bg: u8, a: u8| ((u32::from(fg) * u32::from(a) + u32::from(bg) * (255 - u32::from(a))) / 255) as u8;
|
||||
for (key, nodes, display, pt, size) in equation_pictures() {
|
||||
let img = math_image(&nodes, display, pt, size.x, size.y, 1.0).unwrap_or_else(|| panic!("{key} renders"));
|
||||
assert!(img.pixels.iter().all(|p| *p == egui::Color32::from_white_alpha(p.a())), "{key}: an ink mask");
|
||||
// Ink may reach the corner (a matrix bracket with Windows fonts): look for paper anywhere.
|
||||
assert!(img.pixels.iter().any(|p| p.a() == 0), "{key}: transparent paper");
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
let (bg, fg) = (t.input, t.text);
|
||||
let (lo, hi) = (bg.r().min(fg.r()), bg.r().max(fg.r()));
|
||||
for p in &img.pixels {
|
||||
let a = p.a();
|
||||
let c = [mix(fg.r(), bg.r(), a), mix(fg.g(), bg.g(), a), mix(fg.b(), bg.b(), a)];
|
||||
assert!(c[0] == c[1] && c[1] == c[2], "{key}: a grey, not {c:?}");
|
||||
assert!((lo..=hi).contains(&c[0]), "{key}: between the tile and the text colour, not {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1002: the pictures share one texture, written in place: browsing every gallery creates no
|
||||
/// texture per tile and frees none (hundreds of small textures, freed in bulk, showed other
|
||||
/// textures' memory on DirectX 12).
|
||||
#[test]
|
||||
fn equation_pictures_share_one_texture() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
let pictures = equation_pictures();
|
||||
// Symbols repeat across sets (∀, ≠…): each is drawn once.
|
||||
let unique = pictures.iter().map(|(key, _, _, pt, size)| format!("{key}:{pt}:{size:?}")).collect::<std::collections::HashSet<_>>().len();
|
||||
let mut drawn: HashMap<String, (egui::TextureId, Rect)> = HashMap::new();
|
||||
let mut freed = 0;
|
||||
for _ in 0..200 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
for (key, nodes, display, pt, size) in &pictures {
|
||||
let k = format!("{key}:{pt}:{size:?}");
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = drawn.entry(k)
|
||||
&& let Some(t) = math_texture(&mut app, &ctx, key, nodes, *display, *pt, *size)
|
||||
{
|
||||
e.insert(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
freed += out.textures_delta.free.len();
|
||||
out.textures_delta.clear();
|
||||
if drawn.len() == unique {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(drawn.len(), unique, "every picture is drawn");
|
||||
let ids: std::collections::HashSet<_> = drawn.values().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids.len(), 1, "one texture for every picture");
|
||||
assert_eq!(freed, 0, "no texture freed while browsing");
|
||||
// No two pictures overlap, and each lies inside the texture.
|
||||
let rects: Vec<Rect> = drawn.values().map(|(_, uv)| *uv).collect();
|
||||
for (i, a) in rects.iter().enumerate() {
|
||||
assert!(a.min.x >= 0.0 && a.min.y >= 0.0 && a.max.x <= 1.0 && a.max.y <= 1.0, "{a:?} inside");
|
||||
for b in rects.iter().skip(i + 1) {
|
||||
assert!(!a.intersects(*b) || a.intersect(*b).area() <= 0.0, "{a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1002: pictures are packed row by row, and pictures that can never fit are refused.
|
||||
#[test]
|
||||
fn math_atlas_packs_rows() {
|
||||
let mut a = AtlasPage::default();
|
||||
assert_eq!(a.place(100, 40), Some([0, 0]));
|
||||
assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0]));
|
||||
assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest");
|
||||
assert_eq!(a.place(MATH_ATLAS + 1, 10), None);
|
||||
assert_eq!(a.place(10, 0), None);
|
||||
assert_eq!(a.place(10, MATH_ATLAS), None, "full");
|
||||
assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal");
|
||||
}
|
||||
|
||||
/// #1002: a page is never wiped in a frame that handed out one of its pictures (they are
|
||||
/// already on screen); once all pages are full, the least recently drawn page starts over.
|
||||
#[test]
|
||||
fn math_atlas_never_wipes_a_page_drawn_from_this_frame() {
|
||||
let ctx = egui::Context::default();
|
||||
let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let mut a = MathAtlas::default();
|
||||
let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect();
|
||||
assert!(first.iter().all(Option::is_some), "a page each");
|
||||
let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids.len(), MATH_ATLAS_PAGES);
|
||||
assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame");
|
||||
assert_eq!(a.wipes, 0);
|
||||
assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten");
|
||||
// Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1.
|
||||
assert_eq!(a.get(2, "p0"), first.first().copied().flatten());
|
||||
let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over");
|
||||
assert_eq!(a.wipes, 1);
|
||||
assert_eq!(Some(id), first.get(1).copied().flatten().map(|(id, _)| id), "the least recently drawn page");
|
||||
assert!(a.uv.contains_key("p0") && !a.uv.contains_key("p1"));
|
||||
// Pages 0 and 1 are now in use this frame; 2 and 3 are free to start over, then none.
|
||||
assert!(a.add(ui.ctx(), 2, "x", img()).is_some());
|
||||
assert!(a.add(ui.ctx(), 2, "y", img()).is_some());
|
||||
assert!(a.add(ui.ctx(), 2, "z", img()).is_none());
|
||||
assert_eq!(a.wipes, 3);
|
||||
assert!(a.get(2, "p0").is_some() && a.get(2, "more").is_some() && a.get(2, "x").is_some() && a.get(2, "y").is_some());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// #1002: at a display scale of 3, the 22 built-in equations need more than one page; the
|
||||
/// built-in menu opened again draws them all from the atlas, with nothing wiped or moved
|
||||
/// (a single page used to start over every frame: flicker and constant re-rendering).
|
||||
#[test]
|
||||
fn builtin_menu_at_scale_3_fits_without_wiping() {
|
||||
use wordcraft_doc::math::parse_linear;
|
||||
use wordcraft_engine::math_gallery::BUILT_INS;
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_pixels_per_point(3.0);
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
let builtins: Vec<_> = BUILT_INS.iter().map(|(id, _, lin)| (format!("builtin:{id}"), parse_linear(lin))).collect();
|
||||
let draw = |app: &mut WordApp| {
|
||||
let mut got = Vec::new();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert_eq!(ui.ctx().pixels_per_point(), 3.0);
|
||||
let ctx = ui.ctx().clone();
|
||||
got = builtins.iter().map(|(key, nodes)| math_texture(app, &ctx, key, nodes, true, 11.0, vec2(346.0, 60.0))).collect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
got
|
||||
};
|
||||
// The first opening renders a few a frame.
|
||||
let mut first = draw(&mut app);
|
||||
for _ in 0..20 {
|
||||
if first.iter().all(Option::is_some) {
|
||||
break;
|
||||
}
|
||||
first = draw(&mut app);
|
||||
}
|
||||
assert!(first.iter().all(Option::is_some), "every built-in drawn");
|
||||
let wipes = app.previews.math.wipes;
|
||||
let again = draw(&mut app);
|
||||
assert_eq!(again, first, "the same texture and place for each");
|
||||
assert_eq!(app.previews.math.wipes, wipes, "nothing started over");
|
||||
assert_eq!(wipes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_mask_keeps_the_ink_and_drops_the_paper() {
|
||||
let mut img = egui::ColorImage::new([3, 1], vec![egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::from_gray(128)]);
|
||||
ink_mask(&mut img);
|
||||
assert_eq!(img.pixels[0].a(), 0, "paper becomes transparent");
|
||||
assert_eq!(img.pixels[1], egui::Color32::from_white_alpha(255), "full ink stays opaque");
|
||||
assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage");
|
||||
}
|
||||
|
||||
/// #535: font menu entries drew on a white strip in dark mode. They are now tinted with the
|
||||
/// theme's text colour and draw no background of their own.
|
||||
#[test]
|
||||
fn font_menu_entries_take_the_theme_text_colour() {
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::System);
|
||||
ctx.set_theme(theme);
|
||||
let preview = Previews::default().font_preview_fn();
|
||||
let mut out = None;
|
||||
for time in [1.0, 2.0] {
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
let mut o = ctx.run_ui(input, |ui| {
|
||||
preview(ui, "Source Sans 3");
|
||||
});
|
||||
o.textures_delta.clear();
|
||||
out = Some(o);
|
||||
}
|
||||
let want = if theme == egui::Theme::Dark { Tokens::dark().text } else { Tokens::light().text };
|
||||
let shapes = out.map(|o| o.shapes).unwrap_or_default();
|
||||
let mut images = 0;
|
||||
for s in &shapes {
|
||||
match &s.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => {
|
||||
images += 1;
|
||||
assert!(m.vertices.iter().all(|v| v.color == want), "{theme:?}: the entry is not tinted with the text colour");
|
||||
}
|
||||
egui::Shape::Rect(r) => panic!("{theme:?}: an idle entry paints no background, got {:?}", r.fill),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(images, 1, "{theme:?}: the entry draws its rendered name");
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
use super::*;
|
||||
|
||||
/// #848: each trip through the Font box loaded every family it showed, for good. Entries are
|
||||
/// now drawn with a face read for the preview alone: installed families stay unloaded.
|
||||
#[test]
|
||||
fn font_menu_entries_leave_installed_families_unloaded() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let ink = |img: &egui::ColorImage| img.pixels.iter().filter(|p| p.r() < 128).count();
|
||||
assert!(ink(&font_entry_image("Source Sans 3", 1.0)) > 20, "the bundled face draws");
|
||||
assert!(ink(&font_entry_image("No Such Font 848", 1.0)) > 20, "an unknown font shows in the default face");
|
||||
// Tests running alongside share this database and may load a fallback (a math font for
|
||||
// an equation, a font for a script) at any time: leave the math fonts out, and allow a
|
||||
// stray load or two. Before #848 every family previewed was loaded.
|
||||
let shared = |f: &str| wordcraft_fonts::math::MATH_SUBSTITUTES.iter().any(|n| f.eq_ignore_ascii_case(n));
|
||||
let unloaded: Vec<String> = db.families().into_iter().filter(|f| !db.is_loaded(f) && !shared(f)).take(25).collect();
|
||||
let mut loaded = Vec::new();
|
||||
for f in &unloaded {
|
||||
let img = font_entry_image(f, 1.0);
|
||||
assert_eq!(img.size, [244, 22]);
|
||||
if db.is_loaded(f) {
|
||||
loaded.push(f.clone());
|
||||
}
|
||||
}
|
||||
assert!(loaded.len() <= 2, "previewing loaded {loaded:?}");
|
||||
}
|
||||
|
||||
/// A window whose Styles gallery lists `style` first: runs the gallery for a few frames and
|
||||
/// returns the painted shapes of the last one and every texture picture uploaded on the way.
|
||||
fn run_gallery(mut doc: Document, style: wordcraft_doc::styles::Style) -> (Vec<egui::epaint::ClippedShape>, Vec<egui::ColorImage>) {
|
||||
// Only the style under test is in the gallery.
|
||||
for other in &mut doc.styles.styles {
|
||||
other.quick = false;
|
||||
}
|
||||
doc.styles.upsert(style);
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
|
||||
app.ui.language = "en".into();
|
||||
let ctx = egui::Context::default();
|
||||
let mut shapes = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
for frame in 0..6 {
|
||||
let input = egui::RawInput {
|
||||
time: Some(f64::from(frame) * 0.1),
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 200.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
ui.add_space(20.0);
|
||||
style_gallery(&mut app, ui, &json!({}));
|
||||
});
|
||||
for (_, deltas) in out.textures_delta.set.drain() {
|
||||
for delta in deltas {
|
||||
if let (None, egui::ImageData::Color(img)) = (delta.pos, &delta.image) {
|
||||
images.push(egui::ColorImage::clone(img));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
shapes = out.shapes;
|
||||
}
|
||||
(shapes, images)
|
||||
}
|
||||
|
||||
fn gallery_style(name: &str) -> wordcraft_doc::styles::Style {
|
||||
wordcraft_doc::styles::Style { id: "Backfill".into(), name: name.into(), quick: true, priority: Some(0), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Regression test for #2: a style gallery label longer than its tile was painted at full width
|
||||
/// and ran into the neighbouring tiles; it is now cut with an ellipsis to fit the tile.
|
||||
#[test]
|
||||
fn a_long_style_name_is_truncated_to_its_tile() {
|
||||
let name = "An extraordinarily long custom style name that cannot fit a tile";
|
||||
let (shapes, _) = run_gallery(Document::new(), gallery_style(name));
|
||||
let label = shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) if t.galley.job.text == name => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the long style's label is painted");
|
||||
// A tile is 74 points wide.
|
||||
assert!(label.galley.size().x <= 74.0, "the label is {} points wide, wider than its 74-point tile", label.galley.size().x);
|
||||
assert!(label.galley.elided, "the label is cut short with an ellipsis");
|
||||
}
|
||||
|
||||
/// Regression test for #2: a style whose hanging indent is larger than its left indent previewed
|
||||
/// with its first line starting off the tile's left edge; the first line now starts at the edge.
|
||||
#[test]
|
||||
fn a_hanging_indent_style_previews_from_the_tile_edge() {
|
||||
let mut style = gallery_style("Hanging");
|
||||
style.para.indent_left = Some(0.0);
|
||||
style.para.indent_first = Some(-36.0);
|
||||
let (_, images) = run_gallery(Document::new(), style);
|
||||
// The style is the only one in the gallery: its preview is the picture of a tile's size (the other upload is egui's font texture).
|
||||
let preview = images.iter().find(|i| (200..300).contains(&i.size[0]) && (20..60).contains(&i.size[1])).expect("the preview is rendered");
|
||||
let [w, h] = preview.size;
|
||||
let inked: Vec<bool> = (0..w).map(|x| (0..h).any(|y| preview.pixels.get(y * w + x).is_some_and(|p| p.a() > 0 && p.r() < 160))).collect();
|
||||
let first = inked.iter().position(|&i| i).expect("the preview has text");
|
||||
let last = inked.iter().rposition(|&i| i).unwrap_or(first);
|
||||
// "AaBbCcDd" begins at the left margin (1 point) and is whole: it is about 60 points wide.
|
||||
assert!(first < 6, "the first line starts off the tile's left edge (ink from column {first})");
|
||||
assert!(last - first > 40, "the first line is cut off by the tile's left edge (ink {first}..{last})");
|
||||
}
|
||||
|
||||
/// #663: gallery previews are dimmed in the dark theme, unless they show Switch Modes' dark
|
||||
/// page; the light theme keeps a white page.
|
||||
#[test]
|
||||
fn previews_are_dimmed_in_the_dark_theme() {
|
||||
let (light, dark) = (crate::theme::Tokens::light(), crate::theme::Tokens::dark());
|
||||
assert_eq!(super::preview_tint(&light, false), egui::Color32::WHITE);
|
||||
assert_eq!(super::preview_tint(&dark, true), egui::Color32::WHITE, "the dark page is already dark");
|
||||
let dim = super::preview_tint(&dark, false);
|
||||
assert!(dim.r() < 0xE0 && dim.r() > 0xA0, "{dim:?}");
|
||||
}
|
||||
|
||||
/// Font-menu previews defer over budget, render on a later frame, and reuse cached entries
|
||||
/// without consuming budget or deadlocking.
|
||||
#[test]
|
||||
fn font_menu_previews_render_defer_and_reuse_without_deadlocking() {
|
||||
// A nested Context lock used to hang the first uncached menu entry. Bound the wait so
|
||||
// a regression fails the test instead of hanging the entire test suite.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let ctx = egui::Context::default();
|
||||
let preview = Previews::default().font_preview_fn();
|
||||
let budget_id = egui::Id::unique("font_preview_budget");
|
||||
let sans_id = egui::Id::unique("font:Source Sans 3");
|
||||
let inter_id = egui::Id::unique("font:Inter");
|
||||
ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32)));
|
||||
|
||||
for time in [1.0, 2.0, 3.0] {
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
preview(ui, "Source Sans 3");
|
||||
preview(ui, "Inter");
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
ctx.data(|d| {
|
||||
assert!(d.get_temp::<TextureHandle>(sans_id).is_some());
|
||||
if time == 1.0 {
|
||||
assert!(d.get_temp::<TextureHandle>(inter_id).is_none(), "over-budget entry should defer");
|
||||
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((1.0, 13)));
|
||||
} else {
|
||||
assert!(d.get_temp::<TextureHandle>(inter_id).is_some(), "deferred entry should render next frame");
|
||||
assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((2.0, 1)), "cached entries should not consume budget");
|
||||
}
|
||||
});
|
||||
}
|
||||
tx.send(()).unwrap();
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("font menu preview rendering hung or failed");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
/// #817: equation pictures were black on white; they are now an ink mask (white, transparent paper).
|
||||
#[test]
|
||||
fn math_pictures_are_ink_masks() {
|
||||
use wordcraft_doc::math::parse_linear;
|
||||
let img = math_image(&parse_linear("x/y"), false, 12.0, 40.0, 34.0, 1.0).expect("renders");
|
||||
assert!(img.pixels.iter().any(|p| p.a() == 0), "paper is transparent");
|
||||
assert!(img.pixels.iter().any(|p| p.a() > 0), "ink is kept");
|
||||
assert!(img.pixels.iter().all(|p| *p == egui::Color32::from_white_alpha(p.a())), "ink is white, to be tinted");
|
||||
}
|
||||
|
||||
/// Every picture the Equation tab and Insert › Equation draw: (key, nodes, display, pt, tile size).
|
||||
fn equation_pictures() -> Vec<(String, wordcraft_doc::math::Arg, bool, f32, egui::Vec2)> {
|
||||
use wordcraft_doc::math::{MNode, MRun, parse_linear};
|
||||
use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS};
|
||||
let mut out = Vec::new();
|
||||
for g in STRUCTURES {
|
||||
let pic = crate::equation_tab::button_picture(g.id);
|
||||
out.push((format!("btn:{pic}"), parse_linear(pic), false, 12.0, vec2(40.0, 32.0)));
|
||||
for (_, templates) in g.sections {
|
||||
for t in templates.iter() {
|
||||
out.push((format!("tpl:{}", t.id), t.nodes(), true, 14.0, vec2(56.0, 52.0)));
|
||||
out.push((format!("tpl:{}", t.id), t.nodes(), true, 13.0, vec2(154.0, 56.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, _, lin) in BUILT_INS {
|
||||
out.push((format!("builtin:{id}"), parse_linear(lin), true, 11.0, vec2(346.0, 60.0)));
|
||||
}
|
||||
for (_, chars) in SYMBOL_SETS {
|
||||
for c in chars.chars() {
|
||||
let nodes = vec![MNode::Run(MRun { text: c.to_string(), lit: true, ..Default::default() })];
|
||||
out.push((format!("sym:{c}"), nodes, false, 11.0, vec2(22.0, 22.0)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #1002: drawn the way the Equation tab draws them (the picture tinted with the theme's text
|
||||
/// colour on an input-coloured tile), every gallery tile, button and symbol is the theme's
|
||||
/// greys only: its background is the tile colour and its ink no darker or lighter than the
|
||||
/// text, in light and dark mode, with no red or teal anywhere.
|
||||
#[test]
|
||||
fn equation_pictures_follow_the_theme() {
|
||||
let mix = |fg: u8, bg: u8, a: u8| ((u32::from(fg) * u32::from(a) + u32::from(bg) * (255 - u32::from(a))) / 255) as u8;
|
||||
for (key, nodes, display, pt, size) in equation_pictures() {
|
||||
let img = math_image(&nodes, display, pt, size.x, size.y, 1.0).unwrap_or_else(|| panic!("{key} renders"));
|
||||
assert!(img.pixels.iter().all(|p| *p == egui::Color32::from_white_alpha(p.a())), "{key}: an ink mask");
|
||||
// Ink may reach the corner (a matrix bracket with Windows fonts): look for paper anywhere.
|
||||
assert!(img.pixels.iter().any(|p| p.a() == 0), "{key}: transparent paper");
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
let (bg, fg) = (t.input, t.text);
|
||||
let (lo, hi) = (bg.r().min(fg.r()), bg.r().max(fg.r()));
|
||||
for p in &img.pixels {
|
||||
let a = p.a();
|
||||
let c = [mix(fg.r(), bg.r(), a), mix(fg.g(), bg.g(), a), mix(fg.b(), bg.b(), a)];
|
||||
assert!(c[0] == c[1] && c[1] == c[2], "{key}: a grey, not {c:?}");
|
||||
assert!((lo..=hi).contains(&c[0]), "{key}: between the tile and the text colour, not {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1002: the pictures share one texture, written in place: browsing every gallery creates no
|
||||
/// texture per tile and frees none (hundreds of small textures, freed in bulk, showed other
|
||||
/// textures' memory on DirectX 12).
|
||||
#[test]
|
||||
fn equation_pictures_share_one_texture() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
let pictures = equation_pictures();
|
||||
// Symbols repeat across sets (∀, ≠…): each is drawn once.
|
||||
let unique = pictures.iter().map(|(key, _, _, pt, size)| format!("{key}:{pt}:{size:?}")).collect::<std::collections::HashSet<_>>().len();
|
||||
let mut drawn: HashMap<String, (egui::TextureId, Rect)> = HashMap::new();
|
||||
let mut freed = 0;
|
||||
for _ in 0..200 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
for (key, nodes, display, pt, size) in &pictures {
|
||||
let k = format!("{key}:{pt}:{size:?}");
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = drawn.entry(k)
|
||||
&& let Some(t) = math_texture(&mut app, &ctx, key, nodes, *display, *pt, *size)
|
||||
{
|
||||
e.insert(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
freed += out.textures_delta.free.len();
|
||||
out.textures_delta.clear();
|
||||
if drawn.len() == unique {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(drawn.len(), unique, "every picture is drawn");
|
||||
let ids: std::collections::HashSet<_> = drawn.values().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids.len(), 1, "one texture for every picture");
|
||||
assert_eq!(freed, 0, "no texture freed while browsing");
|
||||
// No two pictures overlap, and each lies inside the texture.
|
||||
let rects: Vec<Rect> = drawn.values().map(|(_, uv)| *uv).collect();
|
||||
for (i, a) in rects.iter().enumerate() {
|
||||
assert!(a.min.x >= 0.0 && a.min.y >= 0.0 && a.max.x <= 1.0 && a.max.y <= 1.0, "{a:?} inside");
|
||||
for b in rects.iter().skip(i + 1) {
|
||||
assert!(!a.intersects(*b) || a.intersect(*b).area() <= 0.0, "{a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1002: pictures are packed row by row, and pictures that can never fit are refused.
|
||||
#[test]
|
||||
fn math_atlas_packs_rows() {
|
||||
let mut a = AtlasPage::default();
|
||||
assert_eq!(a.place(100, 40), Some([0, 0]));
|
||||
assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0]));
|
||||
assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest");
|
||||
assert_eq!(a.place(MATH_ATLAS + 1, 10), None);
|
||||
assert_eq!(a.place(10, 0), None);
|
||||
assert_eq!(a.place(10, MATH_ATLAS), None, "full");
|
||||
assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal");
|
||||
}
|
||||
|
||||
/// #1002: a page is never wiped in a frame that handed out one of its pictures (they are
|
||||
/// already on screen); once all pages are full, the least recently drawn page starts over.
|
||||
#[test]
|
||||
fn math_atlas_never_wipes_a_page_drawn_from_this_frame() {
|
||||
let ctx = egui::Context::default();
|
||||
let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let mut a = MathAtlas::default();
|
||||
let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect();
|
||||
assert!(first.iter().all(Option::is_some), "a page each");
|
||||
let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids.len(), MATH_ATLAS_PAGES);
|
||||
assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame");
|
||||
assert_eq!(a.wipes, 0);
|
||||
assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten");
|
||||
// Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1.
|
||||
assert_eq!(a.get(2, "p0"), first.first().copied().flatten());
|
||||
let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over");
|
||||
assert_eq!(a.wipes, 1);
|
||||
assert_eq!(Some(id), first.get(1).copied().flatten().map(|(id, _)| id), "the least recently drawn page");
|
||||
assert!(a.uv.contains_key("p0") && !a.uv.contains_key("p1"));
|
||||
// Pages 0 and 1 are now in use this frame; 2 and 3 are free to start over, then none.
|
||||
assert!(a.add(ui.ctx(), 2, "x", img()).is_some());
|
||||
assert!(a.add(ui.ctx(), 2, "y", img()).is_some());
|
||||
assert!(a.add(ui.ctx(), 2, "z", img()).is_none());
|
||||
assert_eq!(a.wipes, 3);
|
||||
assert!(a.get(2, "p0").is_some() && a.get(2, "more").is_some() && a.get(2, "x").is_some() && a.get(2, "y").is_some());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// #1002: at a display scale of 3, the 22 built-in equations need more than one page; the
|
||||
/// built-in menu opened again draws them all from the atlas, with nothing wiped or moved
|
||||
/// (a single page used to start over every frame: flicker and constant re-rendering).
|
||||
#[test]
|
||||
fn builtin_menu_at_scale_3_fits_without_wiping() {
|
||||
use wordcraft_doc::math::parse_linear;
|
||||
use wordcraft_engine::math_gallery::BUILT_INS;
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_pixels_per_point(3.0);
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
let builtins: Vec<_> = BUILT_INS.iter().map(|(id, _, lin)| (format!("builtin:{id}"), parse_linear(lin))).collect();
|
||||
let draw = |app: &mut WordApp| {
|
||||
let mut got = Vec::new();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert_eq!(ui.ctx().pixels_per_point(), 3.0);
|
||||
let ctx = ui.ctx().clone();
|
||||
got = builtins.iter().map(|(key, nodes)| math_texture(app, &ctx, key, nodes, true, 11.0, vec2(346.0, 60.0))).collect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
got
|
||||
};
|
||||
// The first opening renders a few a frame.
|
||||
let mut first = draw(&mut app);
|
||||
for _ in 0..20 {
|
||||
if first.iter().all(Option::is_some) {
|
||||
break;
|
||||
}
|
||||
first = draw(&mut app);
|
||||
}
|
||||
assert!(first.iter().all(Option::is_some), "every built-in drawn");
|
||||
let wipes = app.previews.math.wipes;
|
||||
let again = draw(&mut app);
|
||||
assert_eq!(again, first, "the same texture and place for each");
|
||||
assert_eq!(app.previews.math.wipes, wipes, "nothing started over");
|
||||
assert_eq!(wipes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_mask_keeps_the_ink_and_drops_the_paper() {
|
||||
let mut img = egui::ColorImage::new([3, 1], vec![egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::from_gray(128)]);
|
||||
ink_mask(&mut img);
|
||||
assert_eq!(img.pixels[0].a(), 0, "paper becomes transparent");
|
||||
assert_eq!(img.pixels[1], egui::Color32::from_white_alpha(255), "full ink stays opaque");
|
||||
assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage");
|
||||
}
|
||||
|
||||
/// #535: font menu entries drew on a white strip in dark mode. They are now tinted with the
|
||||
/// theme's text colour and draw no background of their own.
|
||||
#[test]
|
||||
fn font_menu_entries_take_the_theme_text_colour() {
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::System);
|
||||
ctx.set_theme(theme);
|
||||
let preview = Previews::default().font_preview_fn();
|
||||
let mut out = None;
|
||||
for time in [1.0, 2.0] {
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
let mut o = ctx.run_ui(input, |ui| {
|
||||
preview(ui, "Source Sans 3");
|
||||
});
|
||||
o.textures_delta.clear();
|
||||
out = Some(o);
|
||||
}
|
||||
let want = if theme == egui::Theme::Dark { Tokens::dark().text } else { Tokens::light().text };
|
||||
let shapes = out.map(|o| o.shapes).unwrap_or_default();
|
||||
let mut images = 0;
|
||||
for s in &shapes {
|
||||
match &s.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => {
|
||||
images += 1;
|
||||
assert!(m.vertices.iter().all(|v| v.color == want), "{theme:?}: the entry is not tinted with the text colour");
|
||||
}
|
||||
egui::Shape::Rect(r) => panic!("{theme:?}: an idle entry paints no background, got {:?}", r.fill),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(images, 1, "{theme:?}: the entry draws its rendered name");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user