Files
figcraft/crates/engine/src/datafill_tests.rs
T
vetlucasmartins 196e3432dd data.fill and text.fit: fill copies of a frame from rows of data
- data.fill {id, rows, gap?, formats?, dir?, format?, scale?}: repeats a
  frame once per row in one undo step; each row key names layers in the
  copy (text, or {text?, fill?, visible?, fit?}). `formats` makes each row
  once per size (feed, story, square), the copy resized so its auto layout
  reflows. With `dir`, each copy is exported under a unique file name; a
  failed export is reported in `errors` and keeps the copies.
- text.fit {ids?, maxLines?, minSize?, orphans?}: the largest font size,
  down to a floor, at which fixed-width text stays within N lines and its
  box; avoids a one-word last line; `fits` comes from the final text.
- Plugins can't call data.fill: it runs other commands inside, past the
  plugin policy, like command.batch.
2026-10-21 19:54:47 +01:00

143 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `data.fill`: a frame repeated per row with that row's texts, fills and visibility, in one undo
//! step, optionally exported.
use crate::Session;
use crate::components::tests::{ex, idv};
use figcraft_doc::*;
use serde_json::json;
/// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame.
fn post(s: &mut Session) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"})));
ex(s, "text.create", json!({"text": "Title", "x": 80, "y": 80, "parent": f, "props": {"name": "Title"}}));
ex(s, "text.create", json!({"text": "R$ 0", "x": 80, "y": 1200, "parent": f, "props": {"name": "Price"}}));
ex(s, "shape.rectangle", json!({"x": 80, "y": 40, "width": 200, "height": 32, "parent": f, "props": {"name": "Badge", "fill": "#3DDC97"}}));
f
}
fn text(s: &Session, frame: NodeId, name: &str) -> String {
let doc = s.doc().unwrap();
doc.subtree(frame)
.into_iter()
.filter_map(|i| doc.node(i))
.find(|n| n.name == name)
.and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.characters.clone()),
_ => None,
})
.unwrap_or_default()
}
#[test]
fn each_row_fills_a_copy_in_one_undo_step() {
let mut s = Session::new();
let f = post(&mut s);
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "rows": [
{"name": "Slide 1", "Title": "Site em 7 dias", "Price": 5500},
{"name": "Slide 2", "Title": "WhatsApp com IA", "Badge": {"visible": false}},
{"Title": "Agenda sozinha", "Badge": {"fill": "#7C5CFF"}, "Subtitle": "nope"}
]}),
);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 3);
assert_eq!(r["missing"], json!(["Subtitle"]));
assert_eq!(text(&s, ids[0], "Title"), "Site em 7 dias");
assert_eq!(text(&s, ids[0], "Price"), "5500");
assert_eq!(text(&s, ids[2], "Title"), "Agenda sozinha");
assert_eq!(text(&s, f, "Title"), "Title", "the source frame is untouched");
let doc = s.doc().unwrap();
assert_eq!(doc.node(ids[0]).unwrap().name, "Slide 1");
assert_eq!(doc.node(ids[2]).unwrap().name, "Post 3");
// Copies sit in a row to the right, 100 apart.
let x = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4];
assert_eq!((x(ids[0]), x(ids[1])), (1180.0, 2360.0));
let badge = |i: NodeId| doc.subtree(i).into_iter().filter_map(|k| doc.node(k)).find(|n| n.name == "Badge").cloned().unwrap();
assert!(!badge(ids[1]).visible && badge(ids[0]).visible);
assert_eq!(badge(ids[2]).fills[0].solid_color().map(|c| c.to_hex()), Some("7C5CFF".into()));
// One undo removes every copy.
ex(&mut s, "edit.undo", json!({}));
assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i)));
}
#[test]
fn copies_export_to_a_folder_and_bad_input_is_refused() {
let mut s = Session::new();
let f = post(&mut s);
let dir = std::env::temp_dir().join(format!("figcraft-datafill-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "rows": [{"name": "Post/1: hero", "Title": "A"}, {"Title": "B"}], "dir": dir.to_string_lossy(), "scale": 0.25}),
);
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("Post-1- hero.png"), "{files:?}");
assert!(files.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0)));
let _ = std::fs::remove_dir_all(&dir);
for bad in [json!({"id": f}), json!({"id": f, "rows": [1, 2]}), json!({"id": f, "rows": [], "format": "gif"}), json!({"id": 999_999, "rows": []})]
{
assert!(s.execute("data.fill", &bad).is_err(), "{bad}");
}
}
#[test]
fn each_row_comes_in_every_format() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"sizingV": "fixed", "padding": 80}}));
let t = idv(&ex(&mut s, "text.create", json!({"text": "Título", "x": 0, "y": 0, "parent": f, "props": {"name": "Title", "fontSize": 96}})));
ex(&mut s, "node.set", json!({"id": t, "props": {"sizingH": "fill", "textAutoResize": "height"}}));
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "formats": [
{"name": "feed", "width": 1080, "height": 1350}, {"name": "story", "width": 1080, "height": 1920}, {"width": 1080, "height": 1080}
], "rows": [{"name": "capa", "Title": "Site em 7 dias"}, {"name": "prova", "Title": "Lighthouse 100"}]}),
);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 6);
let doc = s.doc().unwrap();
let names: Vec<String> = ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect();
assert_eq!(names, ["capa-feed", "capa-story", "capa-1080x1080", "prova-feed", "prova-story", "prova-1080x1080"]);
let size = |i: NodeId| (doc.node(i).unwrap().width, doc.node(i).unwrap().height);
assert_eq!(size(ids[1]), (1080.0, 1920.0));
assert_eq!(size(ids[2]), (1080.0, 1080.0));
assert_eq!(text(&s, ids[4], "Title"), "Lighthouse 100");
// Formats of a row stack downwards; rows go right without overlapping.
let at = |i: NodeId| (doc.node(i).unwrap().transform.as_coeffs()[4], doc.node(i).unwrap().transform.as_coeffs()[5]);
assert_eq!(at(ids[0]).0, at(ids[1]).0);
assert!(at(ids[1]).1 >= at(ids[0]).1 + 1350.0);
assert!(at(ids[3]).0 >= at(ids[0]).0 + 1080.0);
for bad in [json!([]), json!([{"width": 0, "height": 10}]), json!("feed")] {
assert!(s.execute("data.fill", &json!({"id": f, "rows": [{}], "formats": bad})).is_err(), "{bad}");
}
}
#[test]
fn review_export_names_are_unique_and_failures_are_reported() {
let mut s = Session::new();
let f = post(&mut s);
let dir = std::env::temp_dir().join(format!("figcraft-datafill-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "rows": [{"name": "Post"}, {"name": "post"}, {"name": "a/b"}, {"name": "a:b"}], "dir": dir.to_string_lossy(), "scale": 0.1}),
);
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
let lower: std::collections::HashSet<String> = files.iter().map(|f| f.to_lowercase()).collect();
assert_eq!(lower.len(), 4, "one file per copy: {files:?}");
assert!(files.iter().all(|p| std::fs::metadata(p).is_ok()), "{files:?}");
let _ = std::fs::remove_dir_all(&dir);
// A folder that can't be written: the copies stay and every failure is reported.
let r = ex(&mut s, "data.fill", json!({"id": f, "rows": [{"name": "x"}], "dir": "/nonexistent-figcraft-dir/out"}));
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1), "{r}");
assert!(s.doc().unwrap().contains(NodeId(r["ids"][0].as_u64().unwrap())));
}