Files
figcraft/crates/engine/src/tailwind_tests.rs
T

343 lines
20 KiB
Rust

//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
//! the Tailwind theme export.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::tailwind::{classes, merge};
use figcraft_doc::NodeId;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
let mut p = json!({"id": id, "language": "tailwind"});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
}
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
for _ in 0..2 {
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
}
(p, row)
}
fn brand(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Brand"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
}
#[test]
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
let mut s = Session::new();
brand(&mut s);
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("export function Landing() {"), "{out}");
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
assert!(out.contains(want), "{want} in {out}");
}
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
}
#[test]
fn per_side_strokes_are_borders_on_those_sides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}");
let tw = code(&mut s, f, json!({}));
assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}");
// Without per-side weights it is one border.
ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border: 2px solid"), "{css}");
}
#[test]
fn instances_are_inlined_with_their_overrides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
let doc = s.doc().unwrap();
let title = doc.node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
let out = code(&mut s, holder, json!({}));
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
}
#[test]
fn a_mobile_frame_makes_the_code_mobile_first() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
// A layer only on desktop is hidden on mobile.
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("mobile first"), "{out}");
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
}
#[test]
fn declarations_map_to_idiomatic_utilities() {
let got: Vec<String> = classes(
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
let want = [
"flex",
"justify-between",
"items-center",
"py-2",
"px-4",
"font-semibold",
"text-[18px]",
"leading-[1.2]",
"opacity-50",
"text-muted",
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
"[white-space:nowrap]",
];
assert_eq!(got, want);
}
#[test]
fn variables_export_with_units_and_as_a_tailwind_theme() {
let mut s = Session::new();
brand(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
}
}
#[test]
fn desktop_padding_on_other_sides_is_not_reset_away() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
}
#[test]
fn a_layer_name_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
let out = code(&mut s, f, json!({}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
}
#[test]
fn slashed_layer_names_and_phone_widths_merge_right() {
let mut s = Session::new();
let mut frames = Vec::new();
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
frames.push(p);
}
// A layer only the mobile frame has.
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
let button =
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
assert!(out.contains("w-full") && divider <= 2, "{out}");
}
#[test]
fn a_variable_named_after_its_namespace_is_its_base() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
}
#[test]
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
}
#[test]
fn page_code_leaves_resets_to_tailwind_preflight() {
let mut s = Session::new();
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let out = code(&mut s, p, json!({}));
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
assert!(!out.contains(leak), "{leak} in {out}");
}
}
#[test]
fn instances_connected_to_code_are_their_snippet_with_one_import() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
ex(
&mut s,
"codeConnect.set",
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
);
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
for text in ["Agendar diagnóstico", "Ver exemplos"] {
let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p})));
ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}}));
}
let out = code(&mut s, p, json!({}));
assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}");
assert!(out.contains("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
// Unconnected instances are still inlined.
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
let out = code(&mut s, p, json!({}));
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
}
#[test]
fn review_same_named_variables_keep_distinct_theme_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
}
#[test]
fn review_an_alias_target_takes_its_readers_role() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
}
#[test]
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
let got: Vec<String> = classes(
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
assert_eq!(
got,
[
"border-(length:--stroke)",
"border-[#000000]",
"border-b-(length:--hair)",
"border-border",
"[background:url(image.png)_center_/_cover]",
"font-(family-name:--brand-font)"
]
);
}
#[test]
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let root = out.lines().nth(3).unwrap_or_default();
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
}
#[test]
fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
}
#[test]
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
let got: Vec<String> =
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
}