collective

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

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1610,8 +1610,10 @@ dependencies = [  "designcraft-render",  "designcraft-tools",  "egui",+ "egui-wgpu",  "egui_kittest",  "log",+ "pollster 1.0.1",  "serde",  "serde_json",  "tempfile",diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml--- a/crates/ui-egui/Cargo.toml+++ b/crates/ui-egui/Cargo.toml@@ -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]diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs--- a/crates/ui-egui/src/canvas/tests.rs+++ b/crates/ui-egui/src/canvas/tests.rs@@ -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.diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs--- a/crates/ui-egui/src/menus/tests.rs+++ b/crates/ui-egui/src/menus/tests.rs@@ -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.diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs--- a/crates/ui-egui/src/panels/styles.rs+++ b/crates/ui-egui/src/panels/styles.rs@@ -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());diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -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();diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs--- a/crates/ui-egui/src/toolbar/tests.rs+++ b/crates/ui-egui/src/toolbar/tests.rs@@ -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 {