//! 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 = 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::>(), 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::>(); 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 { 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::::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 { 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, s: &str) { let units: Vec = 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 { fn acb_str(out: &mut Vec, s: &str) { let units: Vec = 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 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)); } /// 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 = 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"); }