refactor(ui-egui): move panels/appearance.rs tests to panels/appearance/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:17 +00:00
parent 6ceee3e8e7
commit dd22bf4b1d
2 changed files with 540 additions and 541 deletions
+1 -541
View File
@@ -1023,544 +1023,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opacity_labels() {
assert_eq!(opacity_text(1.0, BlendMode::Normal), "Default");
assert_eq!(opacity_text(0.5, BlendMode::Normal), "50%");
assert_eq!(opacity_text(0.5, BlendMode::Multiply), "50% Multiply");
}
#[test]
fn labels() {
assert_eq!(param_label("offsetX", true), "Offset X");
assert_eq!(param_label("blur", true), "Blur");
assert!(!effect_label("stylize.dropShadow").contains('…'));
assert_eq!(effect_label("path.outlineObject"), "Outline Object");
assert_eq!(effect_label("x.unknownThing"), "unknownThing");
// Only our own effects are interface labels; a plug-in's name is its own.
assert!(built_in_effect("stylize.dropShadow") && !built_in_effect("plugin.example.glow"));
assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow"));
}
/// A Drop Shadow's inline editor has Color and Darkness as radio buttons in its Shade row; a
/// click on Darkness sets the effect's `shade`.
#[test]
fn the_drop_shadow_editor_chooses_color_or_darkness() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out
};
run(&mut app, vec![]);
let out = run(&mut app, vec![]);
// The Shade row's Darkness, below the Darkness value's own row.
fn find(s: &egui::Shape, out: &mut Vec<egui::Pos2>) {
match s {
egui::Shape::Text(t) if t.galley.text() == "Darkness" => out.push(t.visual_bounding_rect().center()),
egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, out)),
_ => {}
}
}
let mut found = vec![];
out.shapes.iter().for_each(|c| find(&c.shape, &mut found));
assert_eq!(found.len(), 2, "{found:?}");
let at = found.into_iter().max_by(|a, b| a.y.total_cmp(&b.y)).unwrap();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness"));
}
/// A rectangle with `effect` applied, its inline editor open, after a first frame in `ctx`.
fn editing(effect: &str) -> (VectorcraftApp, egui::Context, NodeId) {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": effect})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
panel_frame(&mut app, &ctx, vec![]);
(app, ctx, NodeId(id))
}
/// One frame of the panel in `ctx` with `events`: the texts drawn and where.
fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, Rect)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let time = ctx.input(|i| i.time) + 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
/// Clicks the text `label` drawn in the panel (the last one drawn when there are several).
fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
let shown = panel_frame(app, ctx, vec![]);
let at = shown.iter().rev().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
panel_frame(app, ctx, events);
}
}
fn shown_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
panel_frame(app, ctx, vec![]).into_iter().map(|(s, _)| s).collect()
}
/// Roughen's Points and Offset Path's Joins are dropdowns with the effect dialog's labels, and
/// a pick sends the dialog's value as one undo step.
#[test]
fn the_effect_editor_picks_choices_from_the_dialogs_dropdowns() {
for (effect, key, shown, pick, value) in
[("distort.roughen", "points", "Smooth", "Corner", "corner"), ("path.offsetPath", "joins", "Miter", "Round", "round")]
{
let (mut app, ctx, id) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == shown), "{effect}: {texts:?}");
let undo = app.session.doc().unwrap().history.undo.len();
click_text(&mut app, &ctx, shown);
click_text(&mut app, &ctx, pick);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect}");
assert_eq!(st.history.undo.len(), undo + 1, "{effect}");
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect}");
}
}
/// The editor labels a parameter as the effect dialog does, without the ":".
#[test]
fn the_effect_editor_labels_parameters_as_the_dialog_does() {
for (effect, labels) in [
("pixelate.colorHalftone", &["Max. Radius", "Channel 1", "Channel 4"][..]),
("video.deinterlace", &["Create New Fields by", "Duplication", "Eliminate", "Odd Fields"][..]),
] {
let (mut app, ctx, _) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
for label in labels {
assert!(texts.iter().any(|s| s == label), "{effect}: no {label:?} in {texts:?}");
}
}
}
/// A plug-in's choice is a dropdown of the plug-in's own values, also when the parameter is
/// called `mode` and its value names a blend mode.
#[test]
fn a_plug_in_choice_lists_the_plug_ins_values() {
use vectorcraft_plugins::wat as tpl;
let params = r#"{"mode":{"type":"choice","options":["normal","screen","dots"],"default":"screen"}}"#;
let src = tpl::module(&tpl::manifest("test.ui.inlinechoice", "effect", params), tpl::ECHO, "", 1);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinechoice"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
panel_frame(&mut app, &ctx, vec![]);
click_text(&mut app, &ctx, "screen");
click_text(&mut app, &ctx, "dots");
let mode = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["mode"].clone();
assert_eq!(mode(&app), json!("dots"));
click_text(&mut app, &ctx, "dots");
click_text(&mut app, &ctx, "screen");
assert_eq!(mode(&app), json!("screen"));
// Once the plug-in is removed, its value shows as it is, not as a blend mode.
app.run("plugin.remove", json!({"id": "test.ui.inlinechoice"})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "screen") && !texts.iter().any(|s| s == "Screen"), "{texts:?}");
}
/// A plug-in effect's editor labels its parameters, shows their units and leaves out those whose
/// `visibleWhen` isn't met, as the effect's dialog does, and reads a typed number list the same
/// way.
#[test]
fn a_plug_in_effect_s_editor_follows_its_manifest() {
use vectorcraft_plugins::wat as tpl;
let params = r#"{"outline":{"type":"bool","default":true,"label":"Outline"},
"spacing":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","help":"Distance between the lines."},
"angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"unit":"°"},
"width":{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","visibleWhen":{"outline":[true]}}}"#;
let src = tpl::module(&tpl::manifest("test.ui.inlinekeys", "effect", params), tpl::ECHO, "", 1);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinekeys"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let texts = shown_texts(&mut app, &ctx);
for shown in ["Spacing", "1.5 mm", "Angles", "45, 135", "°", "Width", "0.5 pt"] {
assert!(texts.iter().any(|s| s == shown), "no {shown:?} in {texts:?}");
}
assert!(!texts.iter().any(|s| s == "spacing" || s == "width"), "the labels replace the names: {texts:?}");
// Without the outline, its width isn't shown.
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"outline": false}})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(!texts.iter().any(|s| s == "Width") && texts.iter().any(|s| s == "Spacing"), "{texts:?}");
// A list typed in the field: separated by a semicolon and a space, and clamped.
click_text(&mut app, &ctx, "45, 135");
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
panel_frame(&mut app, &ctx, vec![egui::Event::Text("10; 400 20".into())]);
panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
panel_frame(&mut app, &ctx, vec![]);
let angles = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["angles"].clone();
assert_eq!(angles, json!([10.0, 360.0, 20.0]));
app.run("plugin.remove", json!({"id": "test.ui.inlinekeys"})).unwrap();
}
/// Glass with a loaded texture lists Loaded Texture in its Texture menu, as its dialog does.
#[test]
fn the_effect_editor_lists_a_loaded_glass_texture() {
let mut png = vec![];
image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 120, 150, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let stored = vectorcraft_engine::cmd::fileio::texture_image(&png).unwrap();
let (mut app, ctx, id) = editing("distort.glass");
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"texture": "loaded", "textureImage": stored}})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "Loaded Texture") && !texts.iter().any(|s| s == "loaded"), "{texts:?}");
click_text(&mut app, &ctx, "Loaded Texture");
click_text(&mut app, &ctx, "Frosted");
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params["texture"], json!("frosted"));
}
/// The choices that their dialogs list in their own bodies are dropdowns with those dialogs'
/// labels too: Revolve's edge, Extrude & Bevel's bevel, Warp's orientation, Rasterize's
/// options and the channel of Levels and Curves. A pick sends the dialog's value as one undo
/// step.
#[test]
fn the_effect_editor_picks_choices_their_own_dialogs_list() {
for (effect, key, shown, pick, value) in [
("threeD.revolve", "edge", "Left", "Right", "right"),
("threeD.extrude", "bevel", "None", "Rounded", "rounded"),
("warp.arc", "orientation", "Horizontal", "Vertical", "vertical"),
("rasterize", "colorModel", "RGB", "Grayscale", "grayscale"),
("rasterize", "background", "Transparent", "White", "white"),
("rasterize", "antiAlias", "Art Optimized", "Type Optimized", "type"),
("adjust.levels", "channel", "RGB", "Red", "red"),
("adjust.curves", "channel", "RGB", "Blue", "blue"),
] {
let (mut app, ctx, id) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == shown), "{effect} {key}: {texts:?}");
let undo = app.session.doc().unwrap().history.undo.len();
click_text(&mut app, &ctx, shown);
click_text(&mut app, &ctx, pick);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect} {key}");
assert_eq!(st.history.undo.len(), undo + 1, "{effect} {key}");
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect} {key}");
}
}
/// Those choices read as their dialogs show them in the UI language: in French, the channel's
/// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d"
/// row does, not the plain row. Rasterize names the document's color mode.
#[test]
fn the_effect_editor_shows_those_choices_as_their_dialogs_do() {
let fr = crate::i18n::Lang::from_code("fr").unwrap();
crate::i18n::set_current(fr);
let (mut app, ctx, _) = editing("adjust.levels");
let texts = shown_texts(&mut app, &ctx);
let rgb = crate::i18n::tr_ctx(fr, "effect", "RGB");
assert!(texts.iter().any(|s| s == rgb), "{rgb}: {texts:?}");
let es = crate::i18n::Lang::from_code("es").unwrap();
crate::i18n::set_current(es);
let (mut app, ctx, _) = editing("threeD.extrude");
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"bevel": "rounded"}})).unwrap();
let rounded = crate::i18n::tr_ctx(es, "3d", "Rounded");
assert_ne!(rounded, crate::i18n::tr(es, "Rounded"));
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == rounded), "{rounded}");
crate::i18n::set_current(crate::i18n::Lang::EN);
let (mut app, ctx, _) = editing("rasterize");
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "CMYK") && !texts.iter().any(|s| s == "document"), "{texts:?}");
}
#[test]
fn the_effect_editor_lists_earlier_parameters_in_their_current_form() {
// A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations.
let e = Effect { id: "stylize.scribble".into(), params: json!({"variation": 2, "angle": 270}), visible: true };
let p = editor_params(&e);
assert!(!p.contains_key("variation"), "{p:?}");
assert_eq!((&p["overlapVariation"], &p["spacingVariation"], &p["curvinessVariation"]), (&json!(2), &json!(2), &json!(0.0)));
assert_eq!(p["angle"].as_f64(), Some(-90.0));
// Another effect lists its values over its defaults, as before.
let e = Effect { id: "distort.twist".into(), params: json!({"angle": 270}), visible: true };
assert_eq!(editor_params(&e).get("angle"), Some(&json!(270)));
}
/// A text field of the inline editor keeps what is typed from one frame to the next: a Drop
/// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step.
/// Escape drops what is typed.
#[test]
fn the_effect_editor_keeps_text_typed_across_frames() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out
};
// Every text drawn, with its center.
fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let drawn = |out: &egui::FullOutput| {
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
};
run(&mut app, vec![]);
let shown = drawn(&run(&mut app, vec![]));
let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p);
let Some(at) = at else { panic!("no color field: {shown:?}") };
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
let undo = app.session.active().unwrap().history.undo.len();
for ch in "#ff0000".chars() {
run(&mut app, vec![egui::Event::Text(ch.to_string())]);
}
let shown = drawn(&run(&mut app, vec![]));
assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}");
run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
run(&mut app, vec![]);
let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone();
assert_eq!(color(&app), json!("#ff0000"));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
// Escape drops what is typed.
for events in [vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
run(&mut app, vec![egui::Event::Text("0".into())]);
let shown = drawn(&run(&mut app, vec![]));
assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}");
run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
run(&mut app, vec![]);
assert_eq!(color(&app), json!("#ff0000"));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn drops_pick_the_command() {
use vectorcraft_doc::{Appearance, FillLayer};
let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true };
let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art());
n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F]
n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]);
n.appearance.effects.push(fx("stylize.dropShadow"));
let slots = [
Slot::Object { top: true },
Slot::Item { index: 2, sub: false },
Slot::Item { index: 1, sub: false },
Slot::Effect(Some(1), 0),
Slot::Effect(Some(1), 1),
Slot::Item { index: 0, sub: false },
Slot::Effect(None, 0),
Slot::Object { top: false },
];
let rows: Vec<(Slot, Rect)> =
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
// The upper or lower half of row `i`.
let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 });
let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy);
// Fill/stroke rows: moves, Alt copies to where it was dropped.
assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2}))));
assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3}))));
assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0}))));
assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect");
// Effects: within the list (below the next one), into a fill, out onto the object.
let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| {
Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy})))
};
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false));
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself");
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true));
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false));
assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false));
assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack");
}
/// One headless frame of the panel: the texts drawn, top to bottom.
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) {
match s {
egui::Shape::Text(t) => out.push((t.pos.y, t.galley.text().to_string())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.into_iter().map(|(_, t)| t).collect()
}
#[test]
fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
run(&mut app, "object.group", json!({}));
run(&mut app, "appearance.addFill", json!({}));
run(&mut app, "appearance.addStroke", json!({}));
let rows =
|t: Vec<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::<Vec<_>>();
assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]);
run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1}));
assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]);
let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [t]}));
assert_eq!(rows(frame(&mut app)), ["Characters"]);
run(&mut app, "appearance.addFill", json!({}));
assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]);
}
#[test]
fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let rows = |t: Vec<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents"].contains(&t.as_str())).collect::<Vec<_>>();
let mut png = vec![];
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
run(&mut app, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png)}));
assert_eq!(rows(frame(&mut app)), ["Contents"]);
run(&mut app, "appearance.addStroke", json!({}));
assert_eq!(rows(frame(&mut app)), ["Contents", "Stroke:"]);
}
#[test]
fn drops_move_items_across_the_contents_row_and_the_row_itself() {
use vectorcraft_doc::FillLayer;
// A group [F0 | F1 F2]: the Contents row sits above item 0.
let mut n = Node::group(NodeId(1), vec![]);
n.appearance.items = (0..3).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect();
n.appearance.set_contents_at(1);
let slots = [
Slot::Object { top: true },
Slot::Item { index: 2, sub: false },
Slot::Item { index: 1, sub: false },
Slot::Contents,
Slot::Item { index: 0, sub: false },
Slot::Object { top: false },
];
let rows: Vec<(Slot, Rect)> =
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0);
let drop = |what, i| drop_command(&n, &rows, what, at(i), false);
// Onto the Contents row: from above it lands just below, from below just above.
assert_eq!(drop(Dragged::Item(2), 3), Some(("appearance.moveItem", json!({"from": 2, "to": 1, "contents": 2}))));
assert_eq!(drop(Dragged::Item(0), 3), Some(("appearance.moveItem", json!({"from": 0, "to": 0, "contents": 0}))));
// Onto the object row: above everything; under the stack: below everything.
assert_eq!(drop(Dragged::Item(0), 0), Some(("appearance.moveItem", json!({"from": 0, "to": 2, "contents": 0}))));
assert_eq!(drop(Dragged::Item(2), 5), Some(("appearance.moveItem", json!({"from": 2, "to": 0, "contents": 2}))));
// The row itself: above item 2, below item 0; nowhere new on itself.
assert_eq!(drop(Dragged::Contents, 1), Some(("appearance.moveItem", json!({"from": "contents", "to": 3}))));
assert_eq!(drop(Dragged::Contents, 4), Some(("appearance.moveItem", json!({"from": "contents", "to": 0}))));
assert_eq!(drop(Dragged::Contents, 3), None);
assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2}))));
}
#[test]
fn with_nothing_selected_it_lists_what_new_art_gets() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
run(&mut app, "appearance.addFill", json!({}));
run(&mut app, "effect.apply", json!({"effect": "stylize.dropShadow"}));
run(&mut app, "transparency.set", json!({"opacity": 50}));
run(&mut app, "graphicStyle.new", json!({"name": "Fancy"}));
run(&mut app, "select.none", json!({}));
let shown = frame(&mut app);
assert!(shown.contains(&"No Selection".to_string()) && shown.contains(&"Opacity: Default".to_string()), "{shown:?}");
// A style clicked with nothing selected: the rows show what the next object gets.
run(&mut app, "graphicStyle.apply", json!({"name": "Fancy"}));
run(&mut app, "stroke.set", json!({"dash": [3, 3]}));
let shown = frame(&mut app);
let rows: Vec<&String> = shown.iter().filter(|t| ["Fill:", "Stroke:"].contains(&t.as_str())).collect();
assert_eq!(rows, ["Fill:", "Stroke:", "Fill:"], "the added fill is on top");
for want in ["No Selection: Fancy".to_string(), effect_label("stylize.dropShadow"), "Opacity: 50%".into(), "Dashed".into()] {
assert!(shown.contains(&want), "{want} in {shown:?}");
}
}
/// Both stroke rows expose named Weight inputs, and the drop-shadow effect names its Blur input and
/// Color radio button.
#[test]
fn inline_stroke_weight_and_effect_fields_are_named() {
use egui_kittest::kittest::Queryable;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("appearance.addStroke", json!({})).unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let mut first = true;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 760.0)).with_accessibility_check(false).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if std::mem::replace(&mut first, false) {
crate::theme::install_fonts(ui.ctx());
set_pstate(ui.ctx(), "ap-fx-open-None-0", true);
}
show(app, ui);
},
app,
);
h.run_steps(4);
assert_eq!(h.get_all_by_role_and_label(egui::accesskit::Role::TextInput, "Weight:").count(), 2, "both stroke rows are named");
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Blur");
let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color");
}
}
mod tests;
@@ -0,0 +1,539 @@
use super::*;
#[test]
fn opacity_labels() {
assert_eq!(opacity_text(1.0, BlendMode::Normal), "Default");
assert_eq!(opacity_text(0.5, BlendMode::Normal), "50%");
assert_eq!(opacity_text(0.5, BlendMode::Multiply), "50% Multiply");
}
#[test]
fn labels() {
assert_eq!(param_label("offsetX", true), "Offset X");
assert_eq!(param_label("blur", true), "Blur");
assert!(!effect_label("stylize.dropShadow").contains('…'));
assert_eq!(effect_label("path.outlineObject"), "Outline Object");
assert_eq!(effect_label("x.unknownThing"), "unknownThing");
// Only our own effects are interface labels; a plug-in's name is its own.
assert!(built_in_effect("stylize.dropShadow") && !built_in_effect("plugin.example.glow"));
assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow"));
}
/// A Drop Shadow's inline editor has Color and Darkness as radio buttons in its Shade row; a
/// click on Darkness sets the effect's `shade`.
#[test]
fn the_drop_shadow_editor_chooses_color_or_darkness() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out
};
run(&mut app, vec![]);
let out = run(&mut app, vec![]);
// The Shade row's Darkness, below the Darkness value's own row.
fn find(s: &egui::Shape, out: &mut Vec<egui::Pos2>) {
match s {
egui::Shape::Text(t) if t.galley.text() == "Darkness" => out.push(t.visual_bounding_rect().center()),
egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, out)),
_ => {}
}
}
let mut found = vec![];
out.shapes.iter().for_each(|c| find(&c.shape, &mut found));
assert_eq!(found.len(), 2, "{found:?}");
let at = found.into_iter().max_by(|a, b| a.y.total_cmp(&b.y)).unwrap();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness"));
}
/// A rectangle with `effect` applied, its inline editor open, after a first frame in `ctx`.
fn editing(effect: &str) -> (VectorcraftApp, egui::Context, NodeId) {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": effect})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
panel_frame(&mut app, &ctx, vec![]);
(app, ctx, NodeId(id))
}
/// One frame of the panel in `ctx` with `events`: the texts drawn and where.
fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, Rect)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let time = ctx.input(|i| i.time) + 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
/// Clicks the text `label` drawn in the panel (the last one drawn when there are several).
fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
let shown = panel_frame(app, ctx, vec![]);
let at = shown.iter().rev().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
panel_frame(app, ctx, events);
}
}
fn shown_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
panel_frame(app, ctx, vec![]).into_iter().map(|(s, _)| s).collect()
}
/// Roughen's Points and Offset Path's Joins are dropdowns with the effect dialog's labels, and
/// a pick sends the dialog's value as one undo step.
#[test]
fn the_effect_editor_picks_choices_from_the_dialogs_dropdowns() {
for (effect, key, shown, pick, value) in
[("distort.roughen", "points", "Smooth", "Corner", "corner"), ("path.offsetPath", "joins", "Miter", "Round", "round")]
{
let (mut app, ctx, id) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == shown), "{effect}: {texts:?}");
let undo = app.session.doc().unwrap().history.undo.len();
click_text(&mut app, &ctx, shown);
click_text(&mut app, &ctx, pick);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect}");
assert_eq!(st.history.undo.len(), undo + 1, "{effect}");
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect}");
}
}
/// The editor labels a parameter as the effect dialog does, without the ":".
#[test]
fn the_effect_editor_labels_parameters_as_the_dialog_does() {
for (effect, labels) in [
("pixelate.colorHalftone", &["Max. Radius", "Channel 1", "Channel 4"][..]),
("video.deinterlace", &["Create New Fields by", "Duplication", "Eliminate", "Odd Fields"][..]),
] {
let (mut app, ctx, _) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
for label in labels {
assert!(texts.iter().any(|s| s == label), "{effect}: no {label:?} in {texts:?}");
}
}
}
/// A plug-in's choice is a dropdown of the plug-in's own values, also when the parameter is
/// called `mode` and its value names a blend mode.
#[test]
fn a_plug_in_choice_lists_the_plug_ins_values() {
use vectorcraft_plugins::wat as tpl;
let params = r#"{"mode":{"type":"choice","options":["normal","screen","dots"],"default":"screen"}}"#;
let src = tpl::module(&tpl::manifest("test.ui.inlinechoice", "effect", params), tpl::ECHO, "", 1);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinechoice"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
panel_frame(&mut app, &ctx, vec![]);
click_text(&mut app, &ctx, "screen");
click_text(&mut app, &ctx, "dots");
let mode = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["mode"].clone();
assert_eq!(mode(&app), json!("dots"));
click_text(&mut app, &ctx, "dots");
click_text(&mut app, &ctx, "screen");
assert_eq!(mode(&app), json!("screen"));
// Once the plug-in is removed, its value shows as it is, not as a blend mode.
app.run("plugin.remove", json!({"id": "test.ui.inlinechoice"})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "screen") && !texts.iter().any(|s| s == "Screen"), "{texts:?}");
}
/// A plug-in effect's editor labels its parameters, shows their units and leaves out those whose
/// `visibleWhen` isn't met, as the effect's dialog does, and reads a typed number list the same
/// way.
#[test]
fn a_plug_in_effect_s_editor_follows_its_manifest() {
use vectorcraft_plugins::wat as tpl;
let params = r#"{"outline":{"type":"bool","default":true,"label":"Outline"},
"spacing":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","help":"Distance between the lines."},
"angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"unit":"°"},
"width":{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","visibleWhen":{"outline":[true]}}}"#;
let src = tpl::module(&tpl::manifest("test.ui.inlinekeys", "effect", params), tpl::ECHO, "", 1);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinekeys"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let texts = shown_texts(&mut app, &ctx);
for shown in ["Spacing", "1.5 mm", "Angles", "45, 135", "°", "Width", "0.5 pt"] {
assert!(texts.iter().any(|s| s == shown), "no {shown:?} in {texts:?}");
}
assert!(!texts.iter().any(|s| s == "spacing" || s == "width"), "the labels replace the names: {texts:?}");
// Without the outline, its width isn't shown.
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"outline": false}})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(!texts.iter().any(|s| s == "Width") && texts.iter().any(|s| s == "Spacing"), "{texts:?}");
// A list typed in the field: separated by a semicolon and a space, and clamped.
click_text(&mut app, &ctx, "45, 135");
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
panel_frame(&mut app, &ctx, vec![egui::Event::Text("10; 400 20".into())]);
panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
panel_frame(&mut app, &ctx, vec![]);
let angles = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["angles"].clone();
assert_eq!(angles, json!([10.0, 360.0, 20.0]));
app.run("plugin.remove", json!({"id": "test.ui.inlinekeys"})).unwrap();
}
/// Glass with a loaded texture lists Loaded Texture in its Texture menu, as its dialog does.
#[test]
fn the_effect_editor_lists_a_loaded_glass_texture() {
let mut png = vec![];
image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 120, 150, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let stored = vectorcraft_engine::cmd::fileio::texture_image(&png).unwrap();
let (mut app, ctx, id) = editing("distort.glass");
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"texture": "loaded", "textureImage": stored}})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "Loaded Texture") && !texts.iter().any(|s| s == "loaded"), "{texts:?}");
click_text(&mut app, &ctx, "Loaded Texture");
click_text(&mut app, &ctx, "Frosted");
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params["texture"], json!("frosted"));
}
/// The choices that their dialogs list in their own bodies are dropdowns with those dialogs'
/// labels too: Revolve's edge, Extrude & Bevel's bevel, Warp's orientation, Rasterize's
/// options and the channel of Levels and Curves. A pick sends the dialog's value as one undo
/// step.
#[test]
fn the_effect_editor_picks_choices_their_own_dialogs_list() {
for (effect, key, shown, pick, value) in [
("threeD.revolve", "edge", "Left", "Right", "right"),
("threeD.extrude", "bevel", "None", "Rounded", "rounded"),
("warp.arc", "orientation", "Horizontal", "Vertical", "vertical"),
("rasterize", "colorModel", "RGB", "Grayscale", "grayscale"),
("rasterize", "background", "Transparent", "White", "white"),
("rasterize", "antiAlias", "Art Optimized", "Type Optimized", "type"),
("adjust.levels", "channel", "RGB", "Red", "red"),
("adjust.curves", "channel", "RGB", "Blue", "blue"),
] {
let (mut app, ctx, id) = editing(effect);
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == shown), "{effect} {key}: {texts:?}");
let undo = app.session.doc().unwrap().history.undo.len();
click_text(&mut app, &ctx, shown);
click_text(&mut app, &ctx, pick);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect} {key}");
assert_eq!(st.history.undo.len(), undo + 1, "{effect} {key}");
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect} {key}");
}
}
/// Those choices read as their dialogs show them in the UI language: in French, the channel's
/// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d"
/// row does, not the plain row. Rasterize names the document's color mode.
#[test]
fn the_effect_editor_shows_those_choices_as_their_dialogs_do() {
let fr = crate::i18n::Lang::from_code("fr").unwrap();
crate::i18n::set_current(fr);
let (mut app, ctx, _) = editing("adjust.levels");
let texts = shown_texts(&mut app, &ctx);
let rgb = crate::i18n::tr_ctx(fr, "effect", "RGB");
assert!(texts.iter().any(|s| s == rgb), "{rgb}: {texts:?}");
let es = crate::i18n::Lang::from_code("es").unwrap();
crate::i18n::set_current(es);
let (mut app, ctx, _) = editing("threeD.extrude");
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"bevel": "rounded"}})).unwrap();
let rounded = crate::i18n::tr_ctx(es, "3d", "Rounded");
assert_ne!(rounded, crate::i18n::tr(es, "Rounded"));
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == rounded), "{rounded}");
crate::i18n::set_current(crate::i18n::Lang::EN);
let (mut app, ctx, _) = editing("rasterize");
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let texts = shown_texts(&mut app, &ctx);
assert!(texts.iter().any(|s| s == "CMYK") && !texts.iter().any(|s| s == "document"), "{texts:?}");
}
#[test]
fn the_effect_editor_lists_earlier_parameters_in_their_current_form() {
// A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations.
let e = Effect { id: "stylize.scribble".into(), params: json!({"variation": 2, "angle": 270}), visible: true };
let p = editor_params(&e);
assert!(!p.contains_key("variation"), "{p:?}");
assert_eq!((&p["overlapVariation"], &p["spacingVariation"], &p["curvinessVariation"]), (&json!(2), &json!(2), &json!(0.0)));
assert_eq!(p["angle"].as_f64(), Some(-90.0));
// Another effect lists its values over its defaults, as before.
let e = Effect { id: "distort.twist".into(), params: json!({"angle": 270}), visible: true };
assert_eq!(editor_params(&e).get("angle"), Some(&json!(270)));
}
/// A text field of the inline editor keeps what is typed from one frame to the next: a Drop
/// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step.
/// Escape drops what is typed.
#[test]
fn the_effect_editor_keeps_text_typed_across_frames() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out
};
// Every text drawn, with its center.
fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let drawn = |out: &egui::FullOutput| {
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
};
run(&mut app, vec![]);
let shown = drawn(&run(&mut app, vec![]));
let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p);
let Some(at) = at else { panic!("no color field: {shown:?}") };
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
let undo = app.session.active().unwrap().history.undo.len();
for ch in "#ff0000".chars() {
run(&mut app, vec![egui::Event::Text(ch.to_string())]);
}
let shown = drawn(&run(&mut app, vec![]));
assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}");
run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
run(&mut app, vec![]);
let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone();
assert_eq!(color(&app), json!("#ff0000"));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
// Escape drops what is typed.
for events in [vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
run(&mut app, vec![egui::Event::Text("0".into())]);
let shown = drawn(&run(&mut app, vec![]));
assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}");
run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
run(&mut app, vec![]);
assert_eq!(color(&app), json!("#ff0000"));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn drops_pick_the_command() {
use vectorcraft_doc::{Appearance, FillLayer};
let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true };
let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art());
n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F]
n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]);
n.appearance.effects.push(fx("stylize.dropShadow"));
let slots = [
Slot::Object { top: true },
Slot::Item { index: 2, sub: false },
Slot::Item { index: 1, sub: false },
Slot::Effect(Some(1), 0),
Slot::Effect(Some(1), 1),
Slot::Item { index: 0, sub: false },
Slot::Effect(None, 0),
Slot::Object { top: false },
];
let rows: Vec<(Slot, Rect)> =
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
// The upper or lower half of row `i`.
let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 });
let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy);
// Fill/stroke rows: moves, Alt copies to where it was dropped.
assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2}))));
assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3}))));
assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0}))));
assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect");
// Effects: within the list (below the next one), into a fill, out onto the object.
let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| {
Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy})))
};
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false));
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself");
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true));
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false));
assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false));
assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack");
}
/// One headless frame of the panel: the texts drawn, top to bottom.
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) {
match s {
egui::Shape::Text(t) => out.push((t.pos.y, t.galley.text().to_string())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.into_iter().map(|(_, t)| t).collect()
}
#[test]
fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
run(&mut app, "object.group", json!({}));
run(&mut app, "appearance.addFill", json!({}));
run(&mut app, "appearance.addStroke", json!({}));
let rows =
|t: Vec<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::<Vec<_>>();
assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]);
run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1}));
assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]);
let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [t]}));
assert_eq!(rows(frame(&mut app)), ["Characters"]);
run(&mut app, "appearance.addFill", json!({}));
assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]);
}
#[test]
fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let rows = |t: Vec<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents"].contains(&t.as_str())).collect::<Vec<_>>();
let mut png = vec![];
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
run(&mut app, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png)}));
assert_eq!(rows(frame(&mut app)), ["Contents"]);
run(&mut app, "appearance.addStroke", json!({}));
assert_eq!(rows(frame(&mut app)), ["Contents", "Stroke:"]);
}
#[test]
fn drops_move_items_across_the_contents_row_and_the_row_itself() {
use vectorcraft_doc::FillLayer;
// A group [F0 | F1 F2]: the Contents row sits above item 0.
let mut n = Node::group(NodeId(1), vec![]);
n.appearance.items = (0..3).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect();
n.appearance.set_contents_at(1);
let slots = [
Slot::Object { top: true },
Slot::Item { index: 2, sub: false },
Slot::Item { index: 1, sub: false },
Slot::Contents,
Slot::Item { index: 0, sub: false },
Slot::Object { top: false },
];
let rows: Vec<(Slot, Rect)> =
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0);
let drop = |what, i| drop_command(&n, &rows, what, at(i), false);
// Onto the Contents row: from above it lands just below, from below just above.
assert_eq!(drop(Dragged::Item(2), 3), Some(("appearance.moveItem", json!({"from": 2, "to": 1, "contents": 2}))));
assert_eq!(drop(Dragged::Item(0), 3), Some(("appearance.moveItem", json!({"from": 0, "to": 0, "contents": 0}))));
// Onto the object row: above everything; under the stack: below everything.
assert_eq!(drop(Dragged::Item(0), 0), Some(("appearance.moveItem", json!({"from": 0, "to": 2, "contents": 0}))));
assert_eq!(drop(Dragged::Item(2), 5), Some(("appearance.moveItem", json!({"from": 2, "to": 0, "contents": 2}))));
// The row itself: above item 2, below item 0; nowhere new on itself.
assert_eq!(drop(Dragged::Contents, 1), Some(("appearance.moveItem", json!({"from": "contents", "to": 3}))));
assert_eq!(drop(Dragged::Contents, 4), Some(("appearance.moveItem", json!({"from": "contents", "to": 0}))));
assert_eq!(drop(Dragged::Contents, 3), None);
assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2}))));
}
#[test]
fn with_nothing_selected_it_lists_what_new_art_gets() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
run(&mut app, "appearance.addFill", json!({}));
run(&mut app, "effect.apply", json!({"effect": "stylize.dropShadow"}));
run(&mut app, "transparency.set", json!({"opacity": 50}));
run(&mut app, "graphicStyle.new", json!({"name": "Fancy"}));
run(&mut app, "select.none", json!({}));
let shown = frame(&mut app);
assert!(shown.contains(&"No Selection".to_string()) && shown.contains(&"Opacity: Default".to_string()), "{shown:?}");
// A style clicked with nothing selected: the rows show what the next object gets.
run(&mut app, "graphicStyle.apply", json!({"name": "Fancy"}));
run(&mut app, "stroke.set", json!({"dash": [3, 3]}));
let shown = frame(&mut app);
let rows: Vec<&String> = shown.iter().filter(|t| ["Fill:", "Stroke:"].contains(&t.as_str())).collect();
assert_eq!(rows, ["Fill:", "Stroke:", "Fill:"], "the added fill is on top");
for want in ["No Selection: Fancy".to_string(), effect_label("stylize.dropShadow"), "Opacity: 50%".into(), "Dashed".into()] {
assert!(shown.contains(&want), "{want} in {shown:?}");
}
}
/// Both stroke rows expose named Weight inputs, and the drop-shadow effect names its Blur input and
/// Color radio button.
#[test]
fn inline_stroke_weight_and_effect_fields_are_named() {
use egui_kittest::kittest::Queryable;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("appearance.addStroke", json!({})).unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let mut first = true;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 760.0)).with_accessibility_check(false).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if std::mem::replace(&mut first, false) {
crate::theme::install_fonts(ui.ctx());
set_pstate(ui.ctx(), "ap-fx-open-None-0", true);
}
show(app, ui);
},
app,
);
h.run_steps(4);
assert_eq!(h.get_all_by_role_and_label(egui::accesskit::Role::TextInput, "Weight:").count(), 2, "both stroke rows are named");
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Blur");
let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color");
}