From 93a039d52424d312d0cafa532dcfb438705ce4c2 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Sat, 7 Nov 2026 20:26:05 +0200 Subject: [PATCH] UI tests: gradient and rotation controls are found in release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradient and rotate-view harnesses looked up widgets by egui Id debug source strings (for example ".with(…)" and "rot-combo"). Those names exist only with debug assertions, so the same tests panic in --release. Resolve widgets from stable scope salts / AccessKit labels instead, which work in both profiles. --- crates/ui-egui/src/panels/gradient/tests.rs | 49 ++++++++++++--------- crates/ui-egui/src/tests/rotateview.rs | 21 ++++++--- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index e27da3cfd..9177f315e 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -106,13 +106,14 @@ fn app(fill: Value) -> VectorcraftApp { struct Panel { ctx: egui::Context, time: f64, + scope: egui::Id, } impl Panel { fn new() -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - Self { ctx, time: 0.0 } + Self { ctx, time: 0.0, scope: egui::Id::NULL } } /// One frame with `events` (and `modifiers` held); returns the texts drawn and where. @@ -144,23 +145,27 @@ impl Panel { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), ..Default::default() }; - let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); + let mut out = self.ctx.run_ui(input, |ui| { + self.scope = ui.scope_id(); + draw(app, ui); + }); out.textures_delta.clear(); let mut v = vec![]; out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); v } - /// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is - /// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with; - /// debug builds remember the `with(key)` that made each id, so match that. - fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect { - let suffix = format!(".with({key:?})"); - self.ctx - .viewport(|vp| { - vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect) - }) - .expect("widget drawn") + /// Match actual IDs in the panel or its immediate horizontal rows. `parent_id` is an + /// unstable auto ID, and the human-readable `Id` debug source is absent in release builds. + fn widget(&self, key: impl egui::AsIdSalt + Copy) -> Rect { + // Match Ui::new_child: its default child salt is an IdSalt, not the raw string. + let row_scope = self.scope.with(egui::IdSalt::new("child")); + let ids = [self.scope.with(key), row_scope.with(key)]; + // Outside run_ui, read_response can prefer the older this_pass geometry after + // end_pass swaps the passes. Use the completed frame, including after Hide Options. + let matches: Vec<_> = self.ctx.viewport(|vp| ids.into_iter().filter_map(|id| vp.prev_pass.widgets.get(id).map(|w| w.rect)).collect()); + assert_eq!(matches.len(), 1, "expected one widget for {key:?}"); + matches[0] } fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> { @@ -252,7 +257,7 @@ fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() { let fill = p.widget("fill-proxy"); p.click(&mut app, fill.left_top() + vec2(2.0, 2.0)); assert!(app.session.fill_active, "the proxy works with the options hidden"); - p.widget(("grad-stop", 1)); + p.widget(("grad-stop", 1_usize)); } #[test] @@ -331,7 +336,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { let mut p = Panel::new(); let texts = p.frame(&mut app, vec![], Modifiers::NONE); assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}"); - let diamond = p.widget(("grad-mid", 0)); + let diamond = p.widget(("grad-mid", 0_usize)); let texts = p.click(&mut app, diamond.center()); assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection"); assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}"); @@ -343,7 +348,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops); // Clicking a stop selects it again. - let stop = p.widget(("grad-stop", 0)); + let stop = p.widget(("grad-stop", 0_usize)); let texts = p.click(&mut app, stop.center()); assert_eq!(app.session.selected_stop(), Some(0)); assert!(texts.iter().any(|(t, _)| t == "0%")); @@ -474,7 +479,7 @@ fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() { let mut app = app(json!({"gradient": {}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center()); let mid = pos2((a.x + b.x) / 2.0, a.y); let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::>(); // A plain drag moves the stop. @@ -499,7 +504,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() { let mut app = app(json!({"gradient": {}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center()); let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| { egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE); @@ -509,7 +514,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() { drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0)); assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); p.frame(&mut app, vec![], Modifiers::NONE); - let last = p.widget(("grad-stop", 2)).center(); + let last = p.widget(("grad-stop", 2_usize)).center(); drop(&mut p, &mut app, last); assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]); // A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop. @@ -538,7 +543,7 @@ fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() { app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap(); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center()); let at = pos2((a.x + b.x) / 2.0, a.y - 20.0); let red = Color::rgb(1.0, 0.0, 0.0); egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red))); @@ -554,7 +559,7 @@ fn double_clicking_a_stop_opens_the_shared_popover_beside_it() { let mut app = app(json!({"gradient": {}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let stop = p.widget(("grad-stop", 1)); + let stop = p.widget(("grad-stop", 1_usize)); let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE); let d = app.ui.dialog.clone().expect("the popover opened"); @@ -755,7 +760,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() { let mut app = app(json!({"gradient": {"stops": stops}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let mid = p.widget(("grad-stop", 1)).center(); + let mid = p.widget(("grad-stop", 1_usize)).center(); // Dragged down off the ramp, the middle stop goes. p.drag(&mut app, mid, mid + vec2(0.0, 60.0), Modifiers::NONE); assert_eq!(hexes(&app), ["#ffffff", "#000000"]); @@ -773,7 +778,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() { assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); app.run("edit.redo", json!({})).unwrap(); // Two stops stay two: dragged off, the stop comes back, and the button is off. - let first = p.widget(("grad-stop", 0)).center(); + let first = p.widget(("grad-stop", 0_usize)).center(); p.drag(&mut app, first, first + vec2(0.0, 60.0), Modifiers::NONE); assert_eq!(hexes(&app), ["#ffffff", "#000000"]); app.run("gradient.selectStop", json!({"index": 0})).unwrap(); diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index fb434fd2a..8f477894a 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -227,7 +227,7 @@ impl Window { } /// One headless frame of the whole window, `seconds` after the last one, with `events`. - fn after(&mut self, seconds: f64, events: Vec) { + fn after(&mut self, seconds: f64, events: Vec) -> egui::FullOutput { self.time += seconds; let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); let raw = egui::RawInput { screen_rect: Some(rect), time: Some(self.time), events, ..Default::default() }; @@ -237,6 +237,7 @@ impl Window { app.ui(ui); }); out.textures_delta.clear(); + out } /// A press and release of Escape, `seconds` after the last frame. @@ -336,12 +337,18 @@ fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() #[test] fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() { let mut w = Window::new(); - // The menu's button has the id `ComboBox::from_id_salt("rot-combo")` gives it in the status bar's - // row, which hosts other widgets too. egui 0.37 salts the Ui's stable `scope_id`, not the - // widget rect's unstable `parent_id`; in debug builds the id's debug source names its salt. - let widgets: Vec = w.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter().copied()).collect()); - let combo = widgets.iter().find(|r| format!("{:?}", r.id).contains("rot-combo")).copied(); - let combo = combo.expect("the status bar's angle menu"); + // Find the actual combo by its accessible name, which is present in both build profiles; + // `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled. + w.ctx.enable_accesskit(); + let out = w.after(0.0, vec![]); + let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree"); + let combos: Vec<_> = + tree.nodes.iter().filter(|(_, node)| node.role() == egui::accesskit::Role::ComboBox && node.label() == Some("Rotation")).collect(); + assert_eq!(combos.len(), 1, "the status bar's angle menu"); + let combo = w + .ctx + .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == combos[0].0).copied()) + .expect("the angle menu's widget"); w.click(0.0, egui::PointerButton::Primary, combo.rect.center()); w.after(0.0, vec![]); assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open");