Merge pull request #3704 from storytold/land/3698

Regression tests for ten fixes merged without one
This commit is contained in:
BFlat
2026-10-23 03:38:06 +07:00
committed by GitHub
6 changed files with 379 additions and 0 deletions
+26
View File
@@ -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];
+31
View File
@@ -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]
+35
View File
@@ -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:?}");
}
}
}
+25
View File
@@ -187,6 +187,31 @@ mod tests {
assert!(appearance.contains("<circle") && !pathfinder.contains("<circle"), "appearance should read as a list, not overlapping squares");
}
// Regression test for #479: the Add and Delete Anchor Point tools' icons were drawn apart from
// the Pen tool's, so the three tools didn't read as one family. They are the Pen's pen with a
// plus or a minus beside it.
#[test]
fn the_add_and_delete_anchor_tool_icons_are_the_pen_icon_with_a_badge() {
let svg = |tool: &str| {
let name = tool_icon(tool);
let (_, bytes) =
crate::icon_data::ICONS.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{tool} maps to {name}, which isn't an icon"));
String::from_utf8_lossy(bytes).into_owned()
};
let shapes =
|svg: &str| svg.lines().map(|l| l.trim().to_string()).filter(|l| l.starts_with("<path") || l.starts_with("<circle")).collect::<Vec<_>>();
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") };
+19
View File
@@ -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() {
+243
View File
@@ -2970,3 +2970,246 @@ 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<egui::Event>, add: impl FnMut(&mut Ui)) -> Vec<egui::Shape> {
fn flat(s: egui::Shape, out: &mut Vec<egui::Shape>) {
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");
}
// 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<Rect> = 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:?}");
}
}
/// 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<Color32> {
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);
}
}
// 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");
}
}
// 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:?}");
}
// 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<Pos2>, drag: Option<PanelDrag>| -> 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]);
}
}