Browse / github.com/storytold/designcraft / patch
CI: make UI tests portable to macOS
CI: make UI tests portable to macOS DesignCraft's required local CI gate fails on macOS because several UI tests synthesize Linux Control shortcuts, expect non-macOS shortcut labels, or construct test contexts without the app's fonts. The shortcut dialog also lacks the macOS Tab symbol in its bundled fallback stack. On a headless host, the GPU renderer test panics while constructing the harness before reaching its existing error handling. Use platform-correct synthetic modifiers and labels, initialize style tests with deterministic app font definitions, and add the already-bundled JetBrains Mono as a last-resort symbol face aft
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- DAnaeto (imported from pull request #1921, open)
- Base
50650f74d7328745cfcdd16098313a9fc31b23d5- Size
- +116 −33 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
dd8e396b87019cbc249cc0cf
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/ui-egui/Cargo.toml crates/ui-egui/src/canvas/tests.rs crates/ui-egui/src/menus/tests.rs crates/ui-egui/src/panels/styles.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/toolbar/tests.rs
@@ -1610,8 +1610,10 @@ dependencies = [ "designcraft-render", "designcraft-tools", "egui",+ "egui-wgpu", "egui_kittest", "log",+ "pollster 1.0.1", "serde", "serde_json", "tempfile",@@ -41,6 +41,8 @@ designcraft-idml = { workspace = true } zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }+egui-wgpu = { version = "0.37", default-features = false }+pollster = "1.0" designcraft-fonts = { workspace = true, features = ["testing"] } [build-dependencies]@@ -750,7 +750,7 @@ fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() { let clip = app.object_clip_text.clone().expect("the copy's placeholder text"); let mut h = harness(app); let paste = |h: &mut Harness<'static, DesignApp>, modifiers: egui::Modifiers| {- // As egui-winit delivers ⌃⌥⇧V: the chord's paste and the V release, no V press; here the+ // As egui-winit delivers Cmd+Alt+Shift+V: the chord's paste and the V release, no V press; here the // modifiers are released within the same frame. let release = egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers }; let none = egui::Event::ModifiersChanged(egui::Modifiers::NONE);@@ -761,12 +761,17 @@ fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() { assert_ne!(pasted.0, id); st.doc.item(pasted).unwrap().bounds() };- let linux = egui::Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };- assert_eq!(paste(&mut h, linux), original, "Paste in Place");- let moved = paste(&mut h, egui::Modifiers::COMMAND);+ let command = if cfg!(target_os = "macos") {+ egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND+ } else {+ egui::Modifiers::CTRL | egui::Modifiers::COMMAND+ };+ let in_place = command | egui::Modifiers::ALT | egui::Modifiers::SHIFT;+ assert_eq!(paste(&mut h, in_place), original, "Paste in Place");+ let moved = paste(&mut h, command); assert!((moved.x0 - original.x0 - 12.0).abs() < 1e-9 && (moved.y0 - original.y0 - 12.0).abs() < 1e-9, "{moved:?}"); // The modifiers went down in an earlier frame than the paste.- h.event_modifiers(egui::Event::Paste(clip.clone()), linux);+ h.event_modifiers(egui::Event::Paste(clip.clone()), in_place); h.run_steps(2); let st = h.state().session.doc().unwrap(); let pasted = *st.selection.items.first().expect("the copy is selected");@@ -1042,7 +1047,20 @@ fn live_corner_widget_and_diamonds_draw() { app.run("frame.create", json!({"rect": [100, 100, 300, 250], "content": "graphic"})).unwrap(); let id = app.session.doc().unwrap().doc.spreads[0].items[0].id; app.run("selection.set", json!({"ids": [id.0]})).unwrap();- let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_ui_state(+ let setup = egui_kittest::wgpu::default_wgpu_setup();+ let instance = pollster::block_on(setup.new_instance());+ let config = egui_wgpu::WgpuConfiguration { wgpu_setup: setup, ..Default::default() };+ let renderer = match pollster::block_on(egui_wgpu::RenderState::create(&config, &instance, None, egui_wgpu::RendererOptions::PREDICTABLE)) {+ Ok(state) => egui_kittest::wgpu::WgpuTestRenderer::from_render_state(state),+ Err(egui_wgpu::WgpuError::CustomNativeAdapterSelectionError(reason))+ if reason == "No adapter found" && (cfg!(target_os = "freebsd") || std::env::var_os("CI").is_none()) =>+ {+ eprintln!("skipped: no offscreen GPU adapter");+ return;+ }+ Err(error) => panic!("failed to initialize offscreen renderer: {error}"),+ };+ let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(1.0).renderer(renderer).build_ui_state( |ui, app: &mut DesignApp| { let ctx = ui.ctx().clone(); app.logic(&ctx);@@ -1059,20 +1077,13 @@ fn live_corner_widget_and_diamonds_draw() { }; let widget = screen(&h, 300.0, 100.0) + vec2(0.0, 11.5); let diamond = screen(&h, 100.0, 100.0) + vec2(12.0, 0.0);- let yellow = |h: &mut Harness<'_, DesignApp>, p: Pos2| -> Option<bool> {- let img = match h.render() {- Ok(img) => img,- Err(e) => {- eprintln!("skipped: no offscreen renderer ({e})");- return None;- }- };+ let yellow = |h: &mut Harness<'_, DesignApp>, p: Pos2| -> bool {+ let img = h.render().expect("render UI screenshot"); let px = img.get_pixel(p.x.round() as u32, p.y.round() as u32).0;- Some(px[0] > 200 && px[1] > 180 && px[2] < 100)+ px[0] > 200 && px[1] > 180 && px[2] < 100 };- let Some(w) = yellow(&mut h, widget) else { return };- assert!(w, "no widget at {widget:?}");- assert_eq!(yellow(&mut h, diamond), Some(false));+ assert!(yellow(&mut h, widget), "no widget at {widget:?}");+ assert!(!yellow(&mut h, diamond)); h.hover_at(widget); h.step(); for pressed in [true, false] {@@ -1080,8 +1091,8 @@ fn live_corner_widget_and_diamonds_draw() { h.step(); } h.run_steps(3);- assert_eq!(yellow(&mut h, diamond), Some(true), "no diamond at {diamond:?}");- assert_eq!(yellow(&mut h, widget), Some(false));+ assert!(yellow(&mut h, diamond), "no diamond at {diamond:?}");+ assert!(!yellow(&mut h, widget)); } /// A harness whose frames are 1 ms apart, so two clicks make a double-click.@@ -901,6 +901,10 @@ fn middle_drag_pans_with_any_tool() { assert_eq!(items(&app), items0, "the tool saw nothing"); } +fn command_modifier() -> egui::Modifiers {+ if cfg!(target_os = "macos") { egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND } else { egui::Modifiers::CTRL | egui::Modifiers::COMMAND }+}+ #[test] fn layout_menu_goes_through_pages_and_spreads_with_their_shortcuts() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());@@ -941,7 +945,7 @@ fn layout_menu_goes_through_pages_and_spreads_with_their_shortcuts() { assert!(at("view.nextSpread") < at("view.previousSpread")); assert!(at("view.previousSpread") < at("view.goToPage")); let shift = egui::Modifiers::SHIFT;- let cmd_shift = egui::Modifiers { shift: true, command: true, ctrl: true, ..Default::default() };+ let cmd_shift = command_modifier() | egui::Modifiers::SHIFT; let alt = egui::Modifiers::ALT; // Pages 1 | 2-3 | 4-5 | 6-7. frame(&mut app, key(egui::Key::PageDown, shift));@@ -1010,7 +1014,7 @@ fn go_back_and_go_forward_retrace_the_pages_viewed() { assert_eq!(at("view.goBack"), at("view.goToPage") + 1); assert_eq!(at("view.goForward"), at("view.goBack") + 1); assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go");- let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };+ let cmd = command_modifier(); let cmd_shift = egui::Modifiers { shift: true, ..cmd }; let shift = egui::Modifiers::SHIFT; // Pages viewed: 1, 5, 7.@@ -1096,7 +1100,7 @@ fn next_spread_at_the_last_spread_is_no_jump() { ] }; let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();- let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };+ let cmd = command_modifier(); let alt = egui::Modifiers::ALT; frame(&mut app, vec![]); frame(&mut app, vec![]);@@ -1140,7 +1144,7 @@ fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() { let st = app.session.active().unwrap(); crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() };- let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };+ let cmd = command_modifier(); let alt = egui::Modifiers::ALT; frame(&mut app, vec![]); frame(&mut app, vec![]);@@ -1212,7 +1216,7 @@ fn go_back_after_presentation_returns_to_the_last_spread_presented() { let st = app.session.active().unwrap(); crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() };- let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };+ let cmd = command_modifier(); frame(&mut app, vec![]); frame(&mut app, vec![]); // Scrolled off the page's centre, so the place Go Back returns to is not just the page.@@ -246,7 +246,10 @@ mod tests { app.run("style.character.create", json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); app.run("style.character.apply", json!({"name": "Emphasis"})).unwrap(); app.run("type.char", json!({"fill": "[Paper]"})).unwrap();- let mut h = Harness::builder().with_size(vec2(460.0, 300.0)).build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app);+ let mut h = Harness::builder()+ .with_size(vec2(460.0, 300.0))+ .with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(&[], "")))+ .build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app); h.run_steps(3); let at = h.get_all_by_label("Header+").next_back().unwrap().rect().center(); h.event(egui::Event::PointerMoved(at));@@ -281,7 +284,10 @@ mod tests { app.run("type.para", json!({"spaceAfter": 6})).unwrap(); let a = app.run("type.selectionAttrs", json!({})).unwrap(); assert_eq!((a["paraOverrides"].as_u64(), a["charOverrides"].as_u64()), (Some(0), Some(0)));- let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app);+ let mut h = Harness::builder()+ .with_size(vec2(360.0, 240.0))+ .with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(&[], "")))+ .build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app); h.run_steps(3); assert!(h.query_by_label("Body+").is_none()); assert!(h.query_all_by_label("Body").next().is_some());@@ -452,9 +452,11 @@ pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[System for (family, stack) in fonts.families.iter_mut() { // The Arabic families keep their own order (see `font_definitions`). let arabic = matches!(family, FontFamily::Name(n) if n.starts_with("arabic"));- let at = if arabic { stack.len() } else { stack.iter().position(|n| n.starts_with("craft-")).unwrap_or(stack.len()) };+ let mono_fallback = |stack: &[String]| stack.iter().position(|n| n == "mono").filter(|&i| i > 0).unwrap_or(stack.len());+ let at = if arabic { mono_fallback(stack) } else { stack.iter().position(|n| n.starts_with("craft-")).unwrap_or(mono_fallback(stack)) }; stack.splice(at..at, preferred.iter().cloned());- stack.extend(rest.iter().cloned());+ let at = mono_fallback(stack);+ stack.splice(at..at, rest.iter().cloned()); } } @@ -528,6 +530,12 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l let mut seen = std::collections::HashSet::new(); fonts.families.insert(FontFamily::Name(family.into()), stack.filter(|n| seen.insert(n.clone())).collect()); }+ // The bundled mono face supplies shortcut symbols such as ⇥ that the UI face lacks.+ for stack in fonts.families.values_mut() {+ if !stack.iter().any(|name| name == "mono") {+ stack.push("mono".into());+ }+ } fonts } @@ -603,6 +611,48 @@ mod japanese_font_tests { ctx } + #[test]+ fn keyboard_shortcut_symbols_are_available_without_extra_fonts() {+ let defs = super::font_definitions(&[], "ja");+ assert_eq!(defs.families[&egui::FontFamily::Proportional][0], "ui");+ assert_eq!(defs.families[&egui::FontFamily::Name("semibold".into())][0], "ui-semibold");+ let ctx = ctx_with(&[]);+ ctx.fonts_mut(|fonts| {+ for family in families() {+ let font = egui::FontId::new(13.0, family);+ for ch in "⌃⌥⇧⌘⇥".chars() {+ assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");+ }+ }+ });+ }++ #[test]+ fn installed_script_fonts_precede_the_mono_fallback_without_craft_fonts() {+ let face = |family: &str, script| {+ super::SystemFont::new(script, family.into(), designcraft_fonts::testing::font_with(family, &['日', '}']).unwrap(), 0)+ };+ let system = [face("DC Ja", "Jpan"), face("DC Hans", "Hans"), face("DC Arabic", "Arab"), face("DC Tamil", "Taml")];+ for (lang, preferred, other) in [("ja", "system-DC Ja", "system-DC Hans"), ("zh", "system-DC Hans", "system-DC Ja")] {+ let mut defs = super::font_definitions(&[], lang);+ super::add_system_fallbacks(&mut defs, &system, lang);+ for family in [+ egui::FontFamily::Proportional,+ egui::FontFamily::Name("semibold".into()),+ egui::FontFamily::Name("arabic".into()),+ egui::FontFamily::Name("arabic-semibold".into()),+ ] {+ let stack = &defs.families[&family];+ let at = |name: &str| stack.iter().position(|n| n == name).unwrap();+ assert!(at(preferred) < at(other), "{lang} {family:?}: {stack:?}");+ for name in [preferred, other, "system-DC Arabic", "system-DC Tamil"] {+ assert!(at(name) < at("mono"), "{lang} {family:?}: {name} follows mono: {stack:?}");+ }+ }+ assert_eq!(defs.families[&egui::FontFamily::Monospace][0], "mono");+ }+ }+ /// A newly supplied script must remain usable without any matching installed system font. /// Previously craft's script coverage suppressed system discovery while these faces were /// omitted from the actual UI font definitions, so a Hebrew tab panicked on its first glyph.@@ -654,7 +704,10 @@ mod japanese_font_tests { let mut defs = super::font_definitions(&[], "ja"); super::add_system_fallbacks(&mut defs, &system, "ja"); for family in families() {- assert_eq!(defs.families[&family].last().map(String::as_str), Some("system-DC UI Japanese"), "{family:?}");+ let stack = &defs.families[&family];+ let system_at = stack.iter().position(|n| n == "system-DC UI Japanese").unwrap();+ let mono_at = stack.iter().position(|n| n == "mono").unwrap();+ assert!(if family == egui::FontFamily::Monospace { mono_at < system_at } else { system_at < mono_at }, "{family:?}: {stack:?}"); } let ctx = egui::Context::default(); ctx.set_fonts(defs);@@ -721,7 +774,9 @@ mod japanese_font_tests { for (lang, first) in [("ja", "DC Ja"), ("zh", "DC Hans"), ("zh-hant", "DC Hant"), ("ko", "DC Ko"), ("", "DC Ja")] { let mut defs = super::font_definitions(&[], lang); // Stand-in for a built-in CJK face (craft-fonts may be absent in this build).- defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into());+ let stack = defs.families.entry(egui::FontFamily::Proportional).or_default();+ let at = stack.iter().position(|name| name == "mono").unwrap_or(stack.len());+ stack.insert(at, "craft-Stand-in".into()); super::add_system_fallbacks(&mut defs, &system, lang); let stack = &defs.families[&egui::FontFamily::Proportional]; let at = |n: &str| stack.iter().position(|x| x == n).unwrap();@@ -761,7 +816,9 @@ mod japanese_font_tests { assert_eq!(picked, [("Jpan", "Noto Sans CJK JP"), ("Taml", "Nirmala UI"), ("Thai", "Noto Sans Thai")]); let mut defs = super::font_definitions(&[], "ja");- defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into());+ let stack = defs.families.entry(egui::FontFamily::Proportional).or_default();+ let at = stack.iter().position(|name| name == "mono").unwrap_or(stack.len());+ stack.insert(at, "craft-Stand-in".into()); super::add_system_fallbacks(&mut defs, &system, "ja"); let stack = &defs.families[&egui::FontFamily::Proportional]; let at = |n: &str| stack.iter().position(|x| x == n).unwrap();@@ -197,7 +197,8 @@ fn the_tools_panel_lays_out_like_indesigns() { for double in [false, true] { let h = test_window::open(app(double), vec2(1400.0, 900.0)); let _ = rect(&h, "Gradient Swatch Tool (G)");- let _ = rect(&h, "Gradient Feather Tool (Shift+G)");+ let feather = if cfg!(target_os = "macos") { "Gradient Feather Tool (⇧G)" } else { "Gradient Feather Tool (Shift+G)" };+ let _ = rect(&h, feather); let _ = rect(&h, "View Options"); let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"]; for label in split {