From 548fad4b59df0a4f62e5248ca11453c5115630c6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:10:11 -0600 Subject: [PATCH 01/10] #480: test that the Add and Delete Anchor Point tool icons are the Pen's icon with a badge The Add and Delete Anchor Point tools' icons were drawn apart from the Pen tool's, so the three didn't read as one family. The test checks that both tools' icons contain every shape of the Pen tool's icon plus a badge of their own, and that plus and minus differ. With the tool mapping pointing back at the old dc-pen-add and dc-pen-delete icons it fails: the Add tool's icon lacks the Pen's outline. --- crates/ui-egui/src/icons.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52476db6f..8987cc64c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -187,6 +187,31 @@ mod tests { assert!(appearance.contains(">(); + let pen = shapes(&svg("tool-pen")); + assert!(!pen.is_empty(), "the Pen's icon has shapes"); + let (add, delete) = (shapes(&svg("tool-pen-add")), shapes(&svg("tool-pen-delete"))); + for (tool, icon) in [("tool-pen-add", &add), ("tool-pen-delete", &delete)] { + for shape in &pen { + assert!(icon.contains(shape), "{tool}'s icon lacks the Pen's {shape}"); + } + assert!(icon.len() > pen.len(), "{tool}'s icon has a badge of its own"); + } + assert_ne!(add, delete, "plus and minus differ"); + } + #[test] fn shared_catalog_preserves_fallback_and_tool_aliases() { let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") }; From 3f74e38feaf9149fe0bbd572bc486ec4a61b4ae3 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:10:20 -0600 Subject: [PATCH 02/10] #871: test that the Artboard tool names its active artboard once With the Artboard tool the active artboard's name was drawn twice: by the canvas, as for every artboard, and by the tool, a little higher. The test draws a frame with two artboards and checks that each name is painted once and that the active artboard's sits where the other's does. It fails without the canvas skipping the tool-labelled artboard (two labels for the active one) and without the tool's label position (8 points to the right and 11 higher than the other name). --- crates/ui-egui/src/canvas.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e943a0524..7fcbcd742 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4132,6 +4132,37 @@ mod tests { assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected"); } + // Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice, + // once by the canvas as for every artboard and once, a little higher and in blue, by the tool. + // Now each artboard is named once, and the active one's name sits where the others' do. + #[test] + fn the_artboard_tool_names_its_active_artboard_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let drawn = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it. + let named = |name: &str, board: usize| -> Vec<(f32, f32)> { + let r = app.session.active().unwrap().doc.artboards[board].rect; + let corner = xf.to_screen(Point::new(r.x0, r.y0)); + drawn + .iter() + .filter_map(|s| match s { + Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))), + _ => None, + }) + .collect() + }; + let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1)); + assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}"); + assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}"); + assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}"); + assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}"); + } + /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. #[test] From 888af398813cf234a4c15de170f2c5529f615646 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:10:42 -0600 Subject: [PATCH 03/10] #815: test that the toolbar's scroll bar stays a slim line The toolbar's and the dock icon column's scroll bar widened to 10 points while the pointer was on it, covering the group labels and the tool icons' right edge. The test lays out a scrolling strip, rests the pointer on its bar and measures the widest filled rectangle drawn: at most 4 points. Without the slim scroll style it measures 10 points. --- crates/ui-egui/src/widgets.rs | 74 +++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 00452d98d..c6022ad84 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -2970,3 +2970,77 @@ mod button_tests { out.textures_delta.clear(); } } + +/// How the shared widgets look once laid out and painted: shapes of whole frames, measured. +#[cfg(test)] +pub(crate) mod look_tests { + use super::*; + + /// A context with the fonts and the theme in `brightness`, in English. + pub(crate) fn themed(brightness: theme::Brightness) -> egui::Context { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + ctx + } + + /// One frame at `time` seconds of `add` in a 600 × 400 window with `events` → the shapes it + /// drew, `Shape::Vec`s flattened. + pub(crate) fn frame(ctx: &egui::Context, time: f64, events: Vec, add: impl FnMut(&mut Ui)) -> Vec { + fn flat(s: egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let raw = + egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, add); + out.textures_delta.clear(); + let mut shapes = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut shapes)); + shapes + } + + // Regression test for #808: the scroll bar of the toolbar and the dock's icon column widened to + // 10 points while it was dragged or the pointer was on it, covering the group labels and the + // right edge of the tool icons. It stays a slim line. + #[test] + fn the_strip_scroll_bar_stays_a_slim_line_while_the_pointer_is_on_it() { + let ctx = themed(Default::default()); + let area = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + area.set( + ui.allocate_ui(vec2(80.0, 200.0), |ui| { + strip_scroll(ui, "strip", |ui| { + for i in 0..40 { + ui.label(format!("Tool {i}")); + } + }); + }) + .response + .rect, + ); + }; + frame(&ctx, 0.0, vec![], &mut add); + let area = area.get(); + assert!(area.height() >= 199.0, "the strip fills the height it was given: {area:?}"); + // The pointer comes onto the bar's edge and stays there while the bar settles. + let on_the_bar = egui::Event::PointerMoved(pos2(area.right() - 1.0, area.center().y)); + let mut widest = 0.0_f32; + for i in 1..=15 { + let shapes = frame(&ctx, f64::from(i) * 0.1, if i == 1 { vec![on_the_bar.clone()] } else { vec![] }, &mut add); + for s in shapes { + // The bar's track and handle are the filled rectangles. + if let egui::Shape::Rect(r) = s + && r.fill != Color32::TRANSPARENT + { + widest = widest.max(r.rect.width()); + } + } + } + assert!(widest > 0.0, "the scroll bar is drawn"); + assert!(widest <= 4.5, "the scroll bar is {widest} points wide"); + } +} From b5a99542eb0d3179870e037fafdc39a709b97e9d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:10:48 -0600 Subject: [PATCH 04/10] #916: test that a spinner field reads as one box in three parts A number field with a spinner and preset chevron drew as three separate boxes, each joint with two borders. The test lays out a spin field and checks that its three boxes are the row's height and that neighbours overlap by one point so they share a border. Without the overlap (item spacing 0) the boxes touch edge to edge instead. --- crates/ui-egui/src/widgets.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c6022ad84..1458cda8a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -3043,4 +3043,34 @@ pub(crate) mod look_tests { assert!(widest > 0.0, "the scroll bar is drawn"); assert!(widest <= 4.5, "the scroll bar is {widest} points wide"); } + // Regression test for #900: a number field with a spinner and a preset list drew as three + // boxes side by side: its text box was a point taller than the 26-point row, and each joint had + // two borders. + #[test] + fn a_spinner_field_reads_as_one_box_in_three_parts() { + let ctx = themed(Default::default()); + let mut add = |ui: &mut Ui| { + ui.horizontal(|ui| spin_field(ui, "weight", Some(4.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0])); + }; + frame(&ctx, 0.0, vec![], &mut add); + let shapes = frame(&ctx, 0.1, vec![], &mut add); + // The boxes of the row: the spinner, the field and the preset chevron, left to right. + let mut boxes: Vec = vec![]; + for s in &shapes { + if let egui::Shape::Rect(r) = s + && r.rect.height() > 20.0 + && !boxes.contains(&r.rect) + { + boxes.push(r.rect); + } + } + boxes.sort_by(|a, b| a.min.x.total_cmp(&b.min.x)); + assert_eq!(boxes.len(), 3, "{boxes:?}"); + for b in &boxes { + assert_eq!((b.min.y, b.height()), (boxes[0].min.y, 26.0), "all as tall as the row: {boxes:?}"); + } + for pair in boxes.windows(2) { + assert!((pair[1].min.x - pair[0].max.x + 1.0).abs() < 0.01, "neighbours share one border: {boxes:?}"); + } + } } From f39c9544eb1f7dbd0f102ce3dbdceb408a6907b0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:10:56 -0600 Subject: [PATCH 05/10] #1490: test that selected text in value fields is in the strong text colour Selected text in a value field was drawn in the accent on a selection tinted with the same accent, hard to read in every brightness. The tests focus a panel number field and a dialog text field, select their text and read the colours of the glyphs drawn, in all four brightnesses: all must be the strong text colour. Without strong_selected_text the glyphs come out in the accent. --- crates/ui-egui/src/dialogs/form.rs | 35 ++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 47 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index bc2242f38..58ea3b2ed 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -894,3 +894,38 @@ mod typed_number_tests { assert!(typed_number(1e20).is_f64()); } } + +#[cfg(test)] +mod selected_text_tests { + use super::*; + use crate::widgets::look_tests::{frame, glyph_colours, themed}; + + // Regression test for #1465: selected text in a dialog's text field was in the accent, on a + // selection tinted with the same accent. It is in the strong text colour. + #[test] + fn selected_text_in_a_dialog_text_field_is_in_the_strong_text_colour() { + for brightness in crate::theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let mut d = Dialog::new("test", json!({"name": "Artboard 1"})); + let id = std::cell::Cell::new(egui::Id::NULL); + let mut add = |ui: &mut egui::Ui| { + id.set(ui.id().with(("dlg-text", "name"))); + text_edit(ui, &mut d, "name", 120.0); + }; + frame(&ctx, 0.0, vec![], &mut add); + // Focus the field and select all of its text. + ctx.memory_mut(|m| m.request_focus(id.get())); + frame(&ctx, 0.1, vec![], &mut add); + let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(10)))); + state.store(&ctx, id.get()); + let shapes = frame(&ctx, 0.2, vec![], &mut add); + let colours = glyph_colours(&shapes); + assert!(!colours.is_empty(), "{brightness:?}: the text is drawn"); + let mut used = colours.clone(); + used.dedup(); + assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1458cda8a..289112262 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -3073,4 +3073,51 @@ pub(crate) mod look_tests { assert!((pair[1].min.x - pair[0].max.x + 1.0).abs() < 0.01, "neighbours share one border: {boxes:?}"); } } + + /// The colour of every glyph of the text in `shapes`: selected glyphs are drawn in the + /// selection stroke's colour. + pub(crate) fn glyph_colours(shapes: &[egui::Shape]) -> Vec { + let mut colours = vec![]; + for s in shapes { + if let egui::Shape::Text(t) = s { + for row in &t.galley.rows { + let visuals = &row.row.visuals; + colours.extend(visuals.mesh.vertices[visuals.glyph_vertex_range.clone()].iter().map(|v| v.color)); + } + } + } + colours + } + + // Regression test for #1465: the selected number in a value field was hard to read in every + // brightness: egui draws selected text in the selection stroke's colour, the accent, on a + // selection tinted with the same accent. It is in the strong text colour. + #[test] + fn selected_text_in_a_value_field_is_in_the_strong_text_colour() { + for brightness in theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let id = std::cell::Cell::new(egui::Id::NULL); + let mut add = |ui: &mut Ui| { + id.set(ui.id().with("w")); + num_field(ui, "w", Some(1234.0), Unit::Points, 80.0); + }; + frame(&ctx, 0.0, vec![], &mut add); + // The field takes focus, and all of its text is selected. + ctx.memory_mut(|m| m.request_focus(id.get())); + frame(&ctx, 0.1, vec![], &mut add); + assert!(ctx.memory(|m| m.has_focus(id.get())), "{brightness:?}: the field has focus"); + let len = Unit::Points.format(1234.0).chars().count(); + let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + state.store(&ctx, id.get()); + let shapes = frame(&ctx, 0.2, vec![], &mut add); + let colours = glyph_colours(&shapes); + assert!(!colours.is_empty(), "{brightness:?}: the number is drawn"); + let mut used = colours.clone(); + used.dedup(); + assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); + assert_ne!(t.text_strong, t.accent); + } + } } From a65cae367f7cdfcee3d7e06ca607d9b879d3e891 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:11:03 -0600 Subject: [PATCH 06/10] #1506: test that sliders show their rail filled up to the handle The theme's widget fill matched the dialog background, so a slider in a dialog showed only its handle. The test draws widgets::slider in all four brightnesses and checks that a rail in the buttons' border colour and an accent fill from the rail's start up to the handle are painted. With a plain egui slider neither is drawn, only two rectangles in the panel's button fill. --- crates/ui-egui/src/widgets.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 289112262..a78e9b259 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -3120,4 +3120,30 @@ pub(crate) mod look_tests { assert_ne!(t.text_strong, t.accent); } } + + // Regression test for #1483: the theme's widget fill is the panel's colour, so a slider in a + // dialog showed only its handle. `slider` draws the rail in the buttons' border colour and + // fills it up to the handle in the accent. + #[test] + fn a_slider_shows_its_rail_filled_up_to_the_handle() { + for brightness in theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let mut value = 30.0_f64; + let mut add = |ui: &mut Ui| { + slider(ui, egui::Slider::new(&mut value, 0.0..=100.0).show_value(false)); + }; + frame(&ctx, 0.0, vec![], &mut add); + let shapes = frame(&ctx, 0.1, vec![], &mut add); + let rects: Vec<(Rect, Color32)> = + shapes.iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some((r.rect, r.fill)) } else { None }).collect(); + let rail = rects.iter().find(|(_, c)| *c == t.button_border).map(|(r, _)| *r); + let filled = rects.iter().find(|(_, c)| *c == t.accent).map(|(r, _)| *r); + let (Some(rail), Some(filled)) = (rail, filled) else { + panic!("{brightness:?}: the rail ({:?}) and its fill ({:?}) are drawn: {rects:?}", t.button_border, t.accent); + }; + assert!(filled.width() > 1.0 && filled.width() < rail.width(), "{brightness:?}: filled {filled:?} of the rail {rail:?}"); + assert!((filled.min.x - rail.min.x).abs() < 1.0, "{brightness:?}: the fill starts at the rail's start"); + } + } } From ae6cf1a729c0ab650cb1c242a8236a66ab4055fd Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:11:11 -0600 Subject: [PATCH 07/10] #2083: test that panel menu rows light up under the pointer The rows of the panels' menus did not light up under the pointer as the menu bar's items do, because they were drawn with no frame at all. The test hovers a menu_item_name row and checks that a filled rectangle appears behind it, and that none is there at rest. With frame(false) the hovered row stays unfilled. --- crates/ui-egui/src/widgets.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a78e9b259..79e84d746 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -3146,4 +3146,32 @@ pub(crate) mod look_tests { assert!((filled.min.x - rail.min.x).abs() < 1.0, "{brightness:?}: the fill starts at the rail's start"); } } + + // Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as + // the menu bar's items do. + #[test] + fn a_panel_menu_row_lights_up_under_the_pointer() { + let ctx = themed(Default::default()); + let row_at = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + row_at.set( + ui.scope(|ui| { + menu_item_name(ui, "Rename", true, false); + }) + .response + .rect, + ); + }; + // A filled rectangle behind the row's text. + let lit = |shapes: &[egui::Shape], row: Rect| { + shapes.iter().any(|s| matches!(s, egui::Shape::Rect(r) if r.fill != Color32::TRANSPARENT && r.rect.contains(row.center()))) + }; + frame(&ctx, 0.0, vec![], &mut add); + let row = row_at.get(); + let at_rest = frame(&ctx, 0.1, vec![], &mut add); + assert!(!lit(&at_rest, row), "no frame at rest"); + frame(&ctx, 0.2, vec![egui::Event::PointerMoved(row.center())], &mut add); + let hovered = frame(&ctx, 0.3, vec![], &mut add); + assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); + } } From 02f5bc8666471f2ad5c2c434c4c48528364c8387 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:11:17 -0600 Subject: [PATCH 08/10] #3533: test that a popover stays open through its anchor click and a panel drag The Fill/Stroke chip's popover closed on the click that finished a drag between its proxies. The test opens a popover and checks the three outcomes: a click elsewhere closes it, a click on its own anchor that does not toggle it keeps it open, and a click elsewhere during a panel drag keeps it open. Without the fix both of the last two close it. --- crates/ui-egui/src/widgets.rs | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 79e84d746..7a1050afd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -3174,4 +3174,42 @@ pub(crate) mod look_tests { let hovered = frame(&ctx, 0.3, vec![], &mut add); assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); } + + // Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a + // drag between its proxies. It stays open for a click on its own anchor and while a panel + // drag is on; a click elsewhere still closes it. + #[test] + fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Opens the popover, then clicks at `click` (with `drag` as the panel drag under way). + let open_after_click = |click: Option, drag: Option| -> bool { + let ctx = themed(Default::default()); + let toggle = std::cell::Cell::new(true); + let shown = std::cell::Cell::new(false); + let anchor = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + let resp = ui.allocate_response(vec2(40.0, 24.0), Sense::hover()); + anchor.set(resp.rect); + shown.set(popover(&resp, toggle.replace(false), |ui| ui.label("Colour")).is_some()); + }; + frame(&ctx, 0.0, vec![], &mut add); + assert!(shown.get(), "the toggle opens it"); + frame(&ctx, 0.2, vec![], &mut add); + assert!(shown.get(), "it stays open"); + if let Some(d) = drag { + egui::DragAndDrop::set_payload(&ctx, d); + } + let pos = click.unwrap_or_else(|| anchor.get().center()); + frame(&ctx, 0.3, vec![egui::Event::PointerMoved(pos), press(pos, true)], &mut add); + frame(&ctx, 0.4, vec![press(pos, false)], &mut add); + frame(&ctx, 0.5, vec![], &mut add); + shown.get() + }; + let far = Some(pos2(500.0, 350.0)); + let panel_drag = || Some(PanelDrag::Symbol("s".into())); + // Whether it is open after: a click elsewhere; a click on its anchor that doesn't toggle it; + // a panel drag released elsewhere. + let open = [open_after_click(far, None), open_after_click(None, None), open_after_click(far, panel_drag())]; + assert_eq!(open, [false, true, true]); + } } From 8abb1d1c71236add1bd587c93d80e2c1088e0f84 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:11:34 -0600 Subject: [PATCH 09/10] #1807: test that a zstd block larger than its frame's window is refused ruzstd 0.9.0 let a block decompress to more than its frame's window allows (RUSTSEC-2026-0344). The test swaps a hand-built Zstandard frame with a 1 KiB window and one 100,000-byte RLE block into an Affinity container entry and checks that reading the entry fails. With ruzstd pinned back to 0.9.0 the block is decompressed and the read succeeds. --- crates/affinity/src/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 006f9e7bd..817012fa7 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -261,6 +261,32 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's + // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than + // the format permits for a block. 0.9.1 refuses it. + #[test] + fn a_zstd_block_larger_than_its_frames_window_is_refused() { + const LEN: u32 = 100_000; + let plain = vec![0x61u8; LEN as usize]; + let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None); + // A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE + // block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3. + let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00]; + frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap()); + frame.push(0x61); + // Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72). + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize; + let mut swapped = file[..76].to_vec(); + swapped.extend(&frame); + swapped.extend(&file[76 + old_len..]); + let new_fat = fat - old_len + frame.len(); + swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes()); + swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes()); + let mut a = Archive::open(&swapped, Limits::default()).unwrap(); + assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window"); + } + #[test] fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { let big = vec![7u8; 1 << 20]; From 2e142583b3d83f5ba9a4ecad0de34af16edc6d2b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 13:11:34 -0600 Subject: [PATCH 10/10] #2278: test that the Properties flip buttons keep the reference app's names The Properties panel's flip buttons had been renamed Flip Horizontal and Flip Vertical, which are the Transform panel menu's names. The test lays out the Transform section for a selected rectangle and reads the screen-reader names of its buttons: Flip Along Horizontal Axis and Flip Along Vertical Axis must be there and the old names must not. With the old labels the first is missing. --- crates/ui-egui/src/panels/properties.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 42403f222..33cc41439 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -802,6 +802,25 @@ mod tests { assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); } + // Regression test for #1910: the Properties panel's flip buttons are named as the reference + // app's Properties panel names them, not Flip Horizontal and Flip Vertical (the Transform + // panel menu's names). + #[test] + fn the_flip_buttons_keep_the_reference_properties_names() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let tree = crate::widgets::accessibility_tests::tree(|ui| transform_section(&mut app, ui)); + let names: Vec<&str> = tree.iter().filter_map(|n| n.label()).collect(); + for want in ["Flip Along Horizontal Axis", "Flip Along Vertical Axis"] { + assert!(names.contains(&want), "{want} in {names:?}"); + } + for old in ["Flip Horizontal", "Flip Vertical"] { + assert!(!names.contains(&old), "{old} in {names:?}"); + } + } + /// #3451: the Properties panel Transform section shows ellipse pie angles and Invert Pie. #[test] fn transform_section_shows_ellipse_pie_properties() {