mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
refactor(ui-egui): move menus.rs tests to menus/tests.rs
This commit is contained in:
+1
-710
@@ -4442,713 +4442,4 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
|
||||
/// (rect, id of the title's popup).
|
||||
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut layer = None;
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
|
||||
layer = Some(ui.layer_id());
|
||||
menu_bar(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let layer = layer.unwrap();
|
||||
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
|
||||
vp.prev_pass
|
||||
.widgets
|
||||
.get_layer(layer)
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
|
||||
// `egui::Popup::default_response_id` of the title's response.
|
||||
.map(|w| (w.rect, w.id.with("popup")))
|
||||
.collect()
|
||||
});
|
||||
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
|
||||
titles
|
||||
}
|
||||
|
||||
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
|
||||
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
|
||||
}
|
||||
|
||||
/// File ends with Exit, running the app's own quit flow (unsaved documents are asked about).
|
||||
#[test]
|
||||
fn file_ends_with_exit() {
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap();
|
||||
assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}");
|
||||
}
|
||||
|
||||
/// No application menu in the window (as in Illustrator): the bar starts at File.
|
||||
#[test]
|
||||
fn the_bar_starts_at_file() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
menu_bar(&mut app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
|
||||
for t in titles {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_another_title_switches_the_open_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
let titles = bar_frame(&mut app, &ctx, vec![]);
|
||||
// No application menu in the window: the bar starts at File on every platform.
|
||||
assert_eq!(titles.len(), menu_tree().len() - 1);
|
||||
let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center());
|
||||
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut t = bar_frame(app, &ctx, events);
|
||||
for _ in 0..2 {
|
||||
t = bar_frame(app, &ctx, vec![]);
|
||||
}
|
||||
t
|
||||
};
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
|
||||
// Nothing open: hovering a title opens nothing.
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert!(open_titles(&ctx, &t).is_empty());
|
||||
|
||||
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
|
||||
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
|
||||
frames(&mut app, vec![button(file, true)]);
|
||||
let t = frames(&mut app, vec![button(file, false)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit");
|
||||
|
||||
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
|
||||
// stays open while the pointer stays still over Object.
|
||||
egui::Popup::open_id(&ctx, t[0].1);
|
||||
let t = frames(&mut app, vec![]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_popup_covering_a_title_does_not_switch_menus() {
|
||||
let ctx = egui::Context::default();
|
||||
let p = egui::pos2(90.0, 12.0);
|
||||
let mut reaches = (true, false);
|
||||
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
|
||||
for _ in 0..3 {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click());
|
||||
let q = p + egui::vec2(0.0, 100.0);
|
||||
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::unique("free"), egui::Sense::click());
|
||||
egui::Area::new(egui::Id::unique("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
|
||||
ui.allocate_space(egui::vec2(20.0, 20.0));
|
||||
});
|
||||
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert_eq!(reaches, (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_in_menus_are_not_interface_labels() {
|
||||
for (id, label) in [
|
||||
("file.openRecent1", "Layers.svg"),
|
||||
("view.goto2", "Layers"),
|
||||
("type.recentFont1", "Regular"),
|
||||
("text.setStyle", "Black"),
|
||||
("window.workspace", "Layers"),
|
||||
("plugin.dialog", "Group"),
|
||||
("window.userSwatchLibrary3", "Default"),
|
||||
] {
|
||||
assert!(shows_a_name(id, label), "{id} {label}");
|
||||
}
|
||||
for (id, label) in [("object.group", "Group"), ("window.workspace", "Essentials"), ("effect.apply", "Drop Shadow")] {
|
||||
assert!(!shows_a_name(id, label), "{id} {label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_effect_dialog_and_view_toggles() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
assert!(!enabled(&app, "effect.last"));
|
||||
app.last_effect = Some(("distort.roughen".into(), json!({"size": 9})));
|
||||
assert!(enabled(&app, "effect.last"));
|
||||
assert_eq!(dynamic_label(&app, "effect.last", "Last Effect…"), "Last Effect: Roughen…");
|
||||
app.run("effect.last", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("__effect"), d.f64("size", 0.0)), ("effect", "distort.roughen".to_string(), 9.0));
|
||||
for (id, on, off) in [
|
||||
("view.textThreads", "Hide Text Threads", "Show Text Threads"),
|
||||
("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"),
|
||||
("view.livePaintGaps", "Show Live Paint Gaps", "Hide Live Paint Gaps"),
|
||||
("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"),
|
||||
("view.template", "Hide Template", "Show Template"),
|
||||
] {
|
||||
assert_eq!(dynamic_label(&app, id, ""), on);
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(dynamic_label(&app, id, ""), off);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Undo and Edit › Redo name the step in the UI language as the History panel lists it
|
||||
/// (`panels::history::step_name`), including a step named after an effect whose catalog holds
|
||||
/// only the effect's menu row with an ellipsis, as Japanese does for Feather.
|
||||
#[test]
|
||||
fn undo_and_redo_name_the_step_as_the_history_panel_does() {
|
||||
let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");
|
||||
crate::i18n::set_current(ja);
|
||||
let mut app = app_with_a_rectangle();
|
||||
app.run("effect.apply", json!({"effect": "stylize.feather"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.undo.last().map(|h| h.label.as_str()), Some("Feather"));
|
||||
let shown = |app: &VectorcraftApp, id: &'static str| {
|
||||
let item = c(if id == "edit.undo" { "Undo" } else { "Redo" }, id);
|
||||
match entry(app, &item) {
|
||||
Some(Entry::Item(row)) => row.label.to_string(),
|
||||
_ => panic!("{id} is not a menu item"),
|
||||
}
|
||||
};
|
||||
let step = |template: &str, name: &str| {
|
||||
let name = crate::panels::history::step_name(ja, name).unwrap_or_else(|| name.to_string());
|
||||
crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &name)])
|
||||
};
|
||||
assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Feather"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(shown(&app, "edit.redo"), step("Redo {name}", "Feather"));
|
||||
assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Rectangle"), "a step with a plain row");
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(shown(&app, "edit.redo"), "Redo Feather");
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment runs `type.opticalMarginAlignment`, needs a selection, and
|
||||
/// is checked while the selected type has it on. Its check, and Type Orientation's, read the
|
||||
/// type inside a selected group too.
|
||||
#[test]
|
||||
fn optical_margin_alignment_is_a_checked_type_menu_item() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let ids: Vec<&str> = menu_tree()
|
||||
.into_iter()
|
||||
.filter(|(menu, _)| *menu == "Type")
|
||||
.flat_map(|(_, items)| items)
|
||||
.filter_map(|item| match item {
|
||||
Item::Cmd("Optical Margin Alignment", id, _) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids, ["type.opticalMarginAlignment"]);
|
||||
let id = "type.opticalMarginAlignment";
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
assert!(!enabled(&app, id));
|
||||
let text = app.run("text.create", json!({"x": 10, "y": 10, "text": "“Hi”"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [text]})).unwrap();
|
||||
assert!(enabled(&app, id));
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
// With the type in a selected group, the checks show the type the commands change.
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run("type.orientation.vertical", json!({})).unwrap();
|
||||
assert_eq!(checked(&app, "type.orientation.vertical", &Value::Null), Some(true));
|
||||
assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ids_are_case_insensitive_and_labels_work() {
|
||||
assert_eq!(normalize_panel("layers"), Some("layers"));
|
||||
assert_eq!(normalize_panel("Layers"), Some("layers"));
|
||||
assert_eq!(normalize_panel("LAYERS"), Some("layers"));
|
||||
assert_eq!(normalize_panel("Swatches"), Some("swatches"));
|
||||
assert_eq!(normalize_panel("swatches"), Some("swatches"));
|
||||
assert_eq!(normalize_panel("Color Guide"), Some("colorGuide"));
|
||||
assert_eq!(normalize_panel("colorguide"), Some("colorGuide"));
|
||||
assert_eq!(normalize_panel(" Stroke "), Some("stroke"));
|
||||
assert_eq!(normalize_panel("nope"), None);
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
|
||||
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
|
||||
app.run("window.panel", json!({"panel": "Swatches"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
|
||||
assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap());
|
||||
assert!(app.run("window.panel", json!({"panel": "nope"})).is_err());
|
||||
// A collapsed dock pops the panel out of its icon, by canonical id.
|
||||
app.ui.dock_collapsed = true;
|
||||
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
|
||||
assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap());
|
||||
app.ui.dock_collapsed = false;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_views_store_and_restore_the_view() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
v.zoom = 3.0;
|
||||
v.center = vectorcraft_geom::Point::new(120.0, 80.0);
|
||||
}
|
||||
app.run("view.saved.new", json!({"name": "Detail"})).unwrap();
|
||||
assert!(enabled(&app, "view.goto1") && !enabled(&app, "view.goto2"));
|
||||
assert_eq!(dynamic_label(&app, "view.goto1", ""), "Detail");
|
||||
app.view_mut().unwrap().zoom = 0.5;
|
||||
app.run("view.goto1", json!({})).unwrap();
|
||||
let v = app.view().unwrap();
|
||||
assert_eq!((v.zoom, v.center.x, v.center.y), (3.0, 120.0, 80.0));
|
||||
// Unused slots stay out of the menu listing agents see.
|
||||
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2")));
|
||||
}
|
||||
|
||||
/// A document's 25 views are all listed at the bottom of the View menu, by name, and the last
|
||||
/// one brings back its zoom, centre and rotation after the view changed.
|
||||
#[test]
|
||||
fn every_saved_view_is_listed_at_the_bottom_of_the_view_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for i in 1..=25 {
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.zoom, v.center, v.rotation) = (i as f64 / 4.0, vectorcraft_geom::Point::new(10.0 * i as f64, -5.0 * i as f64), i as f64);
|
||||
}
|
||||
invoke(&mut app, "view.saved.new", json!({}));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.views.len(), 25);
|
||||
let entries = menu_entries(&app);
|
||||
let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id));
|
||||
let (edit, first, last) = (at("view.saved.edit").unwrap(), at("view.goto1").unwrap(), at("view.goto25").unwrap());
|
||||
assert!(edit < first && last == first + 24, "listed in order after Edit Views…");
|
||||
assert!(enabled(&app, "view.goto25"));
|
||||
assert_eq!(display_label(&app, "view.goto25", "Saved View"), "View 25");
|
||||
// The document's 26th view is refused, so there is never one the menu leaves out.
|
||||
invoke(&mut app, "view.saved.new", json!({}));
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
app.ui.dialog = None;
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.zoom, v.center, v.rotation) = (1.0, vectorcraft_geom::Point::new(0.0, 0.0), 0.0);
|
||||
}
|
||||
assert_eq!(app.run("view.goto25", json!({})).unwrap()["name"], "View 25");
|
||||
let v = app.view().unwrap();
|
||||
assert_eq!((v.zoom, v.center.x, v.center.y, v.rotation), (6.25, 250.0, -125.0, 25.0));
|
||||
}
|
||||
|
||||
/// Edit Views… is off without a saved view and opens its list on the document's views, the
|
||||
/// way Edit Selection… does.
|
||||
#[test]
|
||||
fn edit_views_opens_its_dialog_on_the_saved_views() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(!enabled(&app, "view.saved.edit"));
|
||||
invoke(&mut app, "view.saved.edit", json!({}));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.ui.status.contains("No saved views"), "{}", app.ui.status);
|
||||
app.run("view.saved.new", json!({"name": "Detail"})).unwrap();
|
||||
app.run("view.saved.new", json!({"name": "Page"})).unwrap();
|
||||
assert!(enabled(&app, "view.saved.edit"));
|
||||
invoke(&mut app, "view.saved.edit", json!({}));
|
||||
let d = app.ui.dialog.clone().expect("Edit Views dialog");
|
||||
assert_eq!((d.kind.as_str(), d.str("orig1"), d.str("name1")), ("editViews", "Page".into(), "Page".into()));
|
||||
// Deleting a view takes its slot out of the menu.
|
||||
let mut d = d;
|
||||
d.fields.insert("deleted0".into(), json!(true));
|
||||
app.ui.dialog = Some(d);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(dynamic_label(&app, "view.goto1", ""), "Page");
|
||||
assert!(shown_state(&app, "view.goto2", &Value::Null).is_none());
|
||||
}
|
||||
|
||||
fn app_with_a_rectangle() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// Edit › Undo and Edit › Redo name a step named after a style as the History panel lists it:
|
||||
/// the template's translation with the style's name in it.
|
||||
#[test]
|
||||
fn undo_and_redo_name_a_style_step_around_the_style_name() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
let text = app.run("text.create", json!({"x": 0, "y": 40, "text": "Hello"})).unwrap()["id"].clone();
|
||||
app.run("charStyle.new", json!({"name": "Brush"})).unwrap();
|
||||
app.run("charStyle.apply", json!({"name": "Brush", "id": text})).unwrap();
|
||||
let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");
|
||||
crate::i18n::set_current(ja);
|
||||
let step = |template: &str| {
|
||||
let applied = crate::i18n::fmt(crate::i18n::tr(ja, "Apply {name}"), &[("name", "Brush")]);
|
||||
crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &applied)])
|
||||
};
|
||||
assert_eq!(display_label(&app, "edit.undo", "Undo"), step("Undo {name}"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(display_label(&app, "edit.redo", "Redo"), step("Redo {name}"));
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(display_label(&app, "edit.redo", "Redo"), "Redo Apply Brush");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_selection_asks_for_a_name() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
invoke(&mut app, "select.save", json!({}));
|
||||
let d = app.ui.dialog.clone().expect("Save Selection dialog");
|
||||
assert_eq!(
|
||||
(d.kind.as_str(), d.str("__command"), d.str("__label"), d.str("name")),
|
||||
("command", "select.save".into(), "Save Selection".into(), "Selection 1".into())
|
||||
);
|
||||
// OK saves under the name typed.
|
||||
let mut d = d;
|
||||
d.fields.insert("name".into(), json!("Logo"));
|
||||
app.ui.dialog = Some(d);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("select.savedList", json!({})).unwrap(), json!(["Logo"]));
|
||||
// The next one starts from the first free name.
|
||||
invoke(&mut app, "select.save", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")), Some("Selection 1".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_selections_are_listed_at_the_bottom_of_the_select_menu() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
// Nothing saved: no slot is listed or enabled.
|
||||
assert!(!enabled(&app, "select.recall1"));
|
||||
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall1")));
|
||||
app.run("select.save", json!({"name": "Ellipse"})).unwrap();
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("select.save", json!({"name": "Everything"})).unwrap();
|
||||
assert!(enabled(&app, "select.recall1") && enabled(&app, "select.recall2") && !enabled(&app, "select.recall3"));
|
||||
assert_eq!(dynamic_label(&app, "select.recall2", "Saved Selection"), "Everything");
|
||||
// A name is shown as it is, not translated.
|
||||
assert_eq!(display_label(&app, "select.recall2", "Saved Selection"), "Everything");
|
||||
assert!(menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall2")));
|
||||
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall3")));
|
||||
// Choosing one selects its objects again.
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let r = app.run("select.recall2", json!({})).unwrap();
|
||||
assert!(r["count"].as_u64().unwrap() >= 1);
|
||||
assert_eq!(app.session.active().unwrap().selection.len(), 2);
|
||||
// A deleted selection's slot leaves the menus.
|
||||
app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap();
|
||||
assert!(!enabled(&app, "select.recall2"));
|
||||
assert!(shown_state(&app, "select.recall2", &Value::Null).is_none());
|
||||
assert!(shown_state(&app, "select.recall1", &Value::Null).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_selection_follows_save_selection_and_needs_a_recall() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
let entries = menu_entries(&app);
|
||||
let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id));
|
||||
assert_eq!(at("select.update").map(|i| i - 1), at("select.save"));
|
||||
app.run("select.save", json!({"name": "Box"})).unwrap();
|
||||
assert!(!enabled(&app, "select.update"));
|
||||
app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("select.recall1", json!({})).unwrap();
|
||||
assert!(enabled(&app, "select.update"));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
invoke(&mut app, "select.update", json!({}));
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
assert_eq!(app.run("select.recall1", json!({})).unwrap()["count"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_opens_its_dialog_on_the_saved_selections() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
// Nothing saved yet: the item is off, and invoking it (palette, shortcut) only says so.
|
||||
assert!(!enabled(&app, "select.editSaved"));
|
||||
invoke(&mut app, "select.editSaved", json!({}));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.ui.status.contains("No saved selections"), "{}", app.ui.status);
|
||||
app.run("select.save", json!({"name": "Logo"})).unwrap();
|
||||
assert!(enabled(&app, "select.editSaved"));
|
||||
invoke(&mut app, "select.editSaved", json!({}));
|
||||
let d = app.ui.dialog.clone().expect("Edit Selection dialog");
|
||||
assert_eq!((d.kind.as_str(), d.str("orig0"), d.str("name0")), (crate::dialogs::edit_selection::KIND, "Logo".into(), "Logo".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_fonts_and_corner_widget_toggle() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let fams = vectorcraft_text::FontDb::global().families();
|
||||
let (a, b) = (fams[0].clone(), fams[fams.len() - 1].clone());
|
||||
app.run("text.create", json!({"x": 10, "y": 10, "text": "Hi"})).unwrap();
|
||||
app.run("text.setStyle", json!({"font": a})).unwrap();
|
||||
app.run("text.setStyle", json!({"font": b})).unwrap();
|
||||
assert_eq!(app.ui.recent_fonts, vec![b.clone(), a.clone()]);
|
||||
assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), a);
|
||||
assert!(enabled(&app, "type.recentFont2") && !enabled(&app, "type.recentFont3"));
|
||||
app.run("type.recentFont2", json!({})).unwrap();
|
||||
assert_eq!(app.ui.recent_fonts[0], a);
|
||||
assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Hide Corner Widget");
|
||||
app.run("view.cornerWidget", json!({})).unwrap();
|
||||
assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Show Corner Widget");
|
||||
}
|
||||
|
||||
/// View › Precise Cursors has no menu item or default key, but the command (assignable in Keyboard Shortcuts)
|
||||
/// writes General › Use Precise Cursors, the value the dialog and the saved preferences read.
|
||||
#[test]
|
||||
fn precise_cursors_command_toggles_the_preference() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
assert!(!app.session.prefs.use_precise_cursors);
|
||||
assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(true));
|
||||
assert!(app.session.prefs.use_precise_cursors);
|
||||
assert_eq!(app.ui.engine_prefs["usePreciseCursors"], json!(true), "saved with the UI state");
|
||||
assert!(UI_COMMANDS.iter().any(|c| c.0 == "view.preciseCursors" && c.2.is_empty()), "assignable, no default key");
|
||||
assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(false));
|
||||
assert!(!app.session.prefs.use_precise_cursors);
|
||||
}
|
||||
|
||||
/// The command is listed in Edit › Keyboard Shortcuts without a key and takes one the user types; with the
|
||||
/// key it is the command that runs.
|
||||
#[test]
|
||||
fn precise_cursors_command_is_listed_and_assignable_in_the_shortcut_editor() {
|
||||
use crate::shortcut_editor as ed;
|
||||
let e = ed::entry("view.preciseCursors").expect("listed in the editor");
|
||||
assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Precise Cursors", "View", false));
|
||||
assert_eq!(ed::default_of("view.preciseCursors"), None, "no default key");
|
||||
let mut overrides = craft_keymap::Overrides::new();
|
||||
assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None);
|
||||
assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+U"), false), Ok(vec![]));
|
||||
let chord = ed::effective_in(&overrides, "view.preciseCursors").expect("assigned");
|
||||
assert_eq!(ed::normalize(&chord), ed::normalize("Cmd+Alt+Shift+U"));
|
||||
// A key another command holds is refused, so the new command can't take it over silently.
|
||||
assert!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+P"), false).is_err());
|
||||
assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", None, false), Ok(vec![]));
|
||||
assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None, "cleared again");
|
||||
}
|
||||
|
||||
/// The command is a preference, not an edit: no undo step, no unsaved changes, with a document open.
|
||||
#[test]
|
||||
fn precise_cursors_command_is_not_an_edit() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let steps = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let before = steps(&app);
|
||||
assert!(!before.2);
|
||||
app.run("view.preciseCursors", json!({})).unwrap();
|
||||
assert!(app.session.prefs.use_precise_cursors);
|
||||
assert_eq!(steps(&app), before);
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display › Zoom to Selection (#394): Zoom In and Zoom Out bring the
|
||||
/// selection to the middle; off, they keep the view's centre.
|
||||
#[test]
|
||||
fn zoom_to_selection_centres_the_selection() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 20})).unwrap();
|
||||
let center = |app: &VectorcraftApp| app.view().unwrap().center;
|
||||
let start = center(&app);
|
||||
app.run("prefs.set", json!({"key": "zoomToSelection", "value": false})).unwrap();
|
||||
app.run("view.zoomIn", json!({})).unwrap();
|
||||
assert_eq!(center(&app), start, "off: the view's centre stays");
|
||||
app.run("prefs.set", json!({"key": "zoomToSelection", "value": true})).unwrap();
|
||||
app.run("view.zoomOut", json!({})).unwrap();
|
||||
assert_eq!(center(&app), vectorcraft_geom::Point::new(30.0, 30.0), "on: the selection's centre");
|
||||
}
|
||||
|
||||
/// Preferences › Type › Number of Recent Fonts (#394): Type › Recent Fonts lists that many.
|
||||
#[test]
|
||||
fn number_of_recent_fonts_sets_how_many_are_listed() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.ui.recent_fonts = (1..=15).map(|i| format!("Font {i}")).collect();
|
||||
let listed = |app: &VectorcraftApp| RECENT_FONT_IDS.iter().filter(|id| enabled(app, id)).count();
|
||||
assert_eq!(listed(&app), 10, "10 by default");
|
||||
for n in [15, 2] {
|
||||
app.run("prefs.set", json!({"key": "recentFontsCount", "value": n})).unwrap();
|
||||
assert_eq!(listed(&app), n);
|
||||
}
|
||||
assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), "Font 2");
|
||||
assert!(app.run("type.recentFont3", json!({})).is_err(), "past the count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_track_opens_and_saves() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut app = VectorcraftApp::new(
|
||||
vectorcraft_engine::Session::new(),
|
||||
crate::Services {
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let (a, b) = (dir.join("a.vectorcraft"), dir.join("b.vectorcraft"));
|
||||
for p in [&a, &b, &a] {
|
||||
app.run("file.saveAs", json!({"path": p.to_string_lossy()})).unwrap();
|
||||
}
|
||||
assert_eq!(app.ui.recent_files, [a.to_string_lossy(), b.to_string_lossy()]);
|
||||
assert_eq!(dynamic_label(&app, "file.openRecent2", ""), "b.vectorcraft");
|
||||
assert!(enabled(&app, "file.openRecent2") && !enabled(&app, "file.openRecent3"));
|
||||
app.run("file.openRecent2", json!({})).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.ui.recent_files[0], b.to_string_lossy(), "reopening moves it to the top");
|
||||
app.run("file.clearRecent", json!({})).unwrap();
|
||||
assert!(app.ui.recent_files.is_empty() && app.run("file.openRecent1", json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bound_menu_command_exists() {
|
||||
fn walk(items: &[Item], bad: &mut Vec<String>) {
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Cmd(_, id, _)
|
||||
if vectorcraft_engine::find_command(id).is_none()
|
||||
&& !UI_COMMANDS.iter().any(|c| c.0 == *id)
|
||||
&& !id.starts_with("object.path.") =>
|
||||
{
|
||||
bad.push(id.to_string());
|
||||
}
|
||||
Item::Sub(_, ch) => walk(ch, bad),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bad = vec![];
|
||||
for (_, items) in menu_tree() {
|
||||
walk(&items, &mut bad);
|
||||
}
|
||||
assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_pretty() {
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]");
|
||||
assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2");
|
||||
}
|
||||
assert_eq!(pretty_shortcut(""), "");
|
||||
}
|
||||
|
||||
/// #543: Object › Image Trace › Make and Make and Expand trace with the Default preset at once,
|
||||
/// with no form asking for a preset's name.
|
||||
#[test]
|
||||
fn image_trace_make_traces_with_the_default_preset() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for cmd in ["imageTrace.make", "imageTrace.makeAndExpand"] {
|
||||
let e = menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some(cmd)).unwrap();
|
||||
assert_eq!(e.path, ["Object", "Image Trace"]);
|
||||
assert_eq!(click_target(&e.label, cmd, &e.params), (cmd.to_string(), json!({"preset": "Default"})), "{}", e.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_is_an_object_menu_item_only() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let paths: Vec<Vec<String>> =
|
||||
menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect();
|
||||
assert_eq!(paths, [vec!["Object".to_string()]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_are_as_wide_as_their_items() {
|
||||
// Items size the popup to the widest label and shortcut instead of the widest a menu may be.
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let id = egui::Id::unique("test-menu");
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
egui::Area::new(id).show(ui.ctx(), |ui| {
|
||||
egui::containers::menu::menu_style(ui.style_mut());
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, file, &mut None));
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let w = ctx.memory(|m| m.area_rect(id)).unwrap().width();
|
||||
assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_menus_scroll_inside_a_short_window() {
|
||||
// The Window menu is taller than a 600 pt window: it stops above the bottom edge and
|
||||
// scrolls instead of running off screen.
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let tree = menu_tree();
|
||||
let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap();
|
||||
let id = egui::Id::unique("test-long-menu");
|
||||
let rect = |height: f32| {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height));
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| {
|
||||
egui::containers::menu::menu_style(ui.style_mut());
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None));
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.memory(|m| m.area_rect(id)).unwrap()
|
||||
};
|
||||
let short = rect(600.0);
|
||||
assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom());
|
||||
// With room to spare it shows whole, taller than the short window allowed.
|
||||
let tall = rect(3000.0);
|
||||
assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height());
|
||||
assert!(tall.bottom() < 3000.0);
|
||||
}
|
||||
|
||||
/// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a
|
||||
/// document, instead of being enabled and answering with an internal milestone note.
|
||||
#[test]
|
||||
fn new_window_is_disabled_until_it_opens_a_window() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window");
|
||||
let e = app.run("window.newWindow", json!({})).unwrap_err();
|
||||
assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
|
||||
/// (rect, id of the title's popup).
|
||||
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut layer = None;
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
|
||||
layer = Some(ui.layer_id());
|
||||
menu_bar(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let layer = layer.unwrap();
|
||||
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
|
||||
vp.prev_pass
|
||||
.widgets
|
||||
.get_layer(layer)
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
|
||||
// `egui::Popup::default_response_id` of the title's response.
|
||||
.map(|w| (w.rect, w.id.with("popup")))
|
||||
.collect()
|
||||
});
|
||||
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
|
||||
titles
|
||||
}
|
||||
|
||||
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
|
||||
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
|
||||
}
|
||||
|
||||
/// File ends with Exit, running the app's own quit flow (unsaved documents are asked about).
|
||||
#[test]
|
||||
fn file_ends_with_exit() {
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap();
|
||||
assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}");
|
||||
}
|
||||
|
||||
/// No application menu in the window (as in Illustrator): the bar starts at File.
|
||||
#[test]
|
||||
fn the_bar_starts_at_file() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
menu_bar(&mut app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
|
||||
for t in titles {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_another_title_switches_the_open_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
let titles = bar_frame(&mut app, &ctx, vec![]);
|
||||
// No application menu in the window: the bar starts at File on every platform.
|
||||
assert_eq!(titles.len(), menu_tree().len() - 1);
|
||||
let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center());
|
||||
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut t = bar_frame(app, &ctx, events);
|
||||
for _ in 0..2 {
|
||||
t = bar_frame(app, &ctx, vec![]);
|
||||
}
|
||||
t
|
||||
};
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
|
||||
// Nothing open: hovering a title opens nothing.
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert!(open_titles(&ctx, &t).is_empty());
|
||||
|
||||
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
|
||||
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
|
||||
frames(&mut app, vec![button(file, true)]);
|
||||
let t = frames(&mut app, vec![button(file, false)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit");
|
||||
|
||||
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
|
||||
// stays open while the pointer stays still over Object.
|
||||
egui::Popup::open_id(&ctx, t[0].1);
|
||||
let t = frames(&mut app, vec![]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_popup_covering_a_title_does_not_switch_menus() {
|
||||
let ctx = egui::Context::default();
|
||||
let p = egui::pos2(90.0, 12.0);
|
||||
let mut reaches = (true, false);
|
||||
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
|
||||
for _ in 0..3 {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click());
|
||||
let q = p + egui::vec2(0.0, 100.0);
|
||||
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::unique("free"), egui::Sense::click());
|
||||
egui::Area::new(egui::Id::unique("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
|
||||
ui.allocate_space(egui::vec2(20.0, 20.0));
|
||||
});
|
||||
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert_eq!(reaches, (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_in_menus_are_not_interface_labels() {
|
||||
for (id, label) in [
|
||||
("file.openRecent1", "Layers.svg"),
|
||||
("view.goto2", "Layers"),
|
||||
("type.recentFont1", "Regular"),
|
||||
("text.setStyle", "Black"),
|
||||
("window.workspace", "Layers"),
|
||||
("plugin.dialog", "Group"),
|
||||
("window.userSwatchLibrary3", "Default"),
|
||||
] {
|
||||
assert!(shows_a_name(id, label), "{id} {label}");
|
||||
}
|
||||
for (id, label) in [("object.group", "Group"), ("window.workspace", "Essentials"), ("effect.apply", "Drop Shadow")] {
|
||||
assert!(!shows_a_name(id, label), "{id} {label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_effect_dialog_and_view_toggles() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
assert!(!enabled(&app, "effect.last"));
|
||||
app.last_effect = Some(("distort.roughen".into(), json!({"size": 9})));
|
||||
assert!(enabled(&app, "effect.last"));
|
||||
assert_eq!(dynamic_label(&app, "effect.last", "Last Effect…"), "Last Effect: Roughen…");
|
||||
app.run("effect.last", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("__effect"), d.f64("size", 0.0)), ("effect", "distort.roughen".to_string(), 9.0));
|
||||
for (id, on, off) in [
|
||||
("view.textThreads", "Hide Text Threads", "Show Text Threads"),
|
||||
("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"),
|
||||
("view.livePaintGaps", "Show Live Paint Gaps", "Hide Live Paint Gaps"),
|
||||
("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"),
|
||||
("view.template", "Hide Template", "Show Template"),
|
||||
] {
|
||||
assert_eq!(dynamic_label(&app, id, ""), on);
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(dynamic_label(&app, id, ""), off);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Undo and Edit › Redo name the step in the UI language as the History panel lists it
|
||||
/// (`panels::history::step_name`), including a step named after an effect whose catalog holds
|
||||
/// only the effect's menu row with an ellipsis, as Japanese does for Feather.
|
||||
#[test]
|
||||
fn undo_and_redo_name_the_step_as_the_history_panel_does() {
|
||||
let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");
|
||||
crate::i18n::set_current(ja);
|
||||
let mut app = app_with_a_rectangle();
|
||||
app.run("effect.apply", json!({"effect": "stylize.feather"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.undo.last().map(|h| h.label.as_str()), Some("Feather"));
|
||||
let shown = |app: &VectorcraftApp, id: &'static str| {
|
||||
let item = c(if id == "edit.undo" { "Undo" } else { "Redo" }, id);
|
||||
match entry(app, &item) {
|
||||
Some(Entry::Item(row)) => row.label.to_string(),
|
||||
_ => panic!("{id} is not a menu item"),
|
||||
}
|
||||
};
|
||||
let step = |template: &str, name: &str| {
|
||||
let name = crate::panels::history::step_name(ja, name).unwrap_or_else(|| name.to_string());
|
||||
crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &name)])
|
||||
};
|
||||
assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Feather"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(shown(&app, "edit.redo"), step("Redo {name}", "Feather"));
|
||||
assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Rectangle"), "a step with a plain row");
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(shown(&app, "edit.redo"), "Redo Feather");
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment runs `type.opticalMarginAlignment`, needs a selection, and
|
||||
/// is checked while the selected type has it on. Its check, and Type Orientation's, read the
|
||||
/// type inside a selected group too.
|
||||
#[test]
|
||||
fn optical_margin_alignment_is_a_checked_type_menu_item() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let ids: Vec<&str> = menu_tree()
|
||||
.into_iter()
|
||||
.filter(|(menu, _)| *menu == "Type")
|
||||
.flat_map(|(_, items)| items)
|
||||
.filter_map(|item| match item {
|
||||
Item::Cmd("Optical Margin Alignment", id, _) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids, ["type.opticalMarginAlignment"]);
|
||||
let id = "type.opticalMarginAlignment";
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
assert!(!enabled(&app, id));
|
||||
let text = app.run("text.create", json!({"x": 10, "y": 10, "text": "“Hi”"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [text]})).unwrap();
|
||||
assert!(enabled(&app, id));
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
// With the type in a selected group, the checks show the type the commands change.
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run("type.orientation.vertical", json!({})).unwrap();
|
||||
assert_eq!(checked(&app, "type.orientation.vertical", &Value::Null), Some(true));
|
||||
assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ids_are_case_insensitive_and_labels_work() {
|
||||
assert_eq!(normalize_panel("layers"), Some("layers"));
|
||||
assert_eq!(normalize_panel("Layers"), Some("layers"));
|
||||
assert_eq!(normalize_panel("LAYERS"), Some("layers"));
|
||||
assert_eq!(normalize_panel("Swatches"), Some("swatches"));
|
||||
assert_eq!(normalize_panel("swatches"), Some("swatches"));
|
||||
assert_eq!(normalize_panel("Color Guide"), Some("colorGuide"));
|
||||
assert_eq!(normalize_panel("colorguide"), Some("colorGuide"));
|
||||
assert_eq!(normalize_panel(" Stroke "), Some("stroke"));
|
||||
assert_eq!(normalize_panel("nope"), None);
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
|
||||
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
|
||||
app.run("window.panel", json!({"panel": "Swatches"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
|
||||
assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap());
|
||||
assert!(app.run("window.panel", json!({"panel": "nope"})).is_err());
|
||||
// A collapsed dock pops the panel out of its icon, by canonical id.
|
||||
app.ui.dock_collapsed = true;
|
||||
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
|
||||
assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap());
|
||||
app.ui.dock_collapsed = false;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_views_store_and_restore_the_view() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
v.zoom = 3.0;
|
||||
v.center = vectorcraft_geom::Point::new(120.0, 80.0);
|
||||
}
|
||||
app.run("view.saved.new", json!({"name": "Detail"})).unwrap();
|
||||
assert!(enabled(&app, "view.goto1") && !enabled(&app, "view.goto2"));
|
||||
assert_eq!(dynamic_label(&app, "view.goto1", ""), "Detail");
|
||||
app.view_mut().unwrap().zoom = 0.5;
|
||||
app.run("view.goto1", json!({})).unwrap();
|
||||
let v = app.view().unwrap();
|
||||
assert_eq!((v.zoom, v.center.x, v.center.y), (3.0, 120.0, 80.0));
|
||||
// Unused slots stay out of the menu listing agents see.
|
||||
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2")));
|
||||
}
|
||||
|
||||
/// A document's 25 views are all listed at the bottom of the View menu, by name, and the last
|
||||
/// one brings back its zoom, centre and rotation after the view changed.
|
||||
#[test]
|
||||
fn every_saved_view_is_listed_at_the_bottom_of_the_view_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for i in 1..=25 {
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.zoom, v.center, v.rotation) = (i as f64 / 4.0, vectorcraft_geom::Point::new(10.0 * i as f64, -5.0 * i as f64), i as f64);
|
||||
}
|
||||
invoke(&mut app, "view.saved.new", json!({}));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.views.len(), 25);
|
||||
let entries = menu_entries(&app);
|
||||
let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id));
|
||||
let (edit, first, last) = (at("view.saved.edit").unwrap(), at("view.goto1").unwrap(), at("view.goto25").unwrap());
|
||||
assert!(edit < first && last == first + 24, "listed in order after Edit Views…");
|
||||
assert!(enabled(&app, "view.goto25"));
|
||||
assert_eq!(display_label(&app, "view.goto25", "Saved View"), "View 25");
|
||||
// The document's 26th view is refused, so there is never one the menu leaves out.
|
||||
invoke(&mut app, "view.saved.new", json!({}));
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
app.ui.dialog = None;
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.zoom, v.center, v.rotation) = (1.0, vectorcraft_geom::Point::new(0.0, 0.0), 0.0);
|
||||
}
|
||||
assert_eq!(app.run("view.goto25", json!({})).unwrap()["name"], "View 25");
|
||||
let v = app.view().unwrap();
|
||||
assert_eq!((v.zoom, v.center.x, v.center.y, v.rotation), (6.25, 250.0, -125.0, 25.0));
|
||||
}
|
||||
|
||||
/// Edit Views… is off without a saved view and opens its list on the document's views, the
|
||||
/// way Edit Selection… does.
|
||||
#[test]
|
||||
fn edit_views_opens_its_dialog_on_the_saved_views() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(!enabled(&app, "view.saved.edit"));
|
||||
invoke(&mut app, "view.saved.edit", json!({}));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.ui.status.contains("No saved views"), "{}", app.ui.status);
|
||||
app.run("view.saved.new", json!({"name": "Detail"})).unwrap();
|
||||
app.run("view.saved.new", json!({"name": "Page"})).unwrap();
|
||||
assert!(enabled(&app, "view.saved.edit"));
|
||||
invoke(&mut app, "view.saved.edit", json!({}));
|
||||
let d = app.ui.dialog.clone().expect("Edit Views dialog");
|
||||
assert_eq!((d.kind.as_str(), d.str("orig1"), d.str("name1")), ("editViews", "Page".into(), "Page".into()));
|
||||
// Deleting a view takes its slot out of the menu.
|
||||
let mut d = d;
|
||||
d.fields.insert("deleted0".into(), json!(true));
|
||||
app.ui.dialog = Some(d);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(dynamic_label(&app, "view.goto1", ""), "Page");
|
||||
assert!(shown_state(&app, "view.goto2", &Value::Null).is_none());
|
||||
}
|
||||
|
||||
fn app_with_a_rectangle() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// Edit › Undo and Edit › Redo name a step named after a style as the History panel lists it:
|
||||
/// the template's translation with the style's name in it.
|
||||
#[test]
|
||||
fn undo_and_redo_name_a_style_step_around_the_style_name() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
let text = app.run("text.create", json!({"x": 0, "y": 40, "text": "Hello"})).unwrap()["id"].clone();
|
||||
app.run("charStyle.new", json!({"name": "Brush"})).unwrap();
|
||||
app.run("charStyle.apply", json!({"name": "Brush", "id": text})).unwrap();
|
||||
let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");
|
||||
crate::i18n::set_current(ja);
|
||||
let step = |template: &str| {
|
||||
let applied = crate::i18n::fmt(crate::i18n::tr(ja, "Apply {name}"), &[("name", "Brush")]);
|
||||
crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &applied)])
|
||||
};
|
||||
assert_eq!(display_label(&app, "edit.undo", "Undo"), step("Undo {name}"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(display_label(&app, "edit.redo", "Redo"), step("Redo {name}"));
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(display_label(&app, "edit.redo", "Redo"), "Redo Apply Brush");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_selection_asks_for_a_name() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
invoke(&mut app, "select.save", json!({}));
|
||||
let d = app.ui.dialog.clone().expect("Save Selection dialog");
|
||||
assert_eq!(
|
||||
(d.kind.as_str(), d.str("__command"), d.str("__label"), d.str("name")),
|
||||
("command", "select.save".into(), "Save Selection".into(), "Selection 1".into())
|
||||
);
|
||||
// OK saves under the name typed.
|
||||
let mut d = d;
|
||||
d.fields.insert("name".into(), json!("Logo"));
|
||||
app.ui.dialog = Some(d);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("select.savedList", json!({})).unwrap(), json!(["Logo"]));
|
||||
// The next one starts from the first free name.
|
||||
invoke(&mut app, "select.save", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")), Some("Selection 1".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_selections_are_listed_at_the_bottom_of_the_select_menu() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
// Nothing saved: no slot is listed or enabled.
|
||||
assert!(!enabled(&app, "select.recall1"));
|
||||
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall1")));
|
||||
app.run("select.save", json!({"name": "Ellipse"})).unwrap();
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("select.save", json!({"name": "Everything"})).unwrap();
|
||||
assert!(enabled(&app, "select.recall1") && enabled(&app, "select.recall2") && !enabled(&app, "select.recall3"));
|
||||
assert_eq!(dynamic_label(&app, "select.recall2", "Saved Selection"), "Everything");
|
||||
// A name is shown as it is, not translated.
|
||||
assert_eq!(display_label(&app, "select.recall2", "Saved Selection"), "Everything");
|
||||
assert!(menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall2")));
|
||||
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall3")));
|
||||
// Choosing one selects its objects again.
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let r = app.run("select.recall2", json!({})).unwrap();
|
||||
assert!(r["count"].as_u64().unwrap() >= 1);
|
||||
assert_eq!(app.session.active().unwrap().selection.len(), 2);
|
||||
// A deleted selection's slot leaves the menus.
|
||||
app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap();
|
||||
assert!(!enabled(&app, "select.recall2"));
|
||||
assert!(shown_state(&app, "select.recall2", &Value::Null).is_none());
|
||||
assert!(shown_state(&app, "select.recall1", &Value::Null).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_selection_follows_save_selection_and_needs_a_recall() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
let entries = menu_entries(&app);
|
||||
let at = |id: &str| entries.iter().position(|e| e.command.as_deref() == Some(id));
|
||||
assert_eq!(at("select.update").map(|i| i - 1), at("select.save"));
|
||||
app.run("select.save", json!({"name": "Box"})).unwrap();
|
||||
assert!(!enabled(&app, "select.update"));
|
||||
app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("select.recall1", json!({})).unwrap();
|
||||
assert!(enabled(&app, "select.update"));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
invoke(&mut app, "select.update", json!({}));
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
assert_eq!(app.run("select.recall1", json!({})).unwrap()["count"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_opens_its_dialog_on_the_saved_selections() {
|
||||
let mut app = app_with_a_rectangle();
|
||||
// Nothing saved yet: the item is off, and invoking it (palette, shortcut) only says so.
|
||||
assert!(!enabled(&app, "select.editSaved"));
|
||||
invoke(&mut app, "select.editSaved", json!({}));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.ui.status.contains("No saved selections"), "{}", app.ui.status);
|
||||
app.run("select.save", json!({"name": "Logo"})).unwrap();
|
||||
assert!(enabled(&app, "select.editSaved"));
|
||||
invoke(&mut app, "select.editSaved", json!({}));
|
||||
let d = app.ui.dialog.clone().expect("Edit Selection dialog");
|
||||
assert_eq!((d.kind.as_str(), d.str("orig0"), d.str("name0")), (crate::dialogs::edit_selection::KIND, "Logo".into(), "Logo".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_fonts_and_corner_widget_toggle() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let fams = vectorcraft_text::FontDb::global().families();
|
||||
let (a, b) = (fams[0].clone(), fams[fams.len() - 1].clone());
|
||||
app.run("text.create", json!({"x": 10, "y": 10, "text": "Hi"})).unwrap();
|
||||
app.run("text.setStyle", json!({"font": a})).unwrap();
|
||||
app.run("text.setStyle", json!({"font": b})).unwrap();
|
||||
assert_eq!(app.ui.recent_fonts, vec![b.clone(), a.clone()]);
|
||||
assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), a);
|
||||
assert!(enabled(&app, "type.recentFont2") && !enabled(&app, "type.recentFont3"));
|
||||
app.run("type.recentFont2", json!({})).unwrap();
|
||||
assert_eq!(app.ui.recent_fonts[0], a);
|
||||
assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Hide Corner Widget");
|
||||
app.run("view.cornerWidget", json!({})).unwrap();
|
||||
assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Show Corner Widget");
|
||||
}
|
||||
|
||||
/// View › Precise Cursors has no menu item or default key, but the command (assignable in Keyboard Shortcuts)
|
||||
/// writes General › Use Precise Cursors, the value the dialog and the saved preferences read.
|
||||
#[test]
|
||||
fn precise_cursors_command_toggles_the_preference() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
assert!(!app.session.prefs.use_precise_cursors);
|
||||
assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(true));
|
||||
assert!(app.session.prefs.use_precise_cursors);
|
||||
assert_eq!(app.ui.engine_prefs["usePreciseCursors"], json!(true), "saved with the UI state");
|
||||
assert!(UI_COMMANDS.iter().any(|c| c.0 == "view.preciseCursors" && c.2.is_empty()), "assignable, no default key");
|
||||
assert_eq!(app.run("view.preciseCursors", json!({})).unwrap(), json!(false));
|
||||
assert!(!app.session.prefs.use_precise_cursors);
|
||||
}
|
||||
|
||||
/// The command is listed in Edit › Keyboard Shortcuts without a key and takes one the user types; with the
|
||||
/// key it is the command that runs.
|
||||
#[test]
|
||||
fn precise_cursors_command_is_listed_and_assignable_in_the_shortcut_editor() {
|
||||
use crate::shortcut_editor as ed;
|
||||
let e = ed::entry("view.preciseCursors").expect("listed in the editor");
|
||||
assert_eq!((e.label.as_str(), e.group.as_str(), e.is_tool), ("Precise Cursors", "View", false));
|
||||
assert_eq!(ed::default_of("view.preciseCursors"), None, "no default key");
|
||||
let mut overrides = craft_keymap::Overrides::new();
|
||||
assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None);
|
||||
assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+U"), false), Ok(vec![]));
|
||||
let chord = ed::effective_in(&overrides, "view.preciseCursors").expect("assigned");
|
||||
assert_eq!(ed::normalize(&chord), ed::normalize("Cmd+Alt+Shift+U"));
|
||||
// A key another command holds is refused, so the new command can't take it over silently.
|
||||
assert!(ed::assign(&mut overrides, "view.preciseCursors", Some("Cmd+Alt+Shift+P"), false).is_err());
|
||||
assert_eq!(ed::assign(&mut overrides, "view.preciseCursors", None, false), Ok(vec![]));
|
||||
assert_eq!(ed::effective_in(&overrides, "view.preciseCursors"), None, "cleared again");
|
||||
}
|
||||
|
||||
/// The command is a preference, not an edit: no undo step, no unsaved changes, with a document open.
|
||||
#[test]
|
||||
fn precise_cursors_command_is_not_an_edit() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let steps = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let before = steps(&app);
|
||||
assert!(!before.2);
|
||||
app.run("view.preciseCursors", json!({})).unwrap();
|
||||
assert!(app.session.prefs.use_precise_cursors);
|
||||
assert_eq!(steps(&app), before);
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display › Zoom to Selection (#394): Zoom In and Zoom Out bring the
|
||||
/// selection to the middle; off, they keep the view's centre.
|
||||
#[test]
|
||||
fn zoom_to_selection_centres_the_selection() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 20})).unwrap();
|
||||
let center = |app: &VectorcraftApp| app.view().unwrap().center;
|
||||
let start = center(&app);
|
||||
app.run("prefs.set", json!({"key": "zoomToSelection", "value": false})).unwrap();
|
||||
app.run("view.zoomIn", json!({})).unwrap();
|
||||
assert_eq!(center(&app), start, "off: the view's centre stays");
|
||||
app.run("prefs.set", json!({"key": "zoomToSelection", "value": true})).unwrap();
|
||||
app.run("view.zoomOut", json!({})).unwrap();
|
||||
assert_eq!(center(&app), vectorcraft_geom::Point::new(30.0, 30.0), "on: the selection's centre");
|
||||
}
|
||||
|
||||
/// Preferences › Type › Number of Recent Fonts (#394): Type › Recent Fonts lists that many.
|
||||
#[test]
|
||||
fn number_of_recent_fonts_sets_how_many_are_listed() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.ui.recent_fonts = (1..=15).map(|i| format!("Font {i}")).collect();
|
||||
let listed = |app: &VectorcraftApp| RECENT_FONT_IDS.iter().filter(|id| enabled(app, id)).count();
|
||||
assert_eq!(listed(&app), 10, "10 by default");
|
||||
for n in [15, 2] {
|
||||
app.run("prefs.set", json!({"key": "recentFontsCount", "value": n})).unwrap();
|
||||
assert_eq!(listed(&app), n);
|
||||
}
|
||||
assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), "Font 2");
|
||||
assert!(app.run("type.recentFont3", json!({})).is_err(), "past the count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_track_opens_and_saves() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut app = VectorcraftApp::new(
|
||||
vectorcraft_engine::Session::new(),
|
||||
crate::Services {
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let (a, b) = (dir.join("a.vectorcraft"), dir.join("b.vectorcraft"));
|
||||
for p in [&a, &b, &a] {
|
||||
app.run("file.saveAs", json!({"path": p.to_string_lossy()})).unwrap();
|
||||
}
|
||||
assert_eq!(app.ui.recent_files, [a.to_string_lossy(), b.to_string_lossy()]);
|
||||
assert_eq!(dynamic_label(&app, "file.openRecent2", ""), "b.vectorcraft");
|
||||
assert!(enabled(&app, "file.openRecent2") && !enabled(&app, "file.openRecent3"));
|
||||
app.run("file.openRecent2", json!({})).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.ui.recent_files[0], b.to_string_lossy(), "reopening moves it to the top");
|
||||
app.run("file.clearRecent", json!({})).unwrap();
|
||||
assert!(app.ui.recent_files.is_empty() && app.run("file.openRecent1", json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bound_menu_command_exists() {
|
||||
fn walk(items: &[Item], bad: &mut Vec<String>) {
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Cmd(_, id, _)
|
||||
if vectorcraft_engine::find_command(id).is_none()
|
||||
&& !UI_COMMANDS.iter().any(|c| c.0 == *id)
|
||||
&& !id.starts_with("object.path.") =>
|
||||
{
|
||||
bad.push(id.to_string());
|
||||
}
|
||||
Item::Sub(_, ch) => walk(ch, bad),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bad = vec![];
|
||||
for (_, items) in menu_tree() {
|
||||
walk(&items, &mut bad);
|
||||
}
|
||||
assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_pretty() {
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]");
|
||||
assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2");
|
||||
}
|
||||
assert_eq!(pretty_shortcut(""), "");
|
||||
}
|
||||
|
||||
/// #543: Object › Image Trace › Make and Make and Expand trace with the Default preset at once,
|
||||
/// with no form asking for a preset's name.
|
||||
#[test]
|
||||
fn image_trace_make_traces_with_the_default_preset() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for cmd in ["imageTrace.make", "imageTrace.makeAndExpand"] {
|
||||
let e = menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some(cmd)).unwrap();
|
||||
assert_eq!(e.path, ["Object", "Image Trace"]);
|
||||
assert_eq!(click_target(&e.label, cmd, &e.params), (cmd.to_string(), json!({"preset": "Default"})), "{}", e.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_is_an_object_menu_item_only() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let paths: Vec<Vec<String>> =
|
||||
menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect();
|
||||
assert_eq!(paths, [vec!["Object".to_string()]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_are_as_wide_as_their_items() {
|
||||
// Items size the popup to the widest label and shortcut instead of the widest a menu may be.
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let id = egui::Id::unique("test-menu");
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
egui::Area::new(id).show(ui.ctx(), |ui| {
|
||||
egui::containers::menu::menu_style(ui.style_mut());
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, file, &mut None));
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let w = ctx.memory(|m| m.area_rect(id)).unwrap().width();
|
||||
assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_menus_scroll_inside_a_short_window() {
|
||||
// The Window menu is taller than a 600 pt window: it stops above the bottom edge and
|
||||
// scrolls instead of running off screen.
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let tree = menu_tree();
|
||||
let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap();
|
||||
let id = egui::Id::unique("test-long-menu");
|
||||
let rect = |height: f32| {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height));
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| {
|
||||
egui::containers::menu::menu_style(ui.style_mut());
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None));
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.memory(|m| m.area_rect(id)).unwrap()
|
||||
};
|
||||
let short = rect(600.0);
|
||||
assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom());
|
||||
// With room to spare it shows whole, taller than the short window allowed.
|
||||
let tall = rect(3000.0);
|
||||
assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height());
|
||||
assert!(tall.bottom() < 3000.0);
|
||||
}
|
||||
|
||||
/// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a
|
||||
/// document, instead of being enabled and answering with an internal milestone note.
|
||||
#[test]
|
||||
fn new_window_is_disabled_until_it_opens_a_window() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window");
|
||||
let e = app.run("window.newWindow", json!({})).unwrap_err();
|
||||
assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}");
|
||||
}
|
||||
Reference in New Issue
Block a user