mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Add deterministic document-space grain, highlight controls and a live dialog with preview, cancel, undo and appearance editing. Preserve coverage, support existing native and raster export paths, translate new labels and update parity documentation. Validate the tonal curve, stationarity, subpixel strength, zoom filtering and tile seams. Full cargo xtask ci passes all seven gates.
211 lines
9.9 KiB
Rust
211 lines
9.9 KiB
Rust
//! Neutral wording in menus and panels: what users read names VectorCraft's own features.
|
||
|
||
use egui::epaint::Shape;
|
||
use serde_json::json;
|
||
use vectorcraft_engine::Session;
|
||
|
||
use crate::VectorcraftApp;
|
||
use crate::menus::{self, Item};
|
||
|
||
fn app() -> VectorcraftApp {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||
app
|
||
}
|
||
|
||
/// Every string painted by `f` in a headless frame (app fonts installed), one per line. The second
|
||
/// of two frames: windows size themselves invisibly in their first.
|
||
pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear();
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui));
|
||
out.textures_delta.clear();
|
||
shapes_text(&out)
|
||
}
|
||
|
||
/// Every string a frame painted, one per line.
|
||
pub(crate) fn shapes_text(out: &egui::FullOutput) -> String {
|
||
fn collect(s: &Shape, out: &mut String) {
|
||
match s {
|
||
Shape::Text(t) => {
|
||
out.push_str(t.galley.text());
|
||
out.push('\n');
|
||
}
|
||
Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut text = String::new();
|
||
for c in &out.shapes {
|
||
collect(&c.shape, &mut text);
|
||
}
|
||
text
|
||
}
|
||
|
||
#[test]
|
||
fn effect_menu_has_a_vector_effects_section() {
|
||
let tree = menus::menu_tree();
|
||
let (_, effect) = tree.iter().find(|(t, _)| *t == "Effect").expect("Effect menu");
|
||
assert!(effect.iter().any(|i| matches!(i, Item::Header("Vector Effects"))));
|
||
}
|
||
|
||
#[test]
|
||
fn effect_menu_lists_film_grain_in_artistic_before_blur() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
let artistic: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Artistic"]).collect();
|
||
assert_eq!(artistic.len(), 1);
|
||
assert_eq!(artistic[0].label, "Film Grain…");
|
||
assert_eq!(artistic[0].command.as_deref(), Some("effect.dialog"));
|
||
let at = |name: &str| entries.iter().position(|e| e.path == ["Effect", name]).unwrap();
|
||
assert!(at("Artistic") < at("Blur"));
|
||
}
|
||
|
||
#[test]
|
||
fn effect_menu_lists_the_pixelate_filters_between_blur_and_sharpen() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
let pixelate: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Pixelate"]).collect();
|
||
let labels: Vec<&str> = pixelate.iter().map(|e| e.label.as_str()).collect();
|
||
assert_eq!(labels, ["Color Halftone…", "Crystallize…", "Mezzotint…", "Pointillize…"]);
|
||
assert!(pixelate.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||
let raster: Vec<&str> =
|
||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||
assert!(at("Blur") < at("Pixelate") && at("Pixelate") < at("Sharpen"));
|
||
}
|
||
|
||
/// Effect › Video, after Sharpen: De-Interlace opens its dialog, NTSC Colors (no options) applies.
|
||
#[test]
|
||
fn effect_menu_lists_the_video_filters_last() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
let video: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Video"]).collect();
|
||
let items: Vec<(&str, Option<&str>)> = video.iter().map(|e| (e.label.as_str(), e.command.as_deref())).collect();
|
||
assert_eq!(items, [("De-Interlace…", Some("effect.dialog")), ("NTSC Colors", Some("effect.apply"))]);
|
||
let raster: Vec<&str> =
|
||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||
assert!(at("Sharpen") < at("Video"));
|
||
}
|
||
|
||
/// Effect › Distort, between Blur and Pixelate: each filter opens its dialog.
|
||
#[test]
|
||
fn effect_menu_lists_the_distort_filters_between_blur_and_pixelate() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
let distort: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Distort"]).collect();
|
||
let labels: Vec<&str> = distort.iter().map(|e| e.label.as_str()).collect();
|
||
assert_eq!(labels, ["Diffuse Glow…", "Glass…", "Ocean Ripple…"]);
|
||
assert!(distort.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||
let raster: Vec<&str> =
|
||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||
assert!(at("Blur") < at("Distort") && at("Distort") < at("Pixelate"));
|
||
// The vector Distort & Transform stays its own submenu.
|
||
assert!(raster.contains(&"Distort & Transform"));
|
||
}
|
||
|
||
/// Effect › Brush Strokes, between Blur and Distort: each filter opens its dialog.
|
||
#[test]
|
||
fn effect_menu_lists_the_brush_strokes_filters_between_blur_and_distort() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
let strokes: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Brush Strokes"]).collect();
|
||
let labels: Vec<&str> = strokes.iter().map(|e| e.label.as_str()).collect();
|
||
assert_eq!(
|
||
labels,
|
||
["Accented Edges…", "Angled Strokes…", "Crosshatch…", "Dark Strokes…", "Ink Outlines…", "Spatter…", "Sprayed Strokes…", "Sumi-e…"]
|
||
);
|
||
assert!(strokes.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||
let raster: Vec<&str> =
|
||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||
assert!(at("Blur") < at("Brush Strokes") && at("Brush Strokes") < at("Distort"));
|
||
}
|
||
|
||
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
|
||
#[test]
|
||
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
let texture: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Texture"]).collect();
|
||
let labels: Vec<&str> = texture.iter().map(|e| e.label.as_str()).collect();
|
||
assert_eq!(labels, ["Craquelure…", "Grain…", "Mosaic Tiles…", "Patchwork…", "Stained Glass…", "Texturizer…"]);
|
||
assert!(texture.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||
let raster: Vec<&str> =
|
||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||
assert!(at("Sharpen") < at("Texture") && at("Texture") < at("Video"));
|
||
}
|
||
|
||
#[test]
|
||
fn library_submenus_are_disabled_placeholders() {
|
||
let app = app();
|
||
let entries = menus::menu_entries(&app);
|
||
for lib in ["Brush Libraries", "Symbol Libraries"] {
|
||
let items: Vec<_> = entries.iter().filter(|e| e.path == ["Window", lib]).collect();
|
||
let labels: Vec<&str> = items.iter().map(|e| e.label.as_str()).collect();
|
||
assert_eq!(labels, ["Built-in Libraries", "User Defined", "Other Library…"], "{lib}");
|
||
assert!(items.iter().all(|e| !e.enabled && e.command.is_none()), "{lib}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_profiles_have_descriptive_names() {
|
||
let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect();
|
||
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End", "Pinch", "Teardrop", "Wave"]);
|
||
}
|
||
|
||
#[test]
|
||
fn swatches_menu_offers_save_swatch_library() {
|
||
let mut app = app();
|
||
let text = painted_text(&mut app, crate::panels::swatches::menu);
|
||
assert!(text.contains("Save Swatch Library…"), "{text}");
|
||
assert!(!text.contains("ASE"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn edit_menu_lists_pdf_presets() {
|
||
let app = app();
|
||
// Implemented (M4.44): enabled, and it opens Edit → PDF Presets.
|
||
assert!(
|
||
menus::menu_entries(&app)
|
||
.iter()
|
||
.any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && e.enabled && e.command.as_deref() == Some("ui.pdfPresetsDialog"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_preferences_name_the_legacy_format_neutrally() {
|
||
let label = |key: &str| vectorcraft_engine::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == key).unwrap().label;
|
||
assert_eq!(label("copyAicb"), "Legacy vector clipboard (no transparency)");
|
||
assert_eq!(label("aicbMode"), "Legacy vector clipboard");
|
||
}
|
||
|
||
#[test]
|
||
fn shortcut_set_old_name_is_accepted() {
|
||
use crate::shortcut_editor::{PRESETS, import_json};
|
||
let (old, _) = crate::shortcut_editor::LEGACY_PRESETS[0];
|
||
assert_eq!(PRESETS, ["VectorCraft Defaults", "Classic Defaults"]);
|
||
// UI preferences saved by an earlier version load (and save again) under the current name.
|
||
let ui: crate::state::UiState = serde_json::from_value(json!({ "shortcut_set": old })).unwrap();
|
||
assert_eq!(ui.shortcut_set, "Classic Defaults");
|
||
let saved = serde_json::to_value(&ui).unwrap();
|
||
assert_eq!(saved["shortcut_set"], "Classic Defaults");
|
||
let back: crate::state::UiState = serde_json::from_value(saved).unwrap();
|
||
assert_eq!(back.shortcut_set, "Classic Defaults");
|
||
let default: crate::state::UiState = serde_json::from_value(json!({})).unwrap();
|
||
assert_eq!(default.shortcut_set, PRESETS[0]);
|
||
// The command and imported sets take the old name too.
|
||
let mut app = app();
|
||
app.run("shortcuts.preset", json!({ "name": old })).unwrap();
|
||
assert_eq!(app.ui.shortcut_set, "Classic Defaults");
|
||
app.run("shortcuts.preset", json!({ "name": "VectorCraft Defaults" })).unwrap();
|
||
assert_eq!(app.ui.shortcut_set, "VectorCraft Defaults");
|
||
assert!(app.run("shortcuts.preset", json!({ "name": "Nope" })).is_err());
|
||
let (set, _) = import_json(&json!({ "set": old, "overrides": {} })).unwrap();
|
||
assert_eq!(set, "Classic Defaults");
|
||
}
|