mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 11:55:23 +00:00
1096 lines
66 KiB
Rust
1096 lines
66 KiB
Rust
//! Swatch libraries: listing, reading, adding to the document (deduped, one undo step, applied)
|
||
//! and Default Swatches.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
|
||
use crate::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||
}
|
||
|
||
fn doc(s: &Session) -> &vectorcraft_doc::Document {
|
||
&s.doc().unwrap().doc
|
||
}
|
||
|
||
fn undo_len(s: &Session) -> usize {
|
||
s.doc().unwrap().history.undo.len()
|
||
}
|
||
|
||
#[test]
|
||
fn list_and_get_every_builtin_library() {
|
||
let mut s = Session::new();
|
||
let libs = run(&mut s, "swatch.library.list", json!({}));
|
||
let libs = libs["libraries"].as_array().unwrap();
|
||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||
for id in [
|
||
"web-safe-216",
|
||
"grays-neutrals",
|
||
"earth-tones",
|
||
"skin-tone-ramps",
|
||
"pastels",
|
||
"brights",
|
||
"metallic-gradients",
|
||
"perceptual-scales",
|
||
"harmony-sets",
|
||
] {
|
||
assert!(ids.contains(&id), "{id} missing from {ids:?}");
|
||
}
|
||
let web = libs.iter().find(|l| l["id"] == "web-safe-216").unwrap();
|
||
assert_eq!((web["name"].as_str(), web["count"].as_u64()), (Some("Web Safe 216"), Some(216)));
|
||
// By id or by name in any case; groups list their swatches.
|
||
let g = run(&mut s, "swatch.library.get", json!({"library": "earth tones"}));
|
||
assert_eq!(g["id"], "earth-tones");
|
||
let clay = g["groups"].as_array().unwrap().iter().find(|x| x["name"] == "Clay").unwrap();
|
||
assert_eq!(clay["swatches"].as_array().unwrap().len(), 6);
|
||
assert!(g["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "color" && w["group"].is_string()));
|
||
let m = run(&mut s, "swatch.library.get", json!({"library": "metallic-gradients"}));
|
||
assert!(m["swatches"].as_array().unwrap().iter().all(|w| w["kind"] == "gradient"));
|
||
assert!(s.execute("swatch.library.get", &json!({"library": "nope"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn add_dedupes_as_one_undo_step() {
|
||
let mut s = session();
|
||
let before = undo_len(&s);
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2", "Clay 3"]}));
|
||
// The Clay group comes whole (its third swatch once); Ochre 2 comes ungrouped.
|
||
let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||
assert_eq!(added.len(), 7, "{added:?}");
|
||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||
let d = doc(&s);
|
||
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Clay").map(|g| g.swatches.len()), Some(6));
|
||
assert!(d.swatches.iter().any(|w| w.name == "Ochre 2"));
|
||
// Adding again finds them all and changes nothing (no undo step).
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", "Ochre 2"]}));
|
||
assert_eq!((r["added"].as_array().unwrap().len(), r["existing"].as_array().unwrap().len()), (0, 7));
|
||
assert_eq!(undo_len(&s), before + 1);
|
||
// A name taken by a different colour gets a number.
|
||
run(&mut s, "swatch.new", json!({"name": "Ochre 3", "color": "#123456"}));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": ["Ochre 3"]}));
|
||
assert_eq!(r["added"], json!(["Ochre 3 2"]));
|
||
// Undo removes the whole first add.
|
||
for _ in 0..3 {
|
||
run(&mut s, "edit.undo", json!({}));
|
||
}
|
||
assert!(doc(&s).swatch_groups.iter().all(|g| g.name != "Clay") && doc(&s).swatch("Ochre 2").is_none());
|
||
assert!(s.execute("swatch.library.add", &json!({"library": "earth-tones", "names": ["Nope"]})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn add_with_apply_paints_the_selection_in_the_same_undo_step() {
|
||
let mut s = session();
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
|
||
let before = undo_len(&s);
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "web-safe-216", "names": ["#FF6600"], "apply": "fill"}));
|
||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["#FF6600"]), json!("#FF6600")));
|
||
let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
|
||
assert_eq!(fill.color().map(|c| c.to_hex()), Some("#ff6600".into()));
|
||
assert_eq!(undo_len(&s), before + 1, "added and applied as one step");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(doc(&s).swatch("#FF6600").is_none());
|
||
// A gradient swatch applied to the stroke; an existing swatch is applied without an add.
|
||
run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "stroke"}));
|
||
let n = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
|
||
assert!(matches!(n.appearance.stroke_paint(), Paint::Gradient(_)));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "metallic-gradients", "names": ["Gold"], "apply": "fill"}));
|
||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!([]), json!("Gold")));
|
||
}
|
||
|
||
#[test]
|
||
fn reset_defaults_restores_missing_swatches_or_replaces_them() {
|
||
let mut s = session();
|
||
let defaults: Vec<String> = doc(&s).swatches_iter().map(|w| w.name.clone()).collect();
|
||
run(&mut s, "swatch.delete", json!({"names": ["Red", "Grays"]}));
|
||
run(&mut s, "swatch.new", json!({"name": "Mine", "color": "#abcdef"}));
|
||
let r = run(&mut s, "swatch.resetDefaults", json!({}));
|
||
assert!(r["added"].as_array().unwrap().iter().any(|n| n == "Red"));
|
||
let d = doc(&s);
|
||
assert!(d.swatch("Mine").is_some(), "the user's swatches stay");
|
||
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Grays").map(|g| g.swatches.len()), Some(9));
|
||
assert!(defaults.iter().all(|n| d.swatch(n).is_some()));
|
||
// Replace: exactly the defaults; art linked to a removed global swatch keeps its colour.
|
||
run(&mut s, "swatch.edit", json!({"name": "Mine", "global": true}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone();
|
||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Mine"}));
|
||
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()), tint: 1.0 });
|
||
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
|
||
let d = doc(&s);
|
||
assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>(), defaults);
|
||
let fill = d.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
|
||
assert_eq!(fill, Paint::solid(Color::from_hex("#abcdef").unwrap()));
|
||
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too");
|
||
}
|
||
|
||
/// A fresh scratch folder for one test.
|
||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||
let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn save_and_load_round_trip_keeps_cmyk_spot_and_groups() {
|
||
let mut s = session();
|
||
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
|
||
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
|
||
let r = run(&mut s, "swatch.library.save", json!({"names": ["Ink", "Brand", "Sunset"], "name": "Brand Kit"}));
|
||
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("vcswatches"), Some(4)));
|
||
let data = r["data"].as_str().unwrap().to_string();
|
||
let r = run(&mut s, "swatch.library.load", json!({"data": data, "name": "kit.vcswatches"}));
|
||
assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Brand Kit"), Some(4)));
|
||
let id = r["library"].as_str().unwrap().to_string();
|
||
let (_, lib) = cmd::swatchlib::library(&s, &id).unwrap();
|
||
let ink = lib.swatch("Ink").unwrap();
|
||
assert!(ink.spot && ink.global);
|
||
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
|
||
assert_eq!(lib.groups[0].name, "Brand");
|
||
assert_eq!(lib.groups[0].swatches.len(), 2);
|
||
assert!(matches!(lib.swatch("Sunset").unwrap().paint, Paint::Gradient(_)));
|
||
// Listed as loaded; adding from it works like any library.
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == id.as_str() && l["category"] == "loaded"));
|
||
assert!(s.execute("swatch.library.save", &json!({"names": ["Nope"]})).is_err());
|
||
assert!(s.execute("swatch.library.save", &json!({"user": true})).is_err(), "no user folder in a headless session");
|
||
}
|
||
|
||
/// The swatch library commands, and the graphic style library save and load that share their file
|
||
/// handling, refuse a parameter of the wrong form, such as `user: "true"` or `names: "Grays"`, with
|
||
/// an error that names it; the document keeps its swatches and no undo step is recorded. A null
|
||
/// counts as a value left out.
|
||
#[test]
|
||
fn library_commands_refuse_params_of_the_wrong_form() {
|
||
let mut s = session();
|
||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||
let swatches = |s: &Session| doc(s).swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>();
|
||
let (names, before) = (swatches(&s), undo_len(&s));
|
||
let data = run(&mut s, "swatch.library.save", json!({"names": ["Grays"]}))["data"].clone();
|
||
for (cmd, p, msg) in [
|
||
("swatch.library.get", json!({"library": 5}), "`library` must be a string"),
|
||
("swatch.library.add", json!({"library": "earth-tones", "names": "Clay"}), "`names` must be a list of names"),
|
||
("swatch.library.add", json!({"library": "earth-tones", "names": ["Clay", 3]}), "`names` must be a list of names"),
|
||
("swatch.library.add", json!({"library": "earth-tones", "names": ["Clay"], "apply": 5}), "`apply` must be a string"),
|
||
("swatch.library.add", json!({"library": "earth-tones", "apply": "fill", "focus": "true"}), "`focus` must be true or false"),
|
||
("swatch.resetDefaults", json!({"replace": "true"}), "`replace` must be true or false"),
|
||
("swatch.library.save", json!({"user": "true"}), "`user` must be true or false"),
|
||
("swatch.library.save", json!({"path": 5}), "`path` must be a string"),
|
||
("swatch.library.save", json!({"format": 5}), "`format` must be a string"),
|
||
("swatch.library.save", json!({"name": 5}), "`name` must be a string"),
|
||
("swatch.library.save", json!({"names": "Grays"}), "`names` must be a list of names"),
|
||
("swatch.library.load", json!({"path": 5, "data": data}), "`path` must be a string"),
|
||
("swatch.library.load", json!({"data": 5}), "`data` must be a string"),
|
||
("swatch.library.load", json!({"dataBase64": 5}), "`dataBase64` must be a string"),
|
||
("swatch.library.load", json!({"data": data, "name": 5}), "`name` must be a string"),
|
||
("graphicStyle.saveLibrary", json!({"user": "true"}), "`user` must be true or false"),
|
||
("graphicStyle.saveLibrary", json!({"names": "Halo"}), "`names` must be a list of names"),
|
||
("graphicStyle.saveLibrary", json!({"name": 5}), "`name` must be a string"),
|
||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": "Halo"}), "`names` must be a list of names"),
|
||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": 5}), "`name` must be a string"),
|
||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": "true"}), "`apply` must be true or false"),
|
||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": true, "add": "true"}), "`add` must be true or false"),
|
||
("graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "apply": true, "ids": "x"}), "ids must be an array"),
|
||
("graphicStyle.loadLibrary", json!({"path": 5, "data": "{}"}), "`path` must be a string"),
|
||
] {
|
||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||
assert!(e.contains(msg), "{cmd} {p}: {e}");
|
||
}
|
||
assert_eq!((swatches(&s), undo_len(&s)), (names.clone(), before), "nothing changed");
|
||
let r = run(&mut s, "swatch.library.save", json!({"path": null, "format": null, "name": null, "names": null, "user": null}));
|
||
assert_eq!(r["format"], "vcswatches");
|
||
let r = run(&mut s, "swatch.library.load", json!({"path": null, "data": data, "dataBase64": null, "name": null}));
|
||
assert_eq!(r["count"], 9);
|
||
run(&mut s, "swatch.resetDefaults", json!({"replace": null}));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "earth-tones", "names": null, "apply": null, "focus": null}));
|
||
assert!(r["added"].as_array().is_some_and(|a| !a.is_empty()) && r.get("applied").is_none(), "{r}");
|
||
run(&mut s, "graphicStyle.saveLibrary", json!({"name": null, "names": null}));
|
||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": null, "names": null}));
|
||
assert!(r["added"].as_array().is_some_and(|a| !a.is_empty()), "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn library_loads_validate_name_before_reading_a_file_or_decoding_data() {
|
||
let mut s = session();
|
||
let missing = temp_dir("invalid-load-name").join("missing.vcswatches").to_string_lossy().to_string();
|
||
for cmd in ["swatch.library.load", "graphicStyle.loadLibrary", "symbol.loadLibrary", "brush.loadLibrary"] {
|
||
for name in [json!(5), json!([])] {
|
||
let p = json!({"path": missing, "name": name});
|
||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||
assert!(e.contains("`name` must be a string"), "{cmd} {p}: {e}");
|
||
}
|
||
let p = json!({"dataBase64": "not base64!", "name": 5});
|
||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||
assert!(e.contains("`name` must be a string"), "{cmd} {p}: {e}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn libraries_saved_in_the_user_folder_are_user_defined() {
|
||
let dir = temp_dir("user");
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "My: Greys", "format": "gpl", "names": ["Grays"]}));
|
||
assert_eq!(r["library"], "user/My- Greys.gpl");
|
||
let path = r["path"].as_str().unwrap().to_string();
|
||
assert!(std::fs::read_to_string(&path).unwrap().starts_with("GIMP Palette\nName: My: Greys\n"));
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect();
|
||
assert_eq!(user.len(), 1);
|
||
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Greys"), Some(9)));
|
||
// Opening a file of the user folder gives its User Defined library.
|
||
let r = run(&mut s, "swatch.library.load", json!({ "path": path }));
|
||
assert_eq!(r["library"], "user/My- Greys.gpl");
|
||
// CSS by extension.
|
||
let css = dir.join("out.css").to_string_lossy().to_string();
|
||
run(&mut s, "swatch.library.save", json!({ "path": css }));
|
||
assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;"));
|
||
// A name with nothing a file name can keep is saved as Library.vcswatches, and User Defined lists it.
|
||
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "..."}));
|
||
assert_eq!(r["library"], "user/Library.vcswatches");
|
||
assert_eq!(cmd::swatchlib::library(&s, "user/Library.vcswatches").map(|(info, _)| info.name), Some("...".into()));
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
/// A library kind whose reader panics on a file that holds `trap`, as a parser bug would.
|
||
#[derive(Default)]
|
||
struct Trap(String);
|
||
|
||
impl cmd::swatchlib::LibraryFile for Trap {
|
||
const EXTS: &'static [&'static str] = &["trap"];
|
||
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
|
||
if bytes == b"trap" {
|
||
panic!("a parser bug");
|
||
}
|
||
Ok(Self(stem.to_string()))
|
||
}
|
||
fn name(&self) -> &str {
|
||
&self.0
|
||
}
|
||
}
|
||
|
||
/// When reading one file of the user library folder panics, `set_user_dir` (as the desktop app
|
||
/// calls it at launch) skips that file and lists the other one.
|
||
#[test]
|
||
fn a_user_library_file_whose_reading_panics_is_skipped() {
|
||
let dir = temp_dir("user-panic");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("Calm.trap"), "calm").unwrap();
|
||
std::fs::write(dir.join("Loud.trap"), "trap").unwrap();
|
||
let mut libraries = cmd::swatchlib::Libraries::<Trap>::default();
|
||
libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let names: Vec<&str> = libraries.infos().map(|l| l.name.as_str()).collect();
|
||
assert_eq!(names, ["Calm"]);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn another_documents_swatches_load_as_a_library() {
|
||
let mut s = session();
|
||
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
|
||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Poster.vectorcraft"}));
|
||
assert_eq!(r["name"], "Poster");
|
||
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
|
||
assert!(lib.swatch("Signal").is_some() && lib.swatch("[None]").is_none());
|
||
assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
|
||
let bytes = vectorcraft_testkit::ase::sample();
|
||
let mut s = session();
|
||
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Brand.ase"}));
|
||
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some("loaded/Brand.ase"), Some("Brand"), Some(4)));
|
||
let g = run(&mut s, "swatch.library.get", json!({"library": r["library"]}));
|
||
let swatches = g["swatches"].as_array().unwrap();
|
||
let kinds: Vec<(&str, bool, bool)> =
|
||
swatches.iter().map(|w| (w["name"].as_str().unwrap(), w["global"].as_bool().unwrap(), w["spot"].as_bool().unwrap())).collect();
|
||
assert_eq!(kinds, [("Sky", true, false), ("Ink", true, true), ("Mist", false, false), ("Clay", false, false)]);
|
||
assert_eq!(swatches[3]["color"], json!({"model": "lab", "l": 50.0, "a": 20.0, "b": -30.0}));
|
||
// Added to the document, the spot color stays a spot color and the group stays a color group.
|
||
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
|
||
let d = doc(&s);
|
||
assert!(d.swatch("Ink").is_some_and(|w| w.spot && w.global));
|
||
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Neutrals").map(|g| g.swatches.len()), Some(2));
|
||
// A damaged file is an error from the swatch exchange reader, not from the document loader.
|
||
let cut = vectorcraft_format::base64_encode(&bytes[..bytes.len() - 1]);
|
||
let e = s.execute("swatch.library.load", &json!({"dataBase64": cut, "name": "Cut.ase"})).unwrap_err().to_string();
|
||
assert!(e.contains("cut short"), "{e}");
|
||
// `.ase` files in the user library folder are User Defined libraries.
|
||
let dir = temp_dir("ase");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("Brand.ase"), &bytes).unwrap();
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let user: Vec<(&str, u64)> = list["libraries"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter(|l| l["category"] == "user")
|
||
.map(|l| (l["id"].as_str().unwrap(), l["count"].as_u64().unwrap()))
|
||
.collect();
|
||
assert_eq!(user, [("user/Brand.ase", 4)]);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn swatch_exchange_files_save_with_models_kinds_and_groups() {
|
||
let mut s = session();
|
||
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
|
||
run(&mut s, "swatch.new", json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}}));
|
||
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
|
||
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "names": ["Ink", "Clay", "Brand", "Grays", "Sunset"], "name": "Brand Kit"}));
|
||
assert_eq!(r["format"], "ase");
|
||
assert!(r.get("data").is_none(), "a binary file comes back as dataBase64");
|
||
let b64 = r["dataBase64"].as_str().unwrap().to_string();
|
||
assert!(vectorcraft_format::base64_decode(&b64).unwrap().starts_with(b"ASEF"));
|
||
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Brand Kit.ase"}));
|
||
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
|
||
assert_eq!(lib.name, "Brand Kit", "named after the file");
|
||
let ink = lib.swatch("Ink").unwrap();
|
||
assert!(ink.spot && ink.global);
|
||
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
|
||
assert_eq!(lib.swatch("Clay").unwrap().paint.color(), Some(Color::lab(50.0, 20.0, -30.0)));
|
||
assert!(lib.swatch("Sunset").is_none(), "a gradient is left out");
|
||
let groups: Vec<(&str, usize)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect();
|
||
assert_eq!(groups, [("Grays", 9), ("Brand", 2)], "in document order");
|
||
// Gray colors come back within float rounding.
|
||
let grays = &doc(&s).swatch_groups.iter().find(|g| g.name == "Grays").unwrap().swatches;
|
||
for (w, want) in lib.groups[0].swatches.iter().zip(grays) {
|
||
let (Some(Color::Gray { k }), Some(Color::Gray { k: want_k })) = (w.paint.color(), want.paint.color()) else {
|
||
panic!("{} isn't gray", w.name)
|
||
};
|
||
assert!(w.name == want.name && (k - want_k).abs() < 1e-4, "{}: {k} for {want_k}", w.name);
|
||
}
|
||
// Into the user library folder, and to a path ending in .ase.
|
||
let dir = temp_dir("ase-save");
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "user": true, "name": "Brand Kit"}));
|
||
assert_eq!(r["library"], "user/Brand Kit.ase");
|
||
assert!(std::fs::read(r["path"].as_str().unwrap()).unwrap().starts_with(b"ASEF"));
|
||
assert!(cmd::swatchlib::library(&s, "user/Brand Kit.ase").is_some(), "the user folder lists it");
|
||
let path = dir.join("out.ase").to_string_lossy().to_string();
|
||
assert_eq!(run(&mut s, "swatch.library.save", json!({ "path": path }))["format"], "ase");
|
||
assert!(std::fs::read(&path).unwrap().starts_with(b"ASEF"));
|
||
let e = s.execute("swatch.library.save", &json!({"format": "aco"})).unwrap_err().to_string();
|
||
assert!(e.contains("vcswatches, gpl, ase or css"), "{e}");
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn save_counts_the_swatches_the_file_holds() {
|
||
let mut s = session();
|
||
// A new document has 46 solid colors and 4 linear or radial gradients besides None. Mesh is a freeform gradient.
|
||
run(&mut s, "swatch.new", json!({"name": "Mesh", "gradient": {"kind": "freeform"}}));
|
||
let mut counts = vec![];
|
||
for format in ["vcswatches", "gpl", "ase", "css"] {
|
||
let r = run(&mut s, "swatch.library.save", json!({ "format": format }));
|
||
// The libraries load back with that many swatches, and the CSS file has one property for each.
|
||
let held = if format == "css" {
|
||
r["data"].as_str().unwrap().lines().filter(|l| l.starts_with(" --")).count() as u64
|
||
} else {
|
||
let load = json!({"data": r["data"], "dataBase64": r["dataBase64"], "name": format!("Saved.{format}")});
|
||
run(&mut s, "swatch.library.load", load)["count"].as_u64().unwrap()
|
||
};
|
||
counts.push((format, r["count"].as_u64().unwrap(), held));
|
||
}
|
||
assert_eq!(counts, [("vcswatches", 51, 51), ("gpl", 46, 46), ("ase", 46, 46), ("css", 50, 50)]);
|
||
}
|
||
|
||
#[test]
|
||
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
|
||
use vectorcraft_testkit::ase::{Block, ase, sample};
|
||
let dir = temp_dir("copy");
|
||
let (user, elsewhere) = (dir.join("user"), dir.join("elsewhere"));
|
||
std::fs::create_dir_all(&user).unwrap();
|
||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||
let source = elsewhere.join("Brand.ase");
|
||
std::fs::write(&source, sample()).unwrap();
|
||
let source = source.to_string_lossy().to_string();
|
||
// Another library whose file name differs only in case.
|
||
let other = ase(&[Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]);
|
||
std::fs::write(user.join("brand.ase"), &other).unwrap();
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
|
||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
|
||
assert_eq!(loaded, format!("loaded/{source}"));
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||
// A swatch exchange library is named after its file.
|
||
assert_eq!(
|
||
(r["library"].as_str(), r["name"].as_str(), r["count"].as_u64(), r["copied"].as_bool()),
|
||
(Some("user/Brand 2.ase"), Some("Brand 2"), Some(4), Some(true))
|
||
);
|
||
assert_eq!(r["path"], user.join("Brand 2.ase").to_string_lossy().to_string());
|
||
assert_eq!(std::fs::read(user.join("Brand 2.ase")).unwrap(), sample(), "the file as it is");
|
||
assert_eq!(std::fs::read(user.join("brand.ase")).unwrap(), other, "never replaced");
|
||
let listed = |s: &mut Session| -> Vec<String> {
|
||
let list = run(s, "swatch.library.list", json!({}));
|
||
let libs = list["libraries"].as_array().unwrap().iter().filter(|l| matches!(l["category"].as_str(), Some("user" | "loaded")));
|
||
libs.map(|l| l["id"].as_str().unwrap().to_string()).collect()
|
||
};
|
||
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"], "listed as User Defined only");
|
||
// The loaded id finds the copy, so choices made with it (Limit to Library) keep their colors.
|
||
let found = cmd::swatchlib::library(&s, loaded.as_str().unwrap()).map(|(info, _)| info.id);
|
||
assert_eq!(found.as_deref(), Some("user/Brand 2.ase"));
|
||
assert!(cmd::swatchlib::limit_palette(&s, loaded.as_str().unwrap()).is_some());
|
||
// The same file again opens the copy, which is listed once; copying it writes nothing.
|
||
let again = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
|
||
assert_eq!(again, "user/Brand 2.ase");
|
||
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"]);
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": again }));
|
||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Brand 2.ase"), Some(false)));
|
||
assert_eq!(std::fs::read_dir(&user).unwrap().count(), 2);
|
||
// A document's swatches and a library loaded from data are written as .vcswatches.
|
||
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
|
||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||
let poster = elsewhere.join("Poster.vectorcraft");
|
||
std::fs::write(&poster, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
|
||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(true)));
|
||
assert!(cmd::swatchlib::library(&s, "user/Poster.vcswatches").unwrap().1.swatch("Signal").is_some());
|
||
// Copied again, the document's swatches are written as the same bytes: the copy there is used.
|
||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(false)));
|
||
let kit = vectorcraft_format::base64_encode(&sample());
|
||
let loaded = run(&mut s, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
|
||
assert_eq!(run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }))["library"], "user/Kit.vcswatches");
|
||
// A library without a name is copied as Library.
|
||
let loaded = run(&mut s, "swatch.library.load", json!({"data": "GIMP Palette\nName:\n0 0 0 Black\n"}))["library"].clone();
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Library.vcswatches"), Some("Library"), Some(true)));
|
||
// When the source no longer reads as a library, the copy fails and no new file is left in the folder.
|
||
let junk = elsewhere.join("Junk.ase");
|
||
std::fs::write(&junk, ase(&[Block::Color("Junk", b"RGB ", &[0.0, 1.0, 0.0], 2)])).unwrap();
|
||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": junk.to_string_lossy() }))["library"].clone();
|
||
std::fs::write(&junk, b"not a library").unwrap();
|
||
let files = std::fs::read_dir(&user).unwrap().count();
|
||
let e = s.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
|
||
assert!(e.contains("the copy `user/Junk.ase` can't be read as a library"), "{e}");
|
||
assert_eq!(std::fs::read_dir(&user).unwrap().count(), files);
|
||
// A User Defined library comes back as it is; a built-in one is an error.
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({"library": "user/brand.ase"}));
|
||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/brand.ase"), Some(false)));
|
||
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "pastels"})).unwrap_err().to_string().contains("built in"));
|
||
assert!(s.execute("swatch.library.copyToUser", &json!({})).is_err());
|
||
// Without a user library folder (the web, headless sessions) there is nowhere to copy to, and
|
||
// the command is off.
|
||
let mut headless = session();
|
||
let info = find_command("swatch.library.copyToUser").unwrap().info(&headless);
|
||
assert!(!info.enabled && info.disabled_reason.as_deref() == Some("no user library folder here"), "{info:?}");
|
||
assert!(find_command("swatch.library.copyToUser").unwrap().info(&s).enabled);
|
||
let loaded = run(&mut headless, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
|
||
let e = headless.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
|
||
assert!(e.contains("no user library folder"), "{e}");
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
/// A color book opened from a host temporary path (macOS sandbox copies) is stored under its title
|
||
/// when copied into the user library folder (#2996).
|
||
#[test]
|
||
fn copying_a_color_book_from_a_temporary_path_uses_its_title() {
|
||
fn acb_str(out: &mut Vec<u8>, s: &str) {
|
||
let units: Vec<u16> = s.encode_utf16().collect();
|
||
out.extend((units.len() as u32).to_be_bytes());
|
||
units.iter().for_each(|u| out.extend(u.to_be_bytes()));
|
||
}
|
||
let mut bytes = b"8BCB".to_vec();
|
||
bytes.extend(1u16.to_be_bytes());
|
||
bytes.extend(3000u16.to_be_bytes());
|
||
for s in ["$$$/colorbook/Test/title=Test Book^R", "TEST ", " C", "a book"] {
|
||
acb_str(&mut bytes, s);
|
||
}
|
||
bytes.extend(1u16.to_be_bytes());
|
||
bytes.extend(7u16.to_be_bytes());
|
||
bytes.extend(0u16.to_be_bytes());
|
||
bytes.extend(0u16.to_be_bytes());
|
||
acb_str(&mut bytes, "185");
|
||
bytes.extend(b"TST001");
|
||
bytes.extend([228, 0, 43]);
|
||
bytes.extend(b"spflspot");
|
||
|
||
let dir = temp_dir("acb-temp");
|
||
let user = dir.join("user");
|
||
let elsewhere = dir.join("elsewhere");
|
||
std::fs::create_dir_all(&user).unwrap();
|
||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||
let source = elsewhere.join("tmp1F2C.tmp.acb");
|
||
std::fs::write(&source, &bytes).unwrap();
|
||
let source = source.to_string_lossy().to_string();
|
||
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
|
||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
|
||
assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": loaded }))["name"], "Test Book®");
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Test Book®.acb"), Some("Test Book®"), Some(true)));
|
||
assert_eq!(r["path"], user.join("Test Book®.acb").to_string_lossy().to_string());
|
||
assert_eq!(std::fs::read(user.join("Test Book®.acb")).unwrap(), bytes);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
/// Color books in the user library folder and opened by path show the file name as the tab title
|
||
/// (#3395), not only the title stored inside the book.
|
||
#[test]
|
||
fn color_book_tab_titles_use_the_file_name() {
|
||
fn acb_bytes(title: &str) -> Vec<u8> {
|
||
fn acb_str(out: &mut Vec<u8>, s: &str) {
|
||
let units: Vec<u16> = s.encode_utf16().collect();
|
||
out.extend((units.len() as u32).to_be_bytes());
|
||
units.iter().for_each(|u| out.extend(u.to_be_bytes()));
|
||
}
|
||
let mut bytes = b"8BCB".to_vec();
|
||
bytes.extend(1u16.to_be_bytes());
|
||
bytes.extend(3000u16.to_be_bytes());
|
||
for s in [title, "PANTONE ", " C", ""] {
|
||
acb_str(&mut bytes, s);
|
||
}
|
||
bytes.extend(1u16.to_be_bytes());
|
||
bytes.extend(7u16.to_be_bytes());
|
||
bytes.extend(0u16.to_be_bytes());
|
||
bytes.extend(0u16.to_be_bytes());
|
||
acb_str(&mut bytes, "185");
|
||
bytes.extend(b"TST001");
|
||
bytes.extend([228, 0, 43]);
|
||
bytes.extend(b"spflspot");
|
||
bytes
|
||
}
|
||
let dir = temp_dir("acb-name");
|
||
let user = dir.join("user");
|
||
let elsewhere = dir.join("elsewhere");
|
||
std::fs::create_dir_all(&user).unwrap();
|
||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||
let on_disk = user.join("PANTONE Solid Coated.acb");
|
||
std::fs::write(&on_disk, acb_bytes("Internal Book Title")).unwrap();
|
||
let named = elsewhere.join("Brand Colors.acb");
|
||
std::fs::write(&named, acb_bytes("Another Internal Title")).unwrap();
|
||
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
|
||
assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": "user/PANTONE Solid Coated.acb" }))["name"], "PANTONE Solid Coated");
|
||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": named.to_string_lossy() }))["library"].clone();
|
||
assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": loaded }))["name"], "Brand Colors");
|
||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Brand Colors.acb"), Some("Brand Colors"), Some(true)));
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
|
||
use vectorcraft_testkit::ase::{Block, ase};
|
||
let mut s = session();
|
||
assert!(doc(&s).swatch("Blue").is_some(), "a new document has a swatch named Blue");
|
||
let bytes = ase(&[
|
||
Block::GroupStart("Blue"),
|
||
Block::Color("Deep", b"RGB ", &[0.0, 0.0, 0.4], 2),
|
||
Block::Color("Pale", b"RGB ", &[0.6, 0.7, 1.0], 2),
|
||
Block::GroupEnd,
|
||
Block::GroupStart("Blue 2"),
|
||
Block::Color("Frost", b"RGB ", &[0.9, 0.95, 1.0], 2),
|
||
Block::GroupEnd,
|
||
]);
|
||
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Blues.ase"}));
|
||
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
|
||
let groups: Vec<(&str, Vec<&str>)> =
|
||
doc(&s).swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
|
||
assert!(groups.contains(&("Blue 2", vec!["Deep", "Pale"])), "{groups:?}");
|
||
assert!(groups.contains(&("Blue 2 2", vec!["Frost"])), "the library's own Blue 2 stays apart: {groups:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn text_libraries_sent_as_bytes_load_when_they_are_not_valid_utf8() {
|
||
let mut s = session();
|
||
let latin1 = vectorcraft_format::base64_encode(b"GIMP Palette\n237 28 36 Rouge fonc\xe9\n");
|
||
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": latin1, "name": "Rouge.gpl"}));
|
||
assert_eq!(r["count"], 1);
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradients() {
|
||
let mut s = session();
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let grads: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "gradients").collect();
|
||
assert!(grads.len() >= 5 && grads.iter().any(|l| l["id"] == "sky-gradients"));
|
||
run(&mut s, "swatch.library.add", json!({"library": "sky-gradients", "names": ["Dawn"]}));
|
||
// Rename keeps the gradient.
|
||
let before = doc(&s).swatch("Dawn").unwrap().paint.clone();
|
||
run(&mut s, "swatch.edit", json!({"name": "Dawn", "newName": "Early"}));
|
||
assert_eq!(doc(&s).swatch("Early").unwrap().paint, before);
|
||
// An edited gradient replaces it, unplaced and unlinked; one undo step.
|
||
let undo = undo_len(&s);
|
||
let g =
|
||
json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "swatch": "Early"}});
|
||
run(&mut s, "swatch.edit", json!({"name": "Early", "paint": g}));
|
||
let Paint::Gradient(gp) = &doc(&s).swatch("Early").unwrap().paint else { panic!("a gradient") };
|
||
assert_eq!((gp.gradient.kind, gp.gradient.stops.len(), gp.swatch.clone(), gp.geom), (vectorcraft_color::GradientKind::Radial, 2, None, None));
|
||
assert_eq!(undo_len(&s), undo + 1);
|
||
// Kinds don't mix; a colour replaces a colour.
|
||
assert!(s.execute("swatch.edit", &json!({"name": "Early", "paint": {"color": "#00ff00"}})).is_err());
|
||
run(&mut s, "swatch.edit", json!({"name": "Red", "paint": {"color": "#00ff00"}}));
|
||
assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() {
|
||
let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap();
|
||
for mode in ["rgb", "cmyk"] {
|
||
let mut s = Session::new();
|
||
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "organic-fades").unwrap().clone();
|
||
assert_eq!(
|
||
(row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()),
|
||
(Some("Organic Fades"), Some("gradients"), Some(lib.len() as u64))
|
||
);
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "organic-fades", "names": ["Petal", "Dew"], "apply": "fill"}));
|
||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Petal", "Dew"]), json!("Petal")), "{mode}");
|
||
for name in ["Petal", "Dew"] {
|
||
// The swatch comes as the library has it: stops (colors as given, opacities, midpoints) and method.
|
||
let Paint::Gradient(added) = &doc(&s).swatch(name).unwrap().paint else { panic!("{name} is a gradient") };
|
||
let Paint::Gradient(want) = &lib.swatch(name).unwrap().paint else { panic!("{name} is a gradient") };
|
||
assert_eq!(added.gradient, want.gradient, "{mode} {name}");
|
||
assert_eq!(added.gradient.method, vectorcraft_color::GradientMethod::Perceptual);
|
||
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode} {name}: the stops stay RGB");
|
||
assert!(added.gradient.stops.iter().any(|st| st.opacity < 1.0 && (st.midpoint - 0.5).abs() > 0.02), "{mode} {name}");
|
||
}
|
||
// The fill takes the fade's method, opacities and midpoints.
|
||
let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
|
||
let (Paint::Gradient(fill), Paint::Gradient(want)) = (fill, &lib.swatch("Petal").unwrap().paint) else { panic!("a gradient fill") };
|
||
assert_eq!(fill.gradient.method, want.gradient.method, "{mode}");
|
||
let shape = |g: &vectorcraft_color::Gradient| g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect::<Vec<_>>();
|
||
assert_eq!(shape(&fill.gradient), shape(&want.gradient), "{mode}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() {
|
||
let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap();
|
||
let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") };
|
||
for mode in ["rgb", "cmyk"] {
|
||
let mut s = Session::new();
|
||
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone();
|
||
assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14)));
|
||
let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"}));
|
||
assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo");
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
|
||
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
|
||
let before = undo_len(&s);
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"}));
|
||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}");
|
||
assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step");
|
||
// The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method.
|
||
let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") };
|
||
assert_eq!(added.gradient, want.gradient, "{mode}");
|
||
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB");
|
||
assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge");
|
||
let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") };
|
||
assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}");
|
||
assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add");
|
||
assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill");
|
||
// A document swatch already named Mint Halo stays; the glow comes in under a number.
|
||
run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"}));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]}));
|
||
assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}");
|
||
assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept");
|
||
assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn pattern_libraries_bring_their_patterns_and_add_each_once() {
|
||
let mut s = session();
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let libs = list["libraries"].as_array().unwrap();
|
||
let categories: Vec<&str> = libs.iter().map(|l| l["category"].as_str().unwrap()).collect();
|
||
let first = categories.iter().position(|c| *c == "patterns").expect("a pattern library");
|
||
assert!(categories.iter().rposition(|c| *c == "gradients") < Some(first), "{categories:?}");
|
||
let geo = libs.iter().find(|l| l["id"] == "geometric-patterns").unwrap();
|
||
assert_eq!(geo["name"], "Geometric Patterns");
|
||
let got = run(&mut s, "swatch.library.get", json!({"library": "geometric patterns"}));
|
||
let swatches = got["swatches"].as_array().unwrap();
|
||
assert_eq!(swatches.len() as u64, geo["count"].as_u64().unwrap());
|
||
assert!(swatches.iter().all(|w| w["kind"] == "pattern" && w["pattern"] == w["name"]));
|
||
// Clicking a pattern adds it with its definition and fills the selection with it, one undo step.
|
||
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
||
let before = undo_len(&s);
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "fill"}));
|
||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Polka Dots"]), json!("Polka Dots")));
|
||
assert_eq!(undo_len(&s), before + 1, "added and applied as one step");
|
||
let d = doc(&s);
|
||
let def = d.pattern("Polka Dots").expect("the pattern comes with its swatch");
|
||
assert!(!def.art.is_empty());
|
||
assert!(matches!(d.swatch("Polka Dots").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Polka Dots"));
|
||
assert!(matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots"));
|
||
// Again: nothing is added; the stroke takes the same pattern.
|
||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||
let had = counts(doc(&s));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "stroke"}));
|
||
assert_eq!((r["added"].clone(), r["existing"].clone(), r["applied"].clone()), (json!([]), json!(["Polka Dots"]), json!("Polka Dots")));
|
||
assert_eq!(counts(doc(&s)), had);
|
||
assert!(matches!(doc(&s).node(id).unwrap().appearance.stroke_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots"));
|
||
// A different pattern under the name: the library's comes in numbered, with its swatch.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
s.edit("Pattern", |d, _| {
|
||
let dot = vectorcraft_doc::Node::path(
|
||
d.alloc_id(),
|
||
vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)),
|
||
vectorcraft_doc::Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0),
|
||
);
|
||
d.patterns.push(vectorcraft_doc::PatternDef::new("Polka Dots", vec![std::sync::Arc::new(dot)]));
|
||
d.swatches.push(vectorcraft_color::Swatch {
|
||
name: "Polka Dots".into(),
|
||
paint: vectorcraft_doc::pattern::pattern_paint("Polka Dots"),
|
||
global: false,
|
||
spot: false,
|
||
});
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "apply": "fill"}));
|
||
let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||
assert_eq!((added.len(), added[0], r["applied"].as_str()), (swatches.len(), "Polka Dots 2", Some("Polka Dots 2")));
|
||
let d = doc(&s);
|
||
assert!(added.iter().all(|n| d.pattern(n).is_some() && d.swatch(n).is_some()), "{added:?}");
|
||
assert!(matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots 2"));
|
||
// A swatch of the pattern moved in its space is not the pattern's own swatch: a new one is added
|
||
// and applied, and no second pattern.
|
||
s.edit("Move", |d, _| {
|
||
if let Some(w) = d.swatches_iter_mut().find(|w| w.name == "Polka Dots 2") {
|
||
w.paint = Paint::Pattern { pattern: "Polka Dots 2".into(), xf: kurbo::Affine::translate((3.0, 0.0)) };
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let patterns = doc(&s).patterns.len();
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "fill"}));
|
||
let applied = r["applied"].as_str().unwrap().to_string();
|
||
assert_ne!(applied, "Polka Dots 2");
|
||
let d = doc(&s);
|
||
assert_eq!(
|
||
(d.patterns.len(), d.swatch(&applied).map(|w| w.paint.clone())),
|
||
(patterns, Some(vectorcraft_doc::pattern::pattern_paint("Polka Dots 2")))
|
||
);
|
||
assert_eq!(d.node(id).unwrap().appearance.fill_paint(), vectorcraft_doc::pattern::pattern_paint("Polka Dots 2"));
|
||
// A pattern library has no colours to limit to, and is built in.
|
||
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "geometric-patterns"})).is_err());
|
||
}
|
||
|
||
/// The built-in pattern libraries: unique, tiling patterns with art, whose names no two libraries
|
||
/// share. Each library's own swatches draw from it; a swatch of the same name elsewhere does not.
|
||
#[test]
|
||
fn built_in_pattern_libraries_hold_tiling_patterns_with_unique_names() {
|
||
use crate::cmd::patternlib::{PATTERN_LIBRARIES, library, library_doc, swatch_source};
|
||
let mut names = std::collections::HashSet::new();
|
||
for b in PATTERN_LIBRARIES {
|
||
let d = library_doc(b.id).unwrap();
|
||
assert!(!d.patterns.is_empty(), "{}", b.id);
|
||
for p in &d.patterns {
|
||
assert!(names.insert(p.name.clone()), "{} is in two libraries", p.name);
|
||
assert!(!p.art.is_empty() && p.width() > 0.0 && p.height() > 0.0, "{}", p.name);
|
||
assert!(vectorcraft_render::render_pattern_swatch(&d, &p.name, 16).is_some(), "{}", p.name);
|
||
}
|
||
let (_, lib) = library(b.id).unwrap();
|
||
for w in &lib.swatches {
|
||
assert!(std::sync::Arc::ptr_eq(&swatch_source(w).unwrap(), &d), "{}", w.name);
|
||
assert!(swatch_source(&w.clone()).is_none(), "a copy of {} is another library's swatch", w.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Listing and reading a pattern library add nothing to the document. A pattern added from it
|
||
/// undoes and redoes with its swatch, and both stay after a save and reopen.
|
||
#[test]
|
||
fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() {
|
||
let mut s = session();
|
||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||
let empty = counts(doc(&s));
|
||
run(&mut s, "swatch.library.list", json!({}));
|
||
run(&mut s, "swatch.library.get", json!({"library": "geometric-patterns"}));
|
||
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
|
||
run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Checkerboard"]}));
|
||
assert_eq!(counts(doc(&s)), (empty.0 + 1, empty.1 + 1), "only the picked pattern and its swatch");
|
||
let has = |d: &vectorcraft_doc::Document| d.pattern("Checkerboard").is_some() && d.swatch("Checkerboard").is_some();
|
||
assert!(has(doc(&s)));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(counts(doc(&s)), empty);
|
||
assert!(doc(&s).pattern("Checkerboard").is_none() && doc(&s).swatch("Checkerboard").is_none());
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert!(has(doc(&s)));
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||
assert_eq!(back.patterns, doc(&s).patterns);
|
||
assert!(has(&back));
|
||
assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint));
|
||
}
|
||
|
||
/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch
|
||
/// once, as one undo step that also takes both away, and both stay after a save and reopen.
|
||
#[test]
|
||
fn a_grit_texture_adds_once_undoes_and_saves() {
|
||
let mut s = session();
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let ids: Vec<&str> =
|
||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
|
||
let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"}));
|
||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||
assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]);
|
||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||
let empty = counts(doc(&s));
|
||
let undo = undo_len(&s);
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
|
||
assert_eq!(r["added"], json!(["Stipple"]));
|
||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
|
||
let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone();
|
||
assert!(def.art.len() >= 144, "{} marks", def.art.len());
|
||
assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple"));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
|
||
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"])));
|
||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(counts(doc(&s)), empty);
|
||
assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none());
|
||
run(&mut s, "edit.redo", json!({}));
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||
assert_eq!(back.pattern("Stipple"), Some(&def));
|
||
assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint));
|
||
}
|
||
|
||
/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of
|
||
/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step
|
||
/// that also takes both away, and both stay after a save and reopen.
|
||
#[test]
|
||
fn an_optical_effect_adds_once_fills_undoes_and_saves() {
|
||
let mut s = session();
|
||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||
let empty = counts(doc(&s));
|
||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||
let ids: Vec<&str> =
|
||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
|
||
let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"}));
|
||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||
assert_eq!(
|
||
names,
|
||
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]
|
||
);
|
||
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
|
||
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
||
let undo = undo_len(&s);
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"}));
|
||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks")));
|
||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
|
||
let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone();
|
||
assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard");
|
||
assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks"));
|
||
let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks");
|
||
assert!(filled(doc(&s)));
|
||
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]}));
|
||
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"])));
|
||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(counts(doc(&s)), empty);
|
||
assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s)));
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert!(filled(doc(&s)));
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||
assert_eq!(back.pattern("Warped Checks"), Some(&def));
|
||
assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint));
|
||
assert!(filled(&back));
|
||
}
|
||
|
||
/// A device name is saved with a `-` after it, which can be another library's own name: neither
|
||
/// library replaces the other, and saving either again replaces only itself.
|
||
#[test]
|
||
fn a_library_named_like_a_device_keeps_the_library_with_its_file_name() {
|
||
for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹"), ("com¹", "COM¹-")] {
|
||
let dir = temp_dir("device-collision");
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let save =
|
||
|s: &mut Session, name: &str| run(s, "swatch.library.save", json!({"user": true, "name": name}))["library"].as_str().unwrap().to_string();
|
||
let a = save(&mut s, first);
|
||
let b = save(&mut s, second);
|
||
assert_ne!(a, b);
|
||
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
|
||
assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}");
|
||
assert_eq!(s.swatch_libraries.get(&a).unwrap().1.name, first);
|
||
assert_eq!(s.swatch_libraries.get(&b).unwrap().1.name, second);
|
||
// The name of the numbered file the second one got is a third library's own name.
|
||
let third = b.strip_prefix("user/").unwrap().strip_suffix(".vcswatches").unwrap().to_string();
|
||
let c = save(&mut s, &third);
|
||
assert!(c != a && c != b, "{third}: {c}");
|
||
assert_eq!(save(&mut s, &third), c, "saving {third} again replaces {third}");
|
||
assert_eq!(s.swatch_libraries.get(&b).unwrap().1.name, second);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|
||
|
||
/// `.ase` keeps no title, so a library's name comes back from its file name. Whichever is saved
|
||
/// first, `COM¹` and `COM¹-` keep a file each, and saving either again replaces only itself. After
|
||
/// a restart neither can be told from the other, so each gets a new file and replaces neither.
|
||
#[test]
|
||
fn device_named_libraries_without_a_title_keep_their_own_files() {
|
||
for (first, second) in [("COM¹", "COM¹-"), ("COM¹-", "COM¹")] {
|
||
let dir = temp_dir("device-ase");
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let save = |s: &mut Session, name: &str| {
|
||
run(s, "swatch.library.save", json!({"user": true, "name": name, "format": "ase"}))["library"].as_str().unwrap().to_string()
|
||
};
|
||
let a = save(&mut s, first);
|
||
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
|
||
let b = save(&mut s, second);
|
||
assert_ne!(a, b);
|
||
assert_eq!(save(&mut s, first), a, "saving {first} again replaces {first}");
|
||
assert_eq!(save(&mut s, second), b, "saving {second} again replaces {second}");
|
||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||
// The next time the app starts.
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
for (name, count) in [(first, 3), (second, 4)] {
|
||
let again = save(&mut s, name);
|
||
assert!(again != a && again != b, "{name}: {again}");
|
||
assert_eq!(save(&mut s, name), again, "saving {name} again replaces {name}");
|
||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), count);
|
||
}
|
||
// The numbered file is as unknown as the first.
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let numbered = b.strip_prefix("user/").unwrap().strip_suffix(".ase").unwrap().to_string();
|
||
assert_ne!(save(&mut s, &numbered), b);
|
||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5);
|
||
// Any other name goes on replacing its own file after a restart.
|
||
let other = save(&mut s, "Greys");
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
assert_eq!(save(&mut s, "Greys"), other);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|
||
|
||
/// A file this session saved for `COM¹`, then replaced, is not written over. The next save goes to
|
||
/// a new file and stays there. That holds for `.ase` and `.gpl`, for bytes that still read as a
|
||
/// library and for bytes that don't, including the file the library moved to. A file removed and
|
||
/// put back is still its library's when the bytes are the ones written, and is kept when they are
|
||
/// not. A name that is not a device name still replaces a file that cannot be read.
|
||
#[test]
|
||
fn a_replaced_device_library_file_is_kept() {
|
||
for format in ["ase", "gpl"] {
|
||
let other: Vec<u8> = if format == "ase" {
|
||
vectorcraft_testkit::ase::ase(&[vectorcraft_testkit::ase::Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)])
|
||
} else {
|
||
b"GIMP Palette\nName: Other\n0 0 0 Black\n".to_vec()
|
||
};
|
||
let save = |s: &mut Session| run(s, "swatch.library.save", json!({"user": true, "name": "COM¹", "format": format}));
|
||
let open = |name: &str| {
|
||
let dir = temp_dir(name);
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
(dir, s)
|
||
};
|
||
|
||
// Replaced with another library.
|
||
let (dir, mut s) = open(&format!("replaced-lib-{format}"));
|
||
let first = save(&mut s);
|
||
let first_id = first["library"].as_str().unwrap().to_string();
|
||
let first_path = first["path"].as_str().unwrap().to_string();
|
||
assert_ne!(std::fs::read(&first_path).unwrap(), other);
|
||
std::fs::write(&first_path, &other).unwrap();
|
||
let moved = save(&mut s);
|
||
let moved_id = moved["library"].as_str().unwrap().to_string();
|
||
assert_ne!(moved_id, first_id);
|
||
assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: the replaced file is left as it is");
|
||
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again replaces the new file");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
|
||
// Replaced with bytes that are not a library, first the file it had, then the one it moved to.
|
||
let (dir, mut s) = open(&format!("replaced-unread-{format}"));
|
||
let first = save(&mut s);
|
||
let first_path = first["path"].as_str().unwrap().to_string();
|
||
std::fs::write(&first_path, b"not a library").unwrap();
|
||
let moved = save(&mut s);
|
||
let moved_id = moved["library"].as_str().unwrap().to_string();
|
||
let first_id = first["library"].as_str().unwrap();
|
||
assert_ne!(moved_id, first_id);
|
||
assert_eq!(std::fs::read(&first_path).unwrap(), b"not a library");
|
||
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()), "{format}: saving again stays on the new file");
|
||
let moved_path = moved["path"].as_str().unwrap().to_string();
|
||
std::fs::write(&moved_path, b"still not").unwrap();
|
||
let again = save(&mut s);
|
||
let again_id = again["library"].as_str().unwrap().to_string();
|
||
assert!(again_id != moved_id && again_id != first_id);
|
||
assert_eq!(std::fs::read(&moved_path).unwrap(), b"still not");
|
||
assert_eq!(save(&mut s)["library"].as_str(), Some(again_id.as_str()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
|
||
// Removed, the folder listed again, then put back: the hash is checked when it reappears.
|
||
let (dir, mut s) = open(&format!("replaced-reappear-{format}"));
|
||
let first = save(&mut s);
|
||
let first_id = first["library"].as_str().unwrap().to_string();
|
||
let first_path = first["path"].as_str().unwrap().to_string();
|
||
let written = std::fs::read(&first_path).unwrap();
|
||
std::fs::remove_file(&first_path).unwrap();
|
||
run(&mut s, "swatch.library.list", json!({}));
|
||
std::fs::write(&first_path, &written).unwrap();
|
||
assert_eq!(save(&mut s)["library"].as_str(), Some(first_id.as_str()), "{format}: the same bytes are still this library");
|
||
std::fs::remove_file(&first_path).unwrap();
|
||
run(&mut s, "swatch.library.list", json!({}));
|
||
std::fs::write(&first_path, &other).unwrap();
|
||
let moved = save(&mut s);
|
||
let moved_id = moved["library"].as_str().unwrap().to_string();
|
||
assert_ne!(moved_id, first_id);
|
||
assert_eq!(std::fs::read(&first_path).unwrap(), other, "{format}: different bytes put back are kept");
|
||
assert_eq!(save(&mut s)["library"].as_str(), Some(moved_id.as_str()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
let dir = temp_dir("replaced-ordinary");
|
||
let mut s = session();
|
||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||
let first = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"}));
|
||
let path = first["path"].as_str().unwrap().to_string();
|
||
std::fs::write(&path, b"not a library").unwrap();
|
||
let again = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Greys", "format": "gpl"}));
|
||
assert_eq!(again["library"], first["library"], "a name that is not a device name replaces its own unreadable file");
|
||
assert!(std::fs::read(&path).unwrap().starts_with(b"GIMP Palette"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// #3602: a library titled with a Windows device name is saved under a name that is not one.
|
||
#[test]
|
||
fn a_library_file_name_is_never_a_windows_device_name() {
|
||
use crate::cmd::swatchlib::library_file_name;
|
||
for (title, ext) in [
|
||
("CON", "json"),
|
||
("nul", "json"),
|
||
("Com1", "acb"),
|
||
("LPT9", "ase"),
|
||
("aux.", "json"),
|
||
("CON.txt", "json"),
|
||
("nul.tar.gz", "acb"),
|
||
("COM¹", "json"),
|
||
("lpt³.x", "ase"),
|
||
] {
|
||
let file = library_file_name(title, ext);
|
||
assert!(!crate::file_access::is_device_name(&file), "{title}: {file}");
|
||
assert!(
|
||
file.ends_with(&format!(".{ext}"))
|
||
&& file.to_ascii_lowercase().starts_with(&title.split('.').next().unwrap_or_default().to_ascii_lowercase()),
|
||
"{title}: {file}"
|
||
);
|
||
}
|
||
assert_eq!(library_file_name("Console", "json"), "Console.json");
|
||
assert_eq!(library_file_name("COM10", "json"), "COM10.json");
|
||
}
|