//! 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 super::*; 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"); } #[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); } #[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); } #[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())); }