refactor(ui-egui): move dialogs/brush_options.rs tests to dialogs/brush_options/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:17 +00:00
parent d129469e90
commit 5ce87c626f
2 changed files with 468 additions and 469 deletions
+1 -469
View File
@@ -359,472 +359,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app
}
fn set(app: &mut VectorcraftApp, fields: Value) {
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
}
fn shown(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let text = shown(&mut app);
for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0));
// Out-of-range values are kept within their ranges.
set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();
assert_eq!(def["modes"], json!(["random", "fixed", "pressure"]));
assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0)));
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0));
}
#[test]
fn a_taken_name_keeps_the_dialog_open() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"name": "3 pt. Round"}));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
#[test]
fn bristle_options_and_an_unknown_brush() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
let text = shown(&mut app);
for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] {
assert!(text.contains(label), "{label} in {text}");
}
set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap();
assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0)));
// No such brush: an error, nothing opens.
app.ui.dialog = None;
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
assert!(app.ui.dialog.is_none());
}
/// Bristle Brush Options' Name field takes at most 31 characters as they are typed; the
/// Calligraphic Brush Options' Name field takes more.
#[test]
fn bristle_brush_options_name_field_takes_at_most_31_characters() {
let mut app = app();
let typed = "é".repeat(40);
for (brush, kept) in [("Bristle Round", 31), ("6 pt. Flat", 40)] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
};
app.run("ui.brushOptions", json!({ "name": brush })).unwrap();
// The Name field takes the focus with its text selected; typing replaces it.
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text(typed.clone())]);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "é".repeat(kept), "{brush}");
super::super::confirm(&mut app).unwrap();
assert!(app.run("brush.get", json!({ "name": "é".repeat(kept) })).is_ok(), "{brush}");
}
// A new name of more than 31 characters in the field (a longer saved name with some of its
// characters deleted) is refused, and the dialog stays open.
app.run("ui.brushOptions", json!({ "name": "é".repeat(31) })).unwrap();
set(&mut app, json!({ "name": "é".repeat(35) }));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
/// A path painted with brush `name`, and the height of the art its stroke draws.
fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) {
let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap();
(id, drawn_height(app, id))
}
fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 {
let st = app.session.active().unwrap();
let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap();
vectorcraft_brush::pieces_bounds(&pieces).unwrap().height()
}
#[test]
fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() {
let mut app = app();
let (id, before) = brushed(&mut app, "Confetti");
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let text = shown(&mut app);
for label in [
"Scatter Brush Options",
"Name:",
"Size:",
"Spacing:",
"Scatter:",
"Rotation:",
"Random",
"Rotation relative to:",
"Page",
"Colorization",
"Method:",
"Tints",
"Key Color:",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into()));
assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into()));
// Values are kept within their ranges (rotation here); Fixed takes the first value.
set(
&mut app,
json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}),
);
assert!(shown(&mut app).contains("Hue Shift"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap();
assert_eq!(
(&def["size"], &def["rotation"][0], &def["modes"]),
(&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"]))
);
assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift")));
assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}");
assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti");
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into()));
}
#[test]
fn brush_key_color_eyedropper_updates_the_open_dialog() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = app();
app.run("paint.setFill", json!({"color": "#336699"})).unwrap();
app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap();
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
set(&mut app, json!({"colorization": "hueShift"}));
let back = app.session.tool_id();
app.select_tool("eyedropper");
app.session.set_tool_option("brushKeyColor", &json!(true));
app.session.set_tool_option("stop", &json!(back));
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Down, 80.0, 70.0), view);
let key = app.ui.dialog.as_ref().unwrap().fields.get("keyColor").cloned().unwrap();
assert_eq!(vectorcraft_engine::cmd::color_value(&key).unwrap().to_hex(), "#336699");
assert_eq!(app.session.tool_id(), back);
}
#[test]
fn art_options_set_width_scale_direction_flips_and_colorization() {
let mut app = app();
let (id, before) = brushed(&mut app, "Arrow");
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
let text = shown(&mut app);
for label in [
"Art Brush Options",
"Width:",
"Brush Scale Options",
"Scale Proportionately",
"Stretch to Fit Stroke Length",
"Stretch Between Guides",
"Start:",
"End:",
"Direction:",
"Flip Along",
"Flip Across",
"Colorization",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")),
(100.0, "betweenGuides".into(), 78.0, "leftToRight".into())
);
set(
&mut app,
json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}),
);
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap();
assert_eq!(
(&def["width"], &def["scale"], &def["direction"]),
(&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop"))
);
assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"})));
assert!(drawn_height(&app, id) != before, "the stroke redraws");
// Stretch to Fit drops the guides.
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
set(&mut app, json!({"scaleMode": "stretch"}));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"}));
}
/// Art Brush Options' Width menu: Pressure takes a second width for the heaviest pen pressure,
/// and a stroke drawn without pressure is half way between the two; Fixed drops the second width.
#[test]
fn art_options_width_follows_the_pen_pressure() {
let mut app = app();
let (id, before) = brushed(&mut app, "Tapered Stroke");
app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("fixed".into(), 100.0));
assert!(shown(&mut app).contains("Fixed"));
set(&mut app, json!({"widthMode": "pressure", "widthMax": 300}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap();
assert_eq!((&def["width"], &def["width_mode"], &def["width_max"]), (&json!(100.0), &json!("pressure"), &json!(300.0)));
let after = drawn_height(&app, id);
assert!((after - 2.0 * before).abs() < 0.01 * before, "200 % half way: {before} then {after}");
// Opened again, it shows what was set; Fixed drops the second width.
app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("pressure".into(), 300.0));
set(&mut app, json!({"widthMode": "fixed"}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap();
assert!(def.get("width_mode").is_none() && def.get("width_max").is_none(), "{def}");
assert!((drawn_height(&app, id) - before).abs() < 1e-6);
}
/// A context with the app's fonts and theme, for [`frame`].
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
/// Runs one 1200 × 900 frame with `events` through the app's key handling and the dialogs, and
/// returns the center of the text `label` if the frame shows it.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, label: &str) -> Option<egui::Pos2> {
use egui::{Pos2, Rect};
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::show(app, ui.ctx());
});
out.textures_delta.clear();
out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == label => Some(egui::Rect::from_min_size(t.pos, t.galley.size()).center()),
_ => None,
})
}
/// Clicks the text `label` the open dialog shows.
fn click_text(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) {
use egui::{Event, PointerButton};
frame(ctx, app, vec![], label);
let at = frame(ctx, app, vec![], label).unwrap_or_else(|| panic!("no {label:?} shown"));
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(ctx, app, vec![Event::PointerMoved(at), button(true)], label);
frame(ctx, app, vec![button(false)], label);
frame(ctx, app, vec![], label);
}
/// Presses Escape.
fn escape(ctx: &egui::Context, app: &mut VectorcraftApp) {
let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(ctx, app, vec![key], "");
frame(ctx, app, vec![], "");
}
/// Scatter, Art and Pattern Brush Options have a Tips button beside Method. A click on it opens
/// Colorization Tips, which names each method and what it does beside a sample of the brush,
/// and a second click closes it. Screen readers name the button, the popover and each sample.
#[test]
fn tips_names_each_colorization_method_beside_a_sample() {
use crate::widgets::accessibility_tests::{role_and_state, tree};
use egui::accesskit::Role;
let mut app = app();
for name in ["Confetti", "Arrow", "Chain"] {
app.run("ui.brushOptions", json!({ "name": name })).unwrap();
let text = shown(&mut app);
assert!(text.lines().any(|l| l == "Tips") && !text.contains("Colorization Tips"), "{name}: {text}");
}
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let ctx = ctx();
click_text(&ctx, &mut app, "Tips");
assert!(app.ui.dialog.as_ref().unwrap().bool("tips"), "Tips opens Colorization Tips");
let text = shown(&mut app);
for label in [
"Colorization Tips",
"The samples show this brush on a blue stroke.",
"None",
"The art keeps its own colors.",
"Tints",
"Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness.",
"Tints and Shades",
"Hue Shift",
"The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness.",
"Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it.",
] {
assert!(text.contains(label), "{label} in {text}");
}
let nodes = tree(|ui| super::super::show(&mut app, ui.ctx()));
assert_eq!(role_and_state(&nodes, "Tips").0, Role::Button);
assert_eq!(role_and_state(&nodes, "Colorization Tips").0, Role::Dialog);
for method in ["None", "Tints", "Tints and Shades", "Hue Shift"] {
assert!(nodes.iter().any(|n| n.role() == Role::Image && n.label() == Some(method)), "a sample named {method}");
}
click_text(&ctx, &mut app, "Tips");
assert!(!app.ui.dialog.as_ref().unwrap().bool("tips"), "a second click closes it");
assert!(!shown(&mut app).contains("Colorization Tips"));
// `ui.dialog.set {field: "tips", value: true}` opens it too.
set(&mut app, json!({"tips": true}));
assert!(shown(&mut app).contains("Colorization Tips"));
}
/// Escape with Colorization Tips or the Method list open closes only that: Brush Options stays
/// open with its edits, and the next Escape cancels it.
#[test]
fn escape_closes_tips_or_the_method_list_and_leaves_the_dialog_open() {
let (mut app, ctx) = (app(), ctx());
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
set(&mut app, json!({"colorization": "tints"}));
click_text(&ctx, &mut app, "Tips");
assert!(app.ui.dialog.as_ref().unwrap().bool("tips"));
escape(&ctx, &mut app);
let d = app.ui.dialog.as_ref().expect("Escape closes Tips, not Brush Options");
assert!(!d.bool("tips") && d.str("colorization") == "tints");
assert!(frame(&ctx, &mut app, vec![], "Colorization Tips").is_none());
click_text(&ctx, &mut app, "Tints");
assert!(egui::Popup::is_any_open(&ctx), "the Method list is open");
escape(&ctx, &mut app);
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.str("colorization") == "tints"), "Escape closes the list, not Brush Options");
assert!(!egui::Popup::is_any_open(&ctx));
escape(&ctx, &mut app);
assert!(app.ui.dialog.is_none(), "the next Escape cancels Brush Options");
// Calligraphic Brush Options has no Tips: with `tips` set, Escape still cancels it.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"tips": true}));
escape(&ctx, &mut app);
assert!(app.ui.dialog.is_none(), "Escape cancels Calligraphic Brush Options");
}
/// Each Colorization Tips sample paints the brush with its own method on the blue stroke:
/// Confetti's black and gray art keeps its colors with None, its black becomes the stroke color
/// with Tints and with Hue Shift (black is its key color), and stays black with Tints and Shades.
#[test]
fn each_tips_sample_paints_the_brush_with_its_method() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
let key: vectorcraft_color::Color = serde_json::from_value(d.fields["keyColor"].clone()).unwrap();
let colors = |id: &str| -> Vec<String> {
let method = vectorcraft_brush::Colorization::parse(id, key).unwrap();
let doc = brush_art_options::tip_doc(&brush_art_options::edited_def(d), "scatter", method, 120.0, 44.0).unwrap();
let mut hex: Vec<String> = vectorcraft_brush::art_colors(doc.layers.iter().map(|l| l.as_ref())).iter().map(|c| c.to_hex()).collect();
hex.sort();
hex
};
let (black, blue) = ("#000000".to_string(), brush_art_options::TIP_STROKE.to_hex());
let confetti = vectorcraft_brush::defaults().iter().find(|b| b.name == "Confetti").unwrap();
let art: Vec<String> = vectorcraft_brush::art_colors(confetti.art()).iter().map(|c| c.to_hex()).collect();
let none = colors("none");
assert!(none.len() == 2 && art.iter().all(|c| none.contains(c)), "None keeps the art's colors {art:?}: {none:?}");
let (tints, shades, hue) = (colors("tints"), colors("tintsAndShades"), colors("hueShift"));
assert!(tints.contains(&blue) && !tints.contains(&black), "{tints:?}");
assert!(shades.contains(&black) && !shades.contains(&blue), "{shades:?}");
assert!(hue.contains(&blue) && !hue.contains(&black), "{hue:?}");
assert!(tints != hue && tints != shades && none != shades, "four different samples");
}
/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not
/// an object, Brush Options and Colorization Tips still draw.
#[test]
fn a_brush_definition_that_is_not_an_object_still_draws() {
let (mut app, ctx) = (app(), ctx());
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5}));
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true}));
let text = shown(&mut app);
assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}");
}
#[test]
fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap();
let text = shown(&mut app);
for label in [
"Pattern Brush Options",
"Scale:",
"Spacing:",
"Outer Corner Tile",
"Side Tile",
"Inner Corner Tile",
"Start Tile",
"End Tile",
"None",
"Fit",
"Stretch to fit",
"Add space to fit",
"Approximate path",
"Flip Along",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect();
tiles.sort();
assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end");
// Diamonds (straight-edged tiles, so sizes compare exactly).
let (id, before) = brushed(&mut app, "Diamonds");
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into()));
set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap();
assert_eq!(
(&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]),
(&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true))
);
assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size");
}
/// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their
/// field, which screen readers name as the row, with the field's number and text over the
/// value's whole range.
#[test]
fn art_width_and_pattern_scale_have_sliders() {
use crate::widgets::accessibility_tests::{slider_value, tree};
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
set(&mut app, json!({"width": 250}));
let shown = tree(|ui| super::super::show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Width:"), (Some(250.0), Some(1.0), Some(1000.0), Some("250%".into())));
app.ui.dialog = None;
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
let shown = tree(|ui| super::super::show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Scale:"), (Some(100.0), Some(1.0), Some(10000.0), Some("100%".into())));
}
}
mod tests;
@@ -0,0 +1,467 @@
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app
}
fn set(app: &mut VectorcraftApp, fields: Value) {
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
}
fn shown(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let text = shown(&mut app);
for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0));
// Out-of-range values are kept within their ranges.
set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();
assert_eq!(def["modes"], json!(["random", "fixed", "pressure"]));
assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0)));
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0));
}
#[test]
fn a_taken_name_keeps_the_dialog_open() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"name": "3 pt. Round"}));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
#[test]
fn bristle_options_and_an_unknown_brush() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
let text = shown(&mut app);
for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] {
assert!(text.contains(label), "{label} in {text}");
}
set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap();
assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0)));
// No such brush: an error, nothing opens.
app.ui.dialog = None;
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
assert!(app.ui.dialog.is_none());
}
/// Bristle Brush Options' Name field takes at most 31 characters as they are typed; the
/// Calligraphic Brush Options' Name field takes more.
#[test]
fn bristle_brush_options_name_field_takes_at_most_31_characters() {
let mut app = app();
let typed = "é".repeat(40);
for (brush, kept) in [("Bristle Round", 31), ("6 pt. Flat", 40)] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
};
app.run("ui.brushOptions", json!({ "name": brush })).unwrap();
// The Name field takes the focus with its text selected; typing replaces it.
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text(typed.clone())]);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "é".repeat(kept), "{brush}");
super::super::confirm(&mut app).unwrap();
assert!(app.run("brush.get", json!({ "name": "é".repeat(kept) })).is_ok(), "{brush}");
}
// A new name of more than 31 characters in the field (a longer saved name with some of its
// characters deleted) is refused, and the dialog stays open.
app.run("ui.brushOptions", json!({ "name": "é".repeat(31) })).unwrap();
set(&mut app, json!({ "name": "é".repeat(35) }));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
/// A path painted with brush `name`, and the height of the art its stroke draws.
fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) {
let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap();
(id, drawn_height(app, id))
}
fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 {
let st = app.session.active().unwrap();
let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap();
vectorcraft_brush::pieces_bounds(&pieces).unwrap().height()
}
#[test]
fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() {
let mut app = app();
let (id, before) = brushed(&mut app, "Confetti");
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let text = shown(&mut app);
for label in [
"Scatter Brush Options",
"Name:",
"Size:",
"Spacing:",
"Scatter:",
"Rotation:",
"Random",
"Rotation relative to:",
"Page",
"Colorization",
"Method:",
"Tints",
"Key Color:",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into()));
assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into()));
// Values are kept within their ranges (rotation here); Fixed takes the first value.
set(
&mut app,
json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}),
);
assert!(shown(&mut app).contains("Hue Shift"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap();
assert_eq!(
(&def["size"], &def["rotation"][0], &def["modes"]),
(&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"]))
);
assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift")));
assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}");
assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti");
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into()));
}
#[test]
fn brush_key_color_eyedropper_updates_the_open_dialog() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = app();
app.run("paint.setFill", json!({"color": "#336699"})).unwrap();
app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap();
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
set(&mut app, json!({"colorization": "hueShift"}));
let back = app.session.tool_id();
app.select_tool("eyedropper");
app.session.set_tool_option("brushKeyColor", &json!(true));
app.session.set_tool_option("stop", &json!(back));
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Down, 80.0, 70.0), view);
let key = app.ui.dialog.as_ref().unwrap().fields.get("keyColor").cloned().unwrap();
assert_eq!(vectorcraft_engine::cmd::color_value(&key).unwrap().to_hex(), "#336699");
assert_eq!(app.session.tool_id(), back);
}
#[test]
fn art_options_set_width_scale_direction_flips_and_colorization() {
let mut app = app();
let (id, before) = brushed(&mut app, "Arrow");
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
let text = shown(&mut app);
for label in [
"Art Brush Options",
"Width:",
"Brush Scale Options",
"Scale Proportionately",
"Stretch to Fit Stroke Length",
"Stretch Between Guides",
"Start:",
"End:",
"Direction:",
"Flip Along",
"Flip Across",
"Colorization",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")),
(100.0, "betweenGuides".into(), 78.0, "leftToRight".into())
);
set(
&mut app,
json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}),
);
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap();
assert_eq!(
(&def["width"], &def["scale"], &def["direction"]),
(&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop"))
);
assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"})));
assert!(drawn_height(&app, id) != before, "the stroke redraws");
// Stretch to Fit drops the guides.
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
set(&mut app, json!({"scaleMode": "stretch"}));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"}));
}
/// Art Brush Options' Width menu: Pressure takes a second width for the heaviest pen pressure,
/// and a stroke drawn without pressure is half way between the two; Fixed drops the second width.
#[test]
fn art_options_width_follows_the_pen_pressure() {
let mut app = app();
let (id, before) = brushed(&mut app, "Tapered Stroke");
app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("fixed".into(), 100.0));
assert!(shown(&mut app).contains("Fixed"));
set(&mut app, json!({"widthMode": "pressure", "widthMax": 300}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap();
assert_eq!((&def["width"], &def["width_mode"], &def["width_max"]), (&json!(100.0), &json!("pressure"), &json!(300.0)));
let after = drawn_height(&app, id);
assert!((after - 2.0 * before).abs() < 0.01 * before, "200 % half way: {before} then {after}");
// Opened again, it shows what was set; Fixed drops the second width.
app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("pressure".into(), 300.0));
set(&mut app, json!({"widthMode": "fixed"}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap();
assert!(def.get("width_mode").is_none() && def.get("width_max").is_none(), "{def}");
assert!((drawn_height(&app, id) - before).abs() < 1e-6);
}
/// A context with the app's fonts and theme, for [`frame`].
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
/// Runs one 1200 × 900 frame with `events` through the app's key handling and the dialogs, and
/// returns the center of the text `label` if the frame shows it.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, label: &str) -> Option<egui::Pos2> {
use egui::{Pos2, Rect};
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::show(app, ui.ctx());
});
out.textures_delta.clear();
out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == label => Some(egui::Rect::from_min_size(t.pos, t.galley.size()).center()),
_ => None,
})
}
/// Clicks the text `label` the open dialog shows.
fn click_text(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) {
use egui::{Event, PointerButton};
frame(ctx, app, vec![], label);
let at = frame(ctx, app, vec![], label).unwrap_or_else(|| panic!("no {label:?} shown"));
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(ctx, app, vec![Event::PointerMoved(at), button(true)], label);
frame(ctx, app, vec![button(false)], label);
frame(ctx, app, vec![], label);
}
/// Presses Escape.
fn escape(ctx: &egui::Context, app: &mut VectorcraftApp) {
let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(ctx, app, vec![key], "");
frame(ctx, app, vec![], "");
}
/// Scatter, Art and Pattern Brush Options have a Tips button beside Method. A click on it opens
/// Colorization Tips, which names each method and what it does beside a sample of the brush,
/// and a second click closes it. Screen readers name the button, the popover and each sample.
#[test]
fn tips_names_each_colorization_method_beside_a_sample() {
use crate::widgets::accessibility_tests::{role_and_state, tree};
use egui::accesskit::Role;
let mut app = app();
for name in ["Confetti", "Arrow", "Chain"] {
app.run("ui.brushOptions", json!({ "name": name })).unwrap();
let text = shown(&mut app);
assert!(text.lines().any(|l| l == "Tips") && !text.contains("Colorization Tips"), "{name}: {text}");
}
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let ctx = ctx();
click_text(&ctx, &mut app, "Tips");
assert!(app.ui.dialog.as_ref().unwrap().bool("tips"), "Tips opens Colorization Tips");
let text = shown(&mut app);
for label in [
"Colorization Tips",
"The samples show this brush on a blue stroke.",
"None",
"The art keeps its own colors.",
"Tints",
"Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness.",
"Tints and Shades",
"Hue Shift",
"The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness.",
"Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it.",
] {
assert!(text.contains(label), "{label} in {text}");
}
let nodes = tree(|ui| super::super::show(&mut app, ui.ctx()));
assert_eq!(role_and_state(&nodes, "Tips").0, Role::Button);
assert_eq!(role_and_state(&nodes, "Colorization Tips").0, Role::Dialog);
for method in ["None", "Tints", "Tints and Shades", "Hue Shift"] {
assert!(nodes.iter().any(|n| n.role() == Role::Image && n.label() == Some(method)), "a sample named {method}");
}
click_text(&ctx, &mut app, "Tips");
assert!(!app.ui.dialog.as_ref().unwrap().bool("tips"), "a second click closes it");
assert!(!shown(&mut app).contains("Colorization Tips"));
// `ui.dialog.set {field: "tips", value: true}` opens it too.
set(&mut app, json!({"tips": true}));
assert!(shown(&mut app).contains("Colorization Tips"));
}
/// Escape with Colorization Tips or the Method list open closes only that: Brush Options stays
/// open with its edits, and the next Escape cancels it.
#[test]
fn escape_closes_tips_or_the_method_list_and_leaves_the_dialog_open() {
let (mut app, ctx) = (app(), ctx());
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
set(&mut app, json!({"colorization": "tints"}));
click_text(&ctx, &mut app, "Tips");
assert!(app.ui.dialog.as_ref().unwrap().bool("tips"));
escape(&ctx, &mut app);
let d = app.ui.dialog.as_ref().expect("Escape closes Tips, not Brush Options");
assert!(!d.bool("tips") && d.str("colorization") == "tints");
assert!(frame(&ctx, &mut app, vec![], "Colorization Tips").is_none());
click_text(&ctx, &mut app, "Tints");
assert!(egui::Popup::is_any_open(&ctx), "the Method list is open");
escape(&ctx, &mut app);
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.str("colorization") == "tints"), "Escape closes the list, not Brush Options");
assert!(!egui::Popup::is_any_open(&ctx));
escape(&ctx, &mut app);
assert!(app.ui.dialog.is_none(), "the next Escape cancels Brush Options");
// Calligraphic Brush Options has no Tips: with `tips` set, Escape still cancels it.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"tips": true}));
escape(&ctx, &mut app);
assert!(app.ui.dialog.is_none(), "Escape cancels Calligraphic Brush Options");
}
/// Each Colorization Tips sample paints the brush with its own method on the blue stroke:
/// Confetti's black and gray art keeps its colors with None, its black becomes the stroke color
/// with Tints and with Hue Shift (black is its key color), and stays black with Tints and Shades.
#[test]
fn each_tips_sample_paints_the_brush_with_its_method() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
let key: vectorcraft_color::Color = serde_json::from_value(d.fields["keyColor"].clone()).unwrap();
let colors = |id: &str| -> Vec<String> {
let method = vectorcraft_brush::Colorization::parse(id, key).unwrap();
let doc = brush_art_options::tip_doc(&brush_art_options::edited_def(d), "scatter", method, 120.0, 44.0).unwrap();
let mut hex: Vec<String> = vectorcraft_brush::art_colors(doc.layers.iter().map(|l| l.as_ref())).iter().map(|c| c.to_hex()).collect();
hex.sort();
hex
};
let (black, blue) = ("#000000".to_string(), brush_art_options::TIP_STROKE.to_hex());
let confetti = vectorcraft_brush::defaults().iter().find(|b| b.name == "Confetti").unwrap();
let art: Vec<String> = vectorcraft_brush::art_colors(confetti.art()).iter().map(|c| c.to_hex()).collect();
let none = colors("none");
assert!(none.len() == 2 && art.iter().all(|c| none.contains(c)), "None keeps the art's colors {art:?}: {none:?}");
let (tints, shades, hue) = (colors("tints"), colors("tintsAndShades"), colors("hueShift"));
assert!(tints.contains(&blue) && !tints.contains(&black), "{tints:?}");
assert!(shades.contains(&black) && !shades.contains(&blue), "{shades:?}");
assert!(hue.contains(&blue) && !hue.contains(&black), "{hue:?}");
assert!(tints != hue && tints != shades && none != shades, "four different samples");
}
/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not
/// an object, Brush Options and Colorization Tips still draw.
#[test]
fn a_brush_definition_that_is_not_an_object_still_draws() {
let (mut app, ctx) = (app(), ctx());
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5}));
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true}));
let text = shown(&mut app);
assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}");
}
#[test]
fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap();
let text = shown(&mut app);
for label in [
"Pattern Brush Options",
"Scale:",
"Spacing:",
"Outer Corner Tile",
"Side Tile",
"Inner Corner Tile",
"Start Tile",
"End Tile",
"None",
"Fit",
"Stretch to fit",
"Add space to fit",
"Approximate path",
"Flip Along",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect();
tiles.sort();
assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end");
// Diamonds (straight-edged tiles, so sizes compare exactly).
let (id, before) = brushed(&mut app, "Diamonds");
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into()));
set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap();
assert_eq!(
(&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]),
(&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true))
);
assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size");
}
/// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their
/// field, which screen readers name as the row, with the field's number and text over the
/// value's whole range.
#[test]
fn art_width_and_pattern_scale_have_sliders() {
use crate::widgets::accessibility_tests::{slider_value, tree};
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
set(&mut app, json!({"width": 250}));
let shown = tree(|ui| super::super::show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Width:"), (Some(250.0), Some(1.0), Some(1000.0), Some("250%".into())));
app.ui.dialog = None;
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
let shown = tree(|ui| super::super::show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Scale:"), (Some(100.0), Some(1.0), Some(10000.0), Some("100%".into())));
}