Files
craft-libs/crates/font-manager/tests/flows.rs
T

463 lines
19 KiB
Rust

//! The main flows against a small index, a font folder made per test and a fake font store.
//! `CRAFT_FONT_MANAGER_SNAPSHOTS=<dir>` also saves screenshots; run that with
//! `-- --test-threads=1`, as rendering on several threads at once can crash older Vulkan loaders.
//! With `CRAFT_FONT_MANAGER_GOOGLE_FONTS=<google/fonts checkout>` the fake store also "downloads"
//! the index's files from it.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use craft_font_manager::craft_font_catalog::Os;
use craft_font_manager::index::Index;
use craft_font_manager::store::{Call, FakeStore, Installer};
use craft_font_manager::{Catalog, FontEnv, Source};
use craft_font_manager::{FontsPanel, Look, Options, Palette, Runner, Settings, Tab, install_interface_fonts};
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::{By, NodeT, Queryable};
const HACK: &str = "ofl/hack/Hack-Regular.ttf";
const UBUNTU: &str = "ufl/ubuntu/Ubuntu-Light.ttf";
fn index() -> Index {
let file = |path: &str, size: usize| {
format!(r#"{{"path":"{path}","size":{size},"sha256":"{}","style":"normal","weight":400,"axes":[],"postscript":[]}}"#, "0a".repeat(32))
};
let family = |name: &str, category: &str, languages: &str, scripts: &str, files: String| {
format!(
r#"{{"family":"{name}","designer":"","categories":["{category}"],"languages":[{languages}],"scripts":[{scripts}],"license":"OFL","license_path":null,"files":[{files}]}}"#
)
};
let json = format!(
r#"{{"schema":1,"version":"1.0","source":"google/fonts","commit":"45e3d5269e4a17d823f6d84470bc084dc5ae46f6","base_url":"https://cdn.jsdelivr.net/gh/google/fonts@45e3d5269e4a17d823f6d84470bc084dc5ae46f6/","fallback_base_url":"https://raw.githubusercontent.com/google/fonts/45e3d5269e4a17d823f6d84470bc084dc5ae46f6/",
"languages":[
{{"id":"ar_Arab","name":"Arabic","autonym":"العربية","script":"Arab","population":1,"historical":false}},
{{"id":"en_Latn","name":"English","autonym":"English","script":"Latn","population":1,"historical":false}},
{{"id":"ja_Jpan","name":"Japanese","autonym":"日本語","script":"Jpan","population":1,"historical":false}}],
"scripts":[{{"id":"Arab","name":"Arabic"}},{{"id":"Jpan","name":"Japanese"}},{{"id":"Latn","name":"Latin"}}],
"families":[{},{},{},{}]}}"#,
family("Amiri", "SERIF", "0,1", r#""Arab","Latn""#, file("ofl/amiri/Amiri-Regular.ttf", 1000)),
family("Hack", "MONOSPACE", "1", r#""Latn""#, file(HACK, epaint_default_fonts::HACK_REGULAR.len())),
family("Noto Sans JP", "SANS_SERIF", "1,2", r#""Jpan","Latn""#, file("ofl/notosansjp/NotoSansJP[wght].ttf", 1000)),
family("Ubuntu", "SANS_SERIF", "1", r#""Latn""#, file(UBUNTU, epaint_default_fonts::UBUNTU_LIGHT.len())),
);
Index::from_json(json.as_bytes()).unwrap()
}
/// A system with Ubuntu Light installed, an empty font store, and a fake store that can "download"
/// Hack and Ubuntu Light.
fn setup(name: &str, settings: Settings) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
setup_with(name, settings, Runner::Inline, |_| {})
}
/// [`setup`] with a job runner and the context set up by `fonts` (the interface fonts and
/// direction), at the launcher's window size less its header and footer.
fn setup_with(
name: &str,
settings: Settings,
runner: Runner,
fonts: impl Fn(&egui::Context) + 'static,
) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("flows").join(name);
let _ = std::fs::remove_dir_all(&root);
let system = root.join("usr/share/fonts");
std::fs::create_dir_all(&system).unwrap();
std::fs::write(system.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap();
let store_dir = root.join("store");
std::fs::create_dir_all(&store_dir).unwrap();
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(store_dir.clone()), ..FontEnv::default() };
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
catalog.rescan();
let files = HashMap::from([
(HACK.to_owned(), epaint_default_fonts::HACK_REGULAR.to_vec()),
(UBUNTU.to_owned(), epaint_default_fonts::UBUNTU_LIGHT.to_vec()),
]);
let store = FakeStore::new(store_dir, files);
let store = Arc::new(match std::env::var_os("CRAFT_FONT_MANAGER_GOOGLE_FONTS") {
Some(checkout) => store.serving(checkout),
None => store,
});
let options = Options { catalog, installer: store.clone(), index: Ok(index()), runner };
let app = FontsPanel::new(options, settings);
let mut harness = Harness::builder()
.with_size([1120.0, 678.0])
// Autonyms such as العربية need fonts beyond egui's; the tests don't depend on the host's.
.allow_missing_glyphs()
.with_context_setup(move |ctx| {
install_interface_fonts(ctx, &[]);
fonts(ctx);
})
.build_ui_state(|ui, app: &mut FontsPanel| app.ui(ui), app);
harness.run_steps(4);
(harness, store)
}
fn settle(h: &mut Harness<'_, FontsPanel>) {
h.run_steps(4);
}
fn click(h: &mut Harness<'_, FontsPanel>, label: &str) {
h.get_all_by_label(label).next().unwrap_or_else(|| panic!("no {label:?}")).click();
settle(h);
}
/// Clicks the first node whose name has every one of `parts` (names with a placeholder are
/// isolated for right-to-left text).
fn click_parts(h: &mut Harness<'_, FontsPanel>, parts: &[&str]) {
let wanted: Vec<String> = parts.iter().map(|p| (*p).to_owned()).collect();
let by = By::new().predicate(move |n| n.label().is_some_and(|l| wanted.iter().all(|p| l.contains(p.as_str()))));
h.get_all(by).next().unwrap_or_else(|| panic!("no {parts:?}")).click();
settle(h);
}
fn click_role(h: &mut Harness<'_, FontsPanel>, role: Role, label: &str) {
h.get_by_role_and_label(role, label).click();
settle(h);
}
fn type_into(h: &mut Harness<'_, FontsPanel>, label: &str, text: &str) {
h.get_by_role_and_label(Role::TextInput, label).focus();
h.step();
h.get_by_role_and_label(Role::TextInput, label).type_text(text);
settle(h);
}
fn allow(h: &mut Harness<'_, FontsPanel>) {
h.state_mut().set_downloads_allowed(true).unwrap();
settle(h);
}
fn installed_tab(h: &mut Harness<'_, FontsPanel>) {
click_role(h, Role::Tab, "Installed");
assert_eq!(h.state().tab(), Tab::Installed);
}
fn has_installed(h: &Harness<'_, FontsPanel>, family: &str) -> bool {
h.state().installed().iter().any(|f| f.name == family)
}
#[test]
fn search_and_filters_narrow_the_catalogue() {
let (mut h, _) = setup("search", Settings::default());
assert_eq!(h.state().tab(), Tab::Catalogue);
assert_eq!(h.state().results(), ["Amiri", "Hack", "Noto Sans JP", "Ubuntu"]);
type_into(&mut h, "Search", "noto");
assert_eq!(h.state().results(), ["Noto Sans JP"]);
type_into(&mut h, "Search", "XYZ");
assert!(h.state().results().is_empty());
assert!(h.query_by_label("No fonts match").is_some());
click(&mut h, "Clear search and filters");
assert_eq!(h.state().results().len(), 4);
click_role(&mut h, Role::RadioButton, "Serif");
assert_eq!(h.state().results(), ["Amiri"]);
click_role(&mut h, Role::RadioButton, "All categories");
// Ubuntu Light is installed on the system.
click_role(&mut h, Role::RadioButton, "Installed");
assert_eq!(h.state().results(), ["Ubuntu"]);
click_role(&mut h, Role::RadioButton, "Not installed");
assert_eq!(h.state().results(), ["Amiri", "Hack", "Noto Sans JP"]);
click(&mut h, "Clear filters");
assert_eq!(h.state().results().len(), 4);
}
#[test]
fn language_filter_finds_languages_by_their_own_name() {
let (mut h, _) = setup("language", Settings::default());
click_role(&mut h, Role::ComboBox, "Language");
type_into(&mut h, "Find a language", "日本");
assert!(h.query_by_label("Arabic · العربية").is_none());
click(&mut h, "Japanese · 日本語");
assert_eq!(h.state().results(), ["Noto Sans JP"]);
}
#[test]
fn install_a_family_then_remove_it_from_its_card() {
let (mut h, store) = setup("install", Settings::default());
assert!(!has_installed(&h, "Hack"));
click(&mut h, "Hack");
assert_eq!(h.state().details_family().as_deref(), Some("Hack"));
click(&mut h, "Install all");
click(&mut h, "Download");
assert_eq!(store.calls(), [Call::Install("Hack".into())]);
assert!(h.state().downloads_allowed());
let hack = h.state().installed().iter().find(|f| f.name == "Hack").cloned().expect("Hack is listed");
assert_eq!(hack.source, Source::Store);
assert!(h.query_by_label("Remove all").is_some());
h.key_press(egui::Key::Escape);
settle(&mut h);
assert!(h.state().details_family().is_none());
installed_tab(&mut h);
click(&mut h, "Remove Hack");
assert_eq!(store.calls().last(), Some(&Call::Remove("Hack".into())));
assert!(!has_installed(&h, "Hack"));
}
#[test]
fn install_and_remove_one_style() {
let (mut h, store) = setup("style", Settings::default());
allow(&mut h);
click(&mut h, "Hack");
click(&mut h, "Install Regular");
assert_eq!(store.calls(), [Call::InstallFile("Hack".into(), HACK.into())]);
assert!(has_installed(&h, "Hack"));
click(&mut h, "Remove Regular");
assert_eq!(store.calls().last(), Some(&Call::Remove("Hack".into())));
assert!(!has_installed(&h, "Hack"));
}
#[test]
fn system_fonts_cannot_be_removed() {
let (mut h, store) = setup("system", Settings::default());
installed_tab(&mut h);
let system = h.state().installed().first().cloned().expect("the system font is listed");
assert_eq!(system.source, Source::System);
assert!(h.query_by_label(&format!("Remove {}", system.name)).is_none());
click(&mut h, &system.name);
let remove = h.get_by_label("Remove all");
assert!(remove.accesskit_node().is_disabled());
remove.click();
settle(&mut h);
assert!(store.calls().is_empty());
}
#[test]
fn nothing_downloads_without_consent() {
let (mut h, store) = setup("consent", Settings::default());
assert!(h.query_by_label("Downloads are off").is_some());
click(&mut h, "Hack");
click(&mut h, "Preview");
assert!(h.query_by_label("Download from Google Fonts?").is_some());
click(&mut h, "Cancel");
click(&mut h, "Install all");
click(&mut h, "Cancel");
assert!(store.calls().is_empty());
assert!(!h.state().downloads_allowed());
assert!(h.query_by_label("Download from Google Fonts?").is_none());
h.key_press(egui::Key::Escape);
settle(&mut h);
// Allowed from the note, a preview downloads without asking and installs nothing.
click_role(&mut h, Role::Button, "Allow downloads");
assert!(h.state().downloads_allowed());
assert!(h.query_by_label("Downloads are off").is_none());
click(&mut h, "Preview Hack");
assert_eq!(store.calls(), [Call::Download("Hack".into())]);
assert!(!has_installed(&h, "Hack"));
}
#[test]
fn a_failed_install_offers_retry() {
let (mut h, store) = setup("retry", Settings::default());
allow(&mut h);
store.fail_next("network unreachable");
click(&mut h, "Install Hack");
assert!(h.query_by_label("Couldn't install").is_some());
assert!(!has_installed(&h, "Hack"));
click(&mut h, "Retry");
assert!(has_installed(&h, "Hack"));
assert!(h.query_by_label("Couldn't install").is_none());
}
#[test]
fn arrow_keys_move_between_cards_and_enter_opens_one() {
let (mut h, _) = setup("keys", Settings::default());
h.get_by_role_and_label(Role::Button, "Amiri").focus();
settle(&mut h);
h.key_press(egui::Key::ArrowDown);
settle(&mut h);
h.key_press(egui::Key::Enter);
settle(&mut h);
assert_eq!(h.state().details_family().as_deref(), Some("Hack"));
}
const USER_INDEX: &str = "https://fonts.example.org/index.json";
/// A source a user could add: another "Hack", from an index of its own.
fn user_index() -> Vec<u8> {
format!(
r#"{{"schema":1,"version":"1.1","source":"examplefonts","commit":"{commit}",
"source_info":{{"id":"examplefonts","name":"Example Fonts","curated":true,"file_urls":["https://fonts.example.org/files/{{path}}"]}},
"languages":[{{"id":"en_Latn","name":"English","script":"Latn","population":1}}],
"scripts":[{{"id":"Latn","name":"Latin"}}],
"families":[{{"family":"Hack","source":"examplefonts","categories":["MONOSPACE"],"license":"OFL","license_path":null,"languages":[0],"scripts":["Latn"],
"files":[{{"path":"{HACK}","size":{size},"sha256":"{sha}","style":"normal","weight":400,"axes":[],"postscript":[]}}]}}]}}"#,
commit = "1".repeat(40),
size = epaint_default_fonts::HACK_REGULAR.len(),
sha = "0a".repeat(32),
)
.into_bytes()
}
/// The panel with its sources beside it, as a host's settings show them.
fn sources_setup(name: &str) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("flows").join(name);
let _ = std::fs::remove_dir_all(&root);
let store_dir = root.join("store");
std::fs::create_dir_all(&store_dir).unwrap();
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(store_dir.clone()), ..FontEnv::default() };
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
catalog.rescan();
let store = Arc::new(FakeStore::new(store_dir, HashMap::new()).with_index(index()).serving_index(USER_INDEX, user_index()));
store.allow_downloads(true).unwrap();
let options = Options { catalog, installer: store.clone(), index: store.indexes().map_err(|e| e.0), runner: Runner::Inline };
let mut harness = Harness::builder()
.with_size([1400.0, 760.0])
.allow_missing_glyphs()
.with_context_setup(|ctx| install_interface_fonts(ctx, &[]))
.build_ui_state(
|ui, app: &mut FontsPanel| {
egui::Panel::left("sources").exact_size(420.0).show(ui, |ui| app.sources_ui(ui));
egui::CentralPanel::default().show(ui, |ui| app.ui(ui));
},
FontsPanel::new(options, Settings::default()),
);
harness.run_steps(4);
(harness, store)
}
/// Steps until `done`, for work on the panel's own threads.
fn wait(h: &mut Harness<'_, FontsPanel>, done: impl Fn(&FontsPanel) -> bool) {
for _ in 0..200 {
if done(h.state()) {
settle(h);
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
h.step();
}
panic!("timed out");
}
#[test]
fn an_added_source_is_not_curated_and_can_be_removed() {
let (mut h, store) = sources_setup("sources");
assert_eq!(h.state().results().len(), 4);
type_into(&mut h, "Index address", USER_INDEX);
click_role(&mut h, Role::Button, "Add");
wait(&mut h, |p| p.results().len() == 5);
// Two families named Hack, one from each source, each with its own card.
assert_eq!(h.state().results().iter().filter(|f| *f == "Hack").count(), 2);
assert!(h.query_all_by_label("Not curated").count() >= 2, "a badge on the card and one in the sources");
assert!(h.query_by_label("Changed since added").is_none());
store.change_index(USER_INDEX);
click(&mut h, "Update sources");
wait(&mut h, |p| !p.fetching_sources());
assert!(h.query_by_label("Changed since added").is_some());
click_parts(&mut h, &["Remove", USER_INDEX]);
assert_eq!(h.state().results().len(), 4);
assert!(store.user_sources().is_empty());
}
/// The launcher's colours, for screenshots that look as the panel does there.
fn launcher_look() -> Look {
let hex = |rgb: u32| egui::Color32::from_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8);
let ink = |rgb: u32, a: u8| egui::Color32::from_rgba_unmultiplied((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, a);
let dark = Palette {
bg: hex(0x121316),
sunken: hex(0x060607),
ink: hex(0xf2f1ee),
ink_strong: hex(0xffffff),
muted: ink(0xf2f1ee, 153),
faint: ink(0xf2f1ee, 97),
line: ink(0xf2f1ee, 38),
line_strong: ink(0xf2f1ee, 102),
accent: hex(0x2d81ff),
accent_ink: hex(0x74aaff),
invert_bg: hex(0xffffff),
invert_fg: hex(0x0b0b0c),
good: hex(0x3fd6b8),
warn: hex(0xffc14d),
bad: hex(0xff6259),
};
Look { dark: Some(dark), caps: true, corner: egui::CornerRadius::ZERO, control_height: 34.0, ..Look::default() }
}
fn save(h: &mut Harness<'_, FontsPanel>, dir: &std::ffi::OsStr, name: &str) {
h.run_steps(8);
let image = h.render().unwrap();
std::fs::create_dir_all(dir).unwrap();
image.save(PathBuf::from(dir).join(format!("{name}.png"))).unwrap();
}
/// The interface fonts with the system's for the scripts egui's own faces lack.
fn system_fonts(language: &'static str) -> impl Fn(&egui::Context) + 'static {
move |ctx| {
use craft_ui::fonts::{I18nScripts, UiFonts};
let provider = Arc::new(egui_system_font_provider::SystemFontProvider::new().with_locale(Some(language)));
UiFonts::new(&I18nScripts).languages(&["en", language]).ui_language(language).system_provider(provider).install(ctx);
craft_ui::direction::apply(ctx, craft_ui::direction::resolve(language, craft_ui::direction::Setting::Language));
}
}
/// `CRAFT_FONT_MANAGER_SNAPSHOTS=<dir>`: the panel's main states.
#[test]
fn screenshots() {
let Some(dir) = std::env::var_os("CRAFT_FONT_MANAGER_SNAPSHOTS") else { return };
let shot = |name: &str, language: &'static str, f: &dyn Fn(&mut Harness<'_, FontsPanel>, &FakeStore)| {
let (mut h, store) = setup_with(&format!("shot-{name}"), Settings::default(), Runner::Thread, system_fonts(language));
h.state_mut().set_look(launcher_look());
h.state_mut().set_language(language);
settle(&mut h);
f(&mut h, &store);
save(&mut h, &dir, name);
store.release();
};
shot("catalogue-downloads-off", "en", &|_, _| {});
shot("catalogue", "en", &|h, _| {
allow(h);
click(h, "Preview Hack");
h.run_steps(20);
});
shot("downloading", "en", &|h, store| {
allow(h);
store.hold();
click(h, "Install Hack");
h.run_steps(10);
});
shot("failed", "en", &|h, store| {
allow(h);
store.fail_next("couldn't reach cdn.jsdelivr.net or raw.githubusercontent.com");
click(h, "Install Hack");
h.run_steps(20);
});
shot("empty-search", "en", &|h, _| type_into(h, "Search", "Garamondish"));
shot("installed", "en", &|h, _| {
allow(h);
click(h, "Install Hack");
h.run_steps(20);
installed_tab(h);
});
shot("details", "en", &|h, _| {
allow(h);
click(h, "Preview Hack");
h.run_steps(20);
click(h, "Hack");
});
shot("details-not-installed", "en", &|h, _| click(h, "Noto Sans JP"));
shot("arabic", "ar", &|h, _| {
allow(h);
click_parts(h, &["معاينة", "Amiri"]);
h.run_steps(20);
click_role(h, Role::ComboBox, "اللغة");
type_into(h, "ابحث عن لغة", "Arabic");
click(h, "Arabic · العربية");
});
shot("arabic-details", "ar", &|h, _| {
allow(h);
click_parts(h, &["معاينة", "Amiri"]);
h.run_steps(20);
click(h, "Amiri");
});
}