From cb661c00517b032cee6be71edfe7992960f18bfd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:46 +0000 Subject: [PATCH] refactor(ui-egui): move theme tests to src/theme/tests.rs --- crates/ui-egui/src/theme.rs | 361 +----------------------------- crates/ui-egui/src/theme/tests.rs | 358 +++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 360 deletions(-) create mode 100644 crates/ui-egui/src/theme/tests.rs diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 478b8b86b..28dc465e2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1267,363 +1267,4 @@ mod contrast_tests { } #[cfg(all(test, not(target_arch = "wasm32")))] -mod tests { - use super::*; - use crate::i18n::Language; - - #[test] - /// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. - fn fallback_faces_are_appended_to_every_family() { - // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain - // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. - for han in [HanOrder::Chinese, HanOrder::Japanese] { - let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; - let fonts = font_definitions_for_language(language); - let fb_keys: Vec = - fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); - if fb_keys.is_empty() { - return; // no fallback face on this machine (minimal Linux) - } - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); - // The optional craft-fonts faces follow the system fallbacks at the very end. - let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); - assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); - assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); - } - } - } - - #[test] - /// Every font family ends with exactly the permitted craft faces in manifest order. - fn craft_fonts_are_the_last_fallback_in_every_family() { - // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. - // A craft face must never displace a system face that already covers a glyph. - let fonts = font_definitions_for_language(Language::En); - let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); - if expected.is_empty() { - return; // optional input absent - } - for (family, chain) in &fonts.families { - let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); - assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); - } - } - - #[test] - /// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. - fn a_required_native_build_embeds_the_chinese_face() { - if cfg!(target_arch = "wasm32") || !craft_fonts_required() { - return; - } - assert!( - chinese_font_loaded(), - "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" - ); - let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); - assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); - } - - #[test] - /// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. - fn barred_faces_never_enter_the_font_set() { - for (family, _, _) in craft_font_scripts() { - assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); - assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); - } - } - - #[test] - /// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. - fn the_required_craft_faces_keep_their_exact_scripts() { - if !craft_fonts_loaded() { - assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); - return; - } - let faces: Vec<_> = craft_font_scripts().collect(); - for (family, style, scripts) in [ - ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), - ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), - ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), - ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), - ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), - ] { - assert!( - faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), - "{family} {style} with {scripts:?} must be embedded; got {faces:?}" - ); - } - } - - #[test] - /// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. - fn available_text_maps_unbundled_chrome_symbols() { - // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is - // never rewritten: this helper is only used for interface tooltips. - let ctx = egui::Context::default(); - ctx.set_fonts(FontDefinitions::default()); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); - }); - out.textures_delta.clear(); - } - - #[test] - fn interface_families_fall_back_to_the_cell_font() { - // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, - // so each interface chain carries the cell face, ahead of the script fallback faces. - let fonts = font_definitions_for_language(Language::En); - let cell = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell) { - return; // no system cell font on this machine - } - let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); - for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { - let chain = fonts.families.get(&family).cloned().unwrap_or_default(); - let at = chain.iter().position(|k| *k == cell); - assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); - let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); - assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); - } - } - - #[test] - fn language_reorders_only_the_han_faces() { - let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!( - keys(HanOrder::Japanese), - ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!( - keys(HanOrder::Chinese), - ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { - assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); - } - } - - #[test] - fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { - // Exercise the candidates the fallback loader actually consumes, not just the directory - // constant. This must work on CI without an installed CJK font or a Flatpak runtime. - for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { - for directory in [ - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - ] { - let path = format!("{directory}/NotoSansCJK-Regular.ttc"); - assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); - } - } - let chinese = cjk_han_simplified(); - assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); - assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); - } - - #[test] - /// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. - fn fallback_faces_load_each_file_once() { - // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded - // once, not once per script (tens of MB each). - for language in [Language::En, Language::Ja] { - let fonts = font_definitions_for_language(language); - let present: Vec<&Arc> = - fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); - for (i, a) in present.iter().enumerate() { - for b in present.iter().skip(i + 1) { - assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); - } - } - } - } - - #[test] - /// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. - fn fallback_covers_its_script() { - // Real glyph check, per script: the proportional family (what cells and the UI use) must - // have a glyph for every non-space character in the sample. A script runs when a system - // face or the optional craft-fonts input provides one; the printed skip is a recorded - // gate, and a present face that is not wired in fails. - let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ - ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), - // Chinese uses a system Han face or the native-only permitted LXGW craft face; the - // barred Noto CJK collection never supplies it. - ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), - ("sys-cjk-hangul", cjk_hangul(), "한글"), - ("sys-thai", thai(), "ทดสอบ"), - ("sys-lao", lao(), "ພາສາລາວ"), - ("sys-hebrew", hebrew(), "עברית"), - ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), - ("craft-noto-sans-georgian", Vec::new(), "ქართული"), - ("ui", candidates(UI), "Русский"), - // Czech interface labels: every Czech accented letter, in both cases. - ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), - ]; - for language in [Language::En, Language::Ja] { - let ctx = egui::Context::default(); - ctx.set_fonts(font_definitions_for_language(language)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { - let font = FontId::new(14.0, family.clone()); - for (key, cands, sample) in &cases { - // A script runs when a system face or the optional craft-fonts input provides - // one. The skip is printed: a missing fixture is a recorded gate, not a pass. - let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); - if !system && !craft_fonts_cover(sample) { - eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); - continue; - } - let missing: String = - sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); - assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); - } - } - } - } - - #[test] - fn chromeos_font_layout_is_probed() { - // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per - // family set; a script whose list misses it renders as tofu with the font installed. - let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); - let cros = "/usr/share/fonts/chromeos"; - assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); - assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); - assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); - assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); - assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); - } - - #[test] - /// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. - fn unparseable_font_files_are_rejected_not_fatal() { - // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of - // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - assert!(font_parses(&real, 0), "egui's own font must parse"); - assert!(!font_parses(b"", 0)); - assert!(!font_parses(&[0xAB; 64], 0)); - assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); - assert!(!font_parses(&real, 3), "a single-face file has no face 3"); - - // A bad face that slipped into the definitions is dropped (key and family entries), and the - // context builds its fonts without panicking. - let mut fonts = font_definitions(); - fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); - for chain in fonts.families.values_mut() { - chain.push("sys-hebrew".into()); - } - drop_unparseable_fonts(&mut fonts); - assert!(!fonts.font_data.contains_key("sys-hebrew")); - assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); - let ctx = egui::Context::default(); - ctx.set_fonts(fonts); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.label("Hebrew עברית"); - }); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// A broken existing font candidate is skipped in favor of a later readable candidate. - fn try_load_skips_a_broken_candidate() { - // A candidate that exists but is not a readable font must not win over a good one behind it. - let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let bad = dir.join("broken.ttf"); - let good = dir.join("good.ttf"); - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); - let _ = std::fs::write(&good, &real); - let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); - assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); - let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); - assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. - fn interface_fonts_are_read_once_and_shared_with_egui() { - // egui copies owned font bytes into its text engine and keeps the original too; borrowed - // bytes it shares. A rebuild of the font set reuses the file read the first time. - let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); - std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); - let p = path.to_string_lossy().into_owned(); - let first = try_load(&[(p.as_str(), 0)]).unwrap(); - std::fs::remove_file(&path).unwrap(); - let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); - assert!( - std::sync::Arc::ptr_eq(&first.font, &second.font), - "interface font bytes must be one shared blob, so egui shares them instead of copying" - ); - assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); - assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); - } - - #[test] - fn macos_bold_interface_face_is_drawn_unhinted() { - // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a - // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every - // other app draws it; the regular face and the other platforms keep egui's default. - let fonts = font_definitions_for_language(Language::En); - let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); - if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { - assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); - } - if let Some(regular) = hinting(&format!("sys-{UI}")) { - assert_eq!(regular, None, "only the bold face is affected"); - } - } - - #[test] - fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { - // #1694: Fedora keeps each font family in its own package folder, so without these entries - // no cell face is found there and bold and italic cells draw as regular text. Each comes - // after the existing Linux folders for the same font, so wherever one of those matched, - // nothing changes. - let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); - let cases: [(&str, Vec, &str, &str); 9] = [ - (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), - (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), - (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), - (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), - (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), - ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), - ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), - ]; - for (role, list, fedora_file, earlier) in &cases { - let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); - let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); - assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); - assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); - assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); - } - // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. - let mono = paths(candidates(MONO)); - let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); - let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); - assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); - let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); - assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs new file mode 100644 index 000000000..15ee29090 --- /dev/null +++ b/crates/ui-egui/src/theme/tests.rs @@ -0,0 +1,358 @@ +use super::*; +use crate::i18n::Language; + +#[test] +/// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. +fn fallback_faces_are_appended_to_every_family() { + // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain + // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. + for han in [HanOrder::Chinese, HanOrder::Japanese] { + let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; + let fonts = font_definitions_for_language(language); + let fb_keys: Vec = + fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + if fb_keys.is_empty() { + return; // no fallback face on this machine (minimal Linux) + } + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); + // The optional craft-fonts faces follow the system fallbacks at the very end. + let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); + assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); + assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); + } + } +} + +#[test] +/// Every font family ends with exactly the permitted craft faces in manifest order. +fn craft_fonts_are_the_last_fallback_in_every_family() { + // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. + // A craft face must never displace a system face that already covers a glyph. + let fonts = font_definitions_for_language(Language::En); + let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); + if expected.is_empty() { + return; // optional input absent + } + for (family, chain) in &fonts.families { + let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); + assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); + } +} + +#[test] +/// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. +fn a_required_native_build_embeds_the_chinese_face() { + if cfg!(target_arch = "wasm32") || !craft_fonts_required() { + return; + } + assert!( + chinese_font_loaded(), + "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" + ); + let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); + assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); +} + +#[test] +/// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. +fn barred_faces_never_enter_the_font_set() { + for (family, _, _) in craft_font_scripts() { + assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); + assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); + } +} + +#[test] +/// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. +fn the_required_craft_faces_keep_their_exact_scripts() { + if !craft_fonts_loaded() { + assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); + return; + } + let faces: Vec<_> = craft_font_scripts().collect(); + for (family, style, scripts) in [ + ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), + ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), + ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), + ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), + ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), + ] { + assert!( + faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), + "{family} {style} with {scripts:?} must be embedded; got {faces:?}" + ); + } +} + +#[test] +/// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. +fn available_text_maps_unbundled_chrome_symbols() { + // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is + // never rewritten: this helper is only used for interface tooltips. + let ctx = egui::Context::default(); + ctx.set_fonts(FontDefinitions::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); + }); + out.textures_delta.clear(); +} + +#[test] +fn interface_families_fall_back_to_the_cell_font() { + // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, + // so each interface chain carries the cell face, ahead of the script fallback faces. + let fonts = font_definitions_for_language(Language::En); + let cell = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell) { + return; // no system cell font on this machine + } + let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); + for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { + let chain = fonts.families.get(&family).cloned().unwrap_or_default(); + let at = chain.iter().position(|k| *k == cell); + assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); + let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); + assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); + } +} + +#[test] +fn language_reorders_only_the_han_faces() { + let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { + assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); + } +} + +#[test] +fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { + // Exercise the candidates the fallback loader actually consumes, not just the directory + // constant. This must work on CI without an installed CJK font or a Flatpak runtime. + for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { + for directory in [ + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + ] { + let path = format!("{directory}/NotoSansCJK-Regular.ttc"); + assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); + } + } + let chinese = cjk_han_simplified(); + assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); + assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); +} + +#[test] +/// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. +fn fallback_faces_load_each_file_once() { + // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded + // once, not once per script (tens of MB each). + for language in [Language::En, Language::Ja] { + let fonts = font_definitions_for_language(language); + let present: Vec<&Arc> = + fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + for (i, a) in present.iter().enumerate() { + for b in present.iter().skip(i + 1) { + assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); + } + } + } +} + +#[test] +/// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. +fn fallback_covers_its_script() { + // Real glyph check, per script: the proportional family (what cells and the UI use) must + // have a glyph for every non-space character in the sample. A script runs when a system + // face or the optional craft-fonts input provides one; the printed skip is a recorded + // gate, and a present face that is not wired in fails. + let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ + ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), + // Chinese uses a system Han face or the native-only permitted LXGW craft face; the + // barred Noto CJK collection never supplies it. + ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), + ("sys-cjk-hangul", cjk_hangul(), "한글"), + ("sys-thai", thai(), "ทดสอบ"), + ("sys-lao", lao(), "ພາສາລາວ"), + ("sys-hebrew", hebrew(), "עברית"), + ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), + ("craft-noto-sans-georgian", Vec::new(), "ქართული"), + ("ui", candidates(UI), "Русский"), + // Czech interface labels: every Czech accented letter, in both cases. + ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), + ]; + for language in [Language::En, Language::Ja] { + let ctx = egui::Context::default(); + ctx.set_fonts(font_definitions_for_language(language)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { + let font = FontId::new(14.0, family.clone()); + for (key, cands, sample) in &cases { + // A script runs when a system face or the optional craft-fonts input provides + // one. The skip is printed: a missing fixture is a recorded gate, not a pass. + let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); + if !system && !craft_fonts_cover(sample) { + eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); + continue; + } + let missing: String = + sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); + assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); + } + } + } +} + +#[test] +fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); +} + +#[test] +/// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. +fn unparseable_font_files_are_rejected_not_fatal() { + // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of + // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + assert!(font_parses(&real, 0), "egui's own font must parse"); + assert!(!font_parses(b"", 0)); + assert!(!font_parses(&[0xAB; 64], 0)); + assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); + assert!(!font_parses(&real, 3), "a single-face file has no face 3"); + + // A bad face that slipped into the definitions is dropped (key and family entries), and the + // context builds its fonts without panicking. + let mut fonts = font_definitions(); + fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); + for chain in fonts.families.values_mut() { + chain.push("sys-hebrew".into()); + } + drop_unparseable_fonts(&mut fonts); + assert!(!fonts.font_data.contains_key("sys-hebrew")); + assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("Hebrew עברית"); + }); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// A broken existing font candidate is skipped in favor of a later readable candidate. +fn try_load_skips_a_broken_candidate() { + // A candidate that exists but is not a readable font must not win over a good one behind it. + let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let bad = dir.join("broken.ttf"); + let good = dir.join("good.ttf"); + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); + let _ = std::fs::write(&good, &real); + let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); + assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); + let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); + assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. +fn interface_fonts_are_read_once_and_shared_with_egui() { + // egui copies owned font bytes into its text engine and keeps the original too; borrowed + // bytes it shares. A rebuild of the font set reuses the file read the first time. + let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let p = path.to_string_lossy().into_owned(); + let first = try_load(&[(p.as_str(), 0)]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); + assert!( + std::sync::Arc::ptr_eq(&first.font, &second.font), + "interface font bytes must be one shared blob, so egui shares them instead of copying" + ); + assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); + assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); +} + +#[test] +fn macos_bold_interface_face_is_drawn_unhinted() { + // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a + // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every + // other app draws it; the regular face and the other platforms keep egui's default. + let fonts = font_definitions_for_language(Language::En); + let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); + if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { + assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); + } + if let Some(regular) = hinting(&format!("sys-{UI}")) { + assert_eq!(regular, None, "only the bold face is affected"); + } +} + +#[test] +fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { + // #1694: Fedora keeps each font family in its own package folder, so without these entries + // no cell face is found there and bold and italic cells draw as regular text. Each comes + // after the existing Linux folders for the same font, so wherever one of those matched, + // nothing changes. + let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); + let cases: [(&str, Vec, &str, &str); 9] = [ + (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), + (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), + (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), + (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), + (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), + ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), + ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), + ]; + for (role, list, fedora_file, earlier) in &cases { + let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); + let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); + assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); + assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); + assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); + } + // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. + let mono = paths(candidates(MONO)); + let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); + let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); + assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); + let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); + assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); +}