//! REST import with prototypes and variables through the file commands, and layer export //! settings (rows, sizes, "Export N layers"). Fixtures are synthetic. use base64::Engine as _; use figcraft_doc::proto::{Action, Trigger}; use figcraft_doc::*; use serde_json::{Value, json}; use crate::Session; use crate::params::id_of; const THEME: &str = "VariableCollectionId:1:1"; const BRAND: &str = "VariableID:1:2"; fn variables() -> Value { json!({"status": 200, "error": false, "meta": { "variableCollections": {THEME: {"id": THEME, "name": "Theme", "defaultModeId": "1:0", "modes": [{"modeId": "1:0", "name": "Light"}, {"modeId": "1:1", "name": "Dark"}]}}, "variables": {BRAND: {"id": BRAND, "name": "brand", "variableCollectionId": THEME, "resolvedType": "COLOR", "valuesByMode": {"1:0": {"r": 1, "g": 0, "b": 0, "a": 1}, "1:1": {"r": 0, "g": 0, "b": 1, "a": 1}}, "scopes": ["ALL_SCOPES"]}} }}) } /// Two screens: a button on the first navigates to the second; its fill is bound to `brand`. fn file() -> Value { let at = |x: f64| json!([[1, 0, x], [0, 1, 0]]); let red = json!({"r": 1, "g": 0, "b": 0, "a": 1}); let action = json!({"type": "NODE", "destinationId": "1:3", "navigation": "NAVIGATE", "transition": {"type": "DISSOLVE", "duration": 200, "easing": {"type": "EASE_OUT"}}}); let button = json!({"id": "1:2", "name": "Button", "type": "RECTANGLE", "relativeTransform": at(10.0), "size": {"x": 40, "y": 20}, "fills": [{"type": "SOLID", "color": red, "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": BRAND}}}], "interactions": [{"trigger": {"type": "ON_CLICK"}, "actions": [action]}]}); let screens = json!([ {"id": "1:1", "name": "Home", "type": "FRAME", "relativeTransform": at(0.0), "size": {"x": 100, "y": 100}, "children": [button]}, {"id": "1:3", "name": "Details", "type": "FRAME", "relativeTransform": at(200.0), "size": {"x": 100, "y": 100}, "fills": [{"type": "SOLID", "color": red}]} ]); json!({"name": "Proto", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ {"id": "0:1", "name": "Screens", "type": "CANVAS", "backgroundColor": {"r": 1, "g": 1, "b": 1, "a": 1}, "flowStartingPoints": [{"nodeId": "1:1", "name": "Flow"}], "children": screens} ]}}) } fn named(s: &Session, name: &str) -> Node { s.doc().unwrap().nodes().find(|n| n.name == name).cloned().unwrap_or_else(|| panic!("no {name}")) } fn fill_hex(n: &Node) -> Option { n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()) } fn temp_dir(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("figcraft-{tag}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn open_reads_variables_from_param_or_next_to_the_file() { let dir = temp_dir("restvars"); let path = dir.join("proto.json"); std::fs::write(&path, serde_json::to_vec(&file()).unwrap()).unwrap(); let path = path.to_string_lossy().to_string(); // Passed as an object. let mut s = Session::new(); s.execute("file.open", &json!({"path": path, "variables": variables()})).unwrap(); let button = named(&s, "Button"); assert_eq!(button.bound.get("fill").map(String::as_str), Some(BRAND)); let details = named(&s, "Details").id; assert_eq!(button.interactions[0].actions, [Action::Navigate { dest: details, preserve_scroll: false }]); assert_eq!(button.interactions[0].trigger, Trigger::OnClick); // The binding is live: switching the mode recolours the button. s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap(); assert_eq!(fill_hex(&named(&s, "Button")).as_deref(), Some("0000FF")); // Found next to the file. let vars_path = dir.join("proto.variables.json"); std::fs::write(&vars_path, serde_json::to_vec(&variables()).unwrap()).unwrap(); let mut s = Session::new(); let r = s.execute("file.open", &json!({"path": path})).unwrap(); assert_eq!(s.doc().unwrap().variables.variables.len(), 1, "{r}"); assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("variable"))), "{r}"); // As a path or JSON text; anything else is refused. let mut s = Session::new(); s.execute("file.import", &json!({"path": path, "variables": vars_path.to_string_lossy()})).unwrap(); assert_eq!(s.doc().unwrap().variables.collections.len(), 1); let mut s = Session::new(); s.execute("file.import", &json!({"path": path, "variables": variables().to_string()})).unwrap(); assert_eq!(s.doc().unwrap().variables.variables.len(), 1); assert!(Session::new().execute("file.open", &json!({"path": path, "variables": {"x": 1}})).is_err()); assert!(Session::new().execute("file.open", &json!({"path": path, "variables": 5})).is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn import_appends_flows_interactions_and_variables() { let mut s = Session::new(); let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap()); s.execute("file.import", &json!({"data": data, "name": "proto.json", "variables": variables()})).unwrap(); // Twice: ids stay unique, variables aren't duplicated. s.execute("file.import", &json!({"data": data, "name": "proto.json", "variables": variables()})).unwrap(); let doc = s.doc().unwrap(); assert_eq!(doc.variables.variables.len(), 1); assert_eq!(doc.pages.len(), 3); for page in &doc.pages[1..] { let NodeKind::Page { flows, .. } = &doc.node(*page).unwrap().kind else { panic!() }; assert_eq!(flows.len(), 1); assert_eq!(doc.page_of(flows[0].start), Some(*page), "flows point into their own page"); let home = doc.node(flows[0].start).unwrap(); let button = doc.node(home.children[0]).unwrap(); let dest = button.interactions[0].actions[0].dest().unwrap(); assert_eq!(doc.page_of(dest), Some(*page)); assert_eq!(doc.node(dest).unwrap().name, "Details"); assert_eq!(button.bound.get("fill").map(String::as_str), Some(BRAND)); } } // ---- export settings ---- fn rect(s: &mut Session, name: &str, w: f64, h: f64) -> NodeId { let id = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": w, "height": h})).unwrap()["id"]).unwrap(); s.execute("node.set", &json!({"ids": [id], "props": {"name": name}})).unwrap(); id } fn exports(s: &Session, id: NodeId) -> Vec { s.doc().unwrap().node(id).unwrap().exports.clone() } /// Width of a PNG from its header. fn png_width(b: &[u8]) -> u32 { u32::from_be_bytes([b[16], b[17], b[18], b[19]]) } #[test] fn export_rows_add_step_up_and_update() { let mut s = Session::new(); let r = rect(&mut s, "Icon", 32.0, 16.0); for _ in 0..3 { s.execute("export.add", &json!({"ids": [r]})).unwrap(); } let rows = exports(&s, r); let sizes: Vec = rows.iter().map(|e| e.size.label()).collect(); assert_eq!(sizes, ["1x", "2x", "3x"]); assert_eq!(rows.iter().map(|e| e.suffix.as_str()).collect::>(), ["", "@2x", "@3x"]); s.execute("export.update", &json!({"ids": [r], "index": 1, "size": "512w", "format": "webp", "suffix": "-wide"})).unwrap(); s.execute("export.update", &json!({"ids": [r], "index": 2, "size": {"by": "height", "value": 64}, "format": "pdf"})).unwrap(); let rows = exports(&s, r); assert_eq!( rows[1], ExportSetting { size: ExportSize::Width { value: 512.0 }, suffix: "-wide".into(), format: ExportFormat::Webp, ..Default::default() } ); assert_eq!((rows[2].size, rows[2].format), (ExportSize::Height { value: 64.0 }, ExportFormat::Pdf)); for bad in [ json!({"size": "0x"}), json!({"size": "abc"}), json!({"size": "1e9w"}), json!({"format": "gif"}), json!({"size": {"by": "scale", "value": -1}}), ] { let mut p = bad.clone(); p["ids"] = json!([r]); p["index"] = json!(0); assert!(s.execute("export.update", &p).is_err(), "{bad}"); } assert_eq!(exports(&s, r), rows, "refused edits change nothing"); // A given setting is used as is (sizes as text too). s.execute("export.add", &json!({"ids": [r], "setting": {"size": "0.5x", "format": "jpg"}})).unwrap(); assert_eq!(exports(&s, r)[3].size, ExportSize::Scale { value: 0.5 }); assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"size": "huge"}})).is_err()); } #[test] fn export_layers_uses_each_layers_settings() { let mut s = Session::new(); let a = rect(&mut s, "Icon", 32.0, 16.0); let b = rect(&mut s, "Logo/Dark", 10.0, 10.0); let c = rect(&mut s, "Plain", 10.0, 10.0); s.execute("export.add", &json!({"ids": [a]})).unwrap(); s.execute("export.add", &json!({"ids": [a]})).unwrap(); s.execute("export.add", &json!({"ids": [a], "setting": {"size": "512w", "format": "png"}})).unwrap(); s.execute("export.add", &json!({"ids": [b], "setting": {"format": "svg"}})).unwrap(); let r = s.execute("file.exportLayers", &json!({"ids": [a, b, c]})).unwrap(); let files = r["files"].as_array().unwrap(); let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Icon.png", "Icon@2x.png", "Icon 2.png", "Logo/Dark.svg"], "unique names; a slash makes a folder"); assert_eq!(r["skipped"], 1); let data = |i: usize| base64::engine::general_purpose::STANDARD.decode(files[i]["data"].as_str().unwrap()).unwrap(); assert_eq!(png_width(&data(0)), 32); assert_eq!(png_width(&data(1)), 64); assert_eq!(png_width(&data(2)), 512, "512w scales to that width"); assert!(String::from_utf8_lossy(&data(3)).contains("