use lini::Options;
fn render_live(src: &str) -> String {
lini::compile_str(src).expect("compile")
}
fn render_baked(src: &str) -> String {
lini::compile_str_with(
src,
&Options {
bake_vars: true,
..Default::default()
},
)
.expect("compile")
}
fn render_themed(src: &str, theme_css: &str) -> String {
lini::compile_str_with(
src,
&Options {
theme_css: Some(theme_css.to_string()),
..Default::default()
},
)
.expect("compile")
}
#[test]
fn theme_font_stack_emits_verbatim() {
let svg = render_themed(
"|box| \"hi\"\n",
".lini { --lini-font-family: Inter, system-ui, sans-serif; }",
);
assert!(
svg.contains("--lini-font-family: Inter, system-ui, sans-serif;"),
"font stack should emit verbatim: {}",
svg
);
}
#[test]
fn theme_quoted_font_family_is_not_double_wrapped() {
let svg = render_themed(
"|box| \"hi\"\n",
".lini { --lini-font-family: \"Helvetica Neue\", sans-serif; }",
);
assert!(
svg.contains("--lini-font-family: \"Helvetica Neue\", sans-serif;"),
"quoted family must not be double-wrapped: {}",
svg
);
assert!(!svg.contains("\"\"Helvetica"), "double-wrapped: {}", svg);
}
#[test]
fn theme_font_inherit_stays_a_keyword() {
let svg = render_themed("|box| \"hi\"\n", ".lini { --lini-font-family: inherit; }");
assert!(svg.contains("--lini-font-family: inherit;"), "{}", svg);
assert!(
!svg.contains("\"inherit\""),
"inherit must be a keyword: {}",
svg
);
}
#[test]
fn live_mode_emits_var_refs_for_visual_attrs() {
let svg = render_live("|box| \"hi\"\n");
assert!(svg.contains("var(--lini-fill)"), "{}", svg);
assert!(svg.contains("var(--lini-stroke)"), "{}", svg);
assert!(
svg.contains("@layer lini.defaults"),
"default style block should be present in live mode"
);
}
#[test]
fn multiline_label_emits_one_tspan_per_line() {
let svg = render_baked("|box#n| \"one\\ntwo\"\n");
assert_eq!(
svg.matches("<tspan").count(),
2,
"expected two tspans: {}",
svg
);
assert!(
svg.contains(">one</tspan>") && svg.contains(">two</tspan>"),
"{}",
svg
);
}
#[test]
fn single_line_label_stays_a_bare_text() {
let svg = render_baked("|box#n| \"solo\"\n");
assert!(
!svg.contains("<tspan"),
"single line must not wrap in a tspan: {}",
svg
);
}
#[test]
fn letter_spacing_bakes_a_dx_list_never_css() {
let svg = render_baked("|box| \"abc\" { letter-spacing: 5 }\n");
assert!(svg.contains(r#"dx="0 5 5""#), "{}", svg);
assert!(
!svg.contains("letter-spacing"),
"no CSS letter-spacing: {}",
svg
);
}
#[test]
fn line_spacing_widens_the_tspan_leading_never_css() {
let svg = render_baked("|box| \"one\\ntwo\" { line-spacing: 10 }\n");
assert!(svg.contains(r#"dy="28""#), "{}", svg);
assert!(
!svg.contains("line-spacing"),
"no CSS line-spacing: {}",
svg
);
}
#[test]
fn font_style_emits_as_live_css_with_no_default() {
let svg = render_baked("|group#g| \"hi\" { font-style: italic }\n");
assert!(
svg.contains("font-style: italic"),
"emits where set: {}",
svg
);
assert!(
!render_baked("|box| \"x\"\n").contains("font-style"),
"no global font-style default"
);
}
fn lini_root_rule(svg: &str) -> String {
svg.lines()
.find(|l| l.trim_start().starts_with(".lini {"))
.expect(".lini root rule")
.to_string()
}
#[test]
fn global_font_style_states_on_the_lini_rule() {
let rule = lini_root_rule(&render_baked("{ font-style: italic }\n|box| \"hi\"\n"));
assert!(rule.contains("font-style: italic"), "{}", rule);
}
#[test]
fn text_transform_is_live_css_on_an_element_and_globally() {
let el = render_baked("|box| \"hi\" { text-transform: uppercase }\n");
assert!(el.contains("text-transform: uppercase"), "{}", el);
let rule = lini_root_rule(&render_baked(
"{ text-transform: capitalize }\n|box| \"hi\"\n",
));
assert!(rule.contains("text-transform: capitalize"), "{}", rule);
assert!(!render_baked("|box| \"x\"\n").contains("text-transform"));
}
#[test]
fn text_decoration_is_live_css_on_an_element_and_globally() {
let el = render_baked("|box| \"hi\" { text-decoration: underline }\n");
assert!(el.contains("text-decoration: underline"), "{}", el);
let rule = lini_root_rule(&render_baked(
"{ text-decoration: line-through }\n|box| \"hi\"\n",
));
assert!(rule.contains("text-decoration: line-through"), "{}", rule);
assert!(!render_baked("|box| \"x\"\n").contains("text-decoration"));
}
#[test]
fn text_shadow_compiles_unitless_lengths_to_px() {
let el = render_baked("|box| \"hi\" { text-shadow: 1 1 2 gray }\n");
assert!(el.contains("text-shadow: 1px 1px 2px gray"), "{}", el);
let rule = lini_root_rule(&render_baked("{ text-shadow: 2 2 black }\n|box| \"hi\"\n"));
assert!(rule.contains("text-shadow: 2px 2px black"), "{}", rule);
}
#[test]
fn global_font_family_weight_color_override_their_var() {
let set = lini_root_rule(&render_baked(
"{ font-weight: normal; color: navy; font-family: serif }\n|box| \"hi\"\n",
));
assert!(
set.contains("font-weight: normal")
&& set.contains("color: navy")
&& set.contains("font-family: serif"),
"{}",
set
);
let dflt = lini_root_rule(&render_live("|box| \"hi\"\n"));
assert!(
dflt.contains("color: var(--lini-text-color)")
&& dflt.contains("font-weight: var(--lini-font-weight)"),
"{}",
dflt
);
}
#[test]
fn colors_support_rgba_hsl_hsla_and_alpha_hex() {
for c in [
"rgba(1, 2, 3, 0.5)",
"hsl(200, 50%, 50%)",
"hsla(0, 70%, 50%, 0.5)",
"#0a8f",
] {
let svg = render_baked(&format!("|box#b| \"x\" {{ fill: {c} }}\n"));
assert!(svg.contains(&format!("fill: {c}")), "{c}: {svg}");
}
}
#[test]
fn line_missing_points_error_uses_pipe_sigil() {
let err = lini::compile_str("|line#x|\n").expect_err("line needs points");
assert!(
err.to_string().contains("'|line|' requires 'points'"),
"got: {}",
err
);
}
#[test]
fn define_paint_rides_its_shape_rule() {
let svg = render_baked("{\n |s::box| { stroke: blue; radius: 5; }\n}\n|s#n| \"n\"\n");
let rule = svg
.lines()
.find(|l| l.contains(".lini-s {"))
.expect("shape-s rule present");
assert!(
rule.contains("stroke: blue"),
"define paint on its rule: {}",
rule
);
assert!(
!rule.contains("radius"),
"geometry must not ride CSS: {}",
rule
);
}
#[test]
fn inherited_text_prop_reset_to_default_is_emitted() {
let svg =
render_baked("|group#crew| { font-size: 20 } [ |block#reset| \"x\" { font-size: 13 } ]\n");
let g_line = svg
.lines()
.find(|l| l.contains("data-id=\"reset\""))
.expect("reset node present");
assert!(
g_line.contains("font-size: 13px"),
"reset must emit its own font-size, not inherit 20px: {}",
g_line
);
}
#[test]
fn bake_mode_resolves_var_refs_to_literals() {
let svg = render_baked("|box| \"hi\"\n");
assert!(svg.contains("fill: white; stroke: #444;"), "{}", svg);
assert!(svg.contains("fill: currentColor"), "{}", svg);
assert!(svg.contains("color: black"), "{}", svg);
assert!(svg.contains(".lini .lini-box {"), "{}", svg);
assert!(
!svg.contains("@layer lini.defaults"),
"bake mode should omit the var defaults block"
);
assert!(!svg.contains("var("), "no var() survives baking: {}", svg);
}
#[test]
fn inline_override_baked_into_style_attr() {
let svg = render_baked("{\n --accent: #ff00aa;\n}\n|box#cat| \"Cat\" { fill: --accent }\n");
assert!(svg.contains(r#"style="fill: #ff00aa""#), "{}", svg);
}
#[test]
fn auto_classes_include_primitive_and_styles() {
let svg = render_live(
"{\n .bold { font-weight: bold; }\n .thin { stroke: #444; }\n}\n|box#cat| \"Cat\" .bold.thin\n",
);
assert!(svg.contains("lini-box"), "{}", svg);
assert!(svg.contains("lini-style-bold"), "{}", svg);
assert!(svg.contains("lini-style-thin"), "{}", svg);
}
#[test]
fn auto_classes_include_user_shape_chain() {
let svg = render_live("{\n |treat::box| { radius: 5; }\n}\n|treat#cat| \"Cat\"\n");
assert!(svg.contains("lini-treat"), "{}", svg);
assert!(svg.contains("lini-box"), "{}", svg);
assert!(svg.contains(r#"data-id="cat""#), "{}", svg);
}
#[test]
fn hex_emits_polygon() {
assert!(render_live("|hex| { width: 60; height: 60; }\n").contains("<polygon"));
}
#[test]
fn diamond_emits_polygon() {
assert!(render_live("|diamond| { width: 60; height: 60; }\n").contains("<polygon"));
}
#[test]
fn slant_emits_polygon_with_skew() {
assert!(render_live("|slant| { width: 80; height: 40; skew: 20; }\n").contains("<polygon"));
}
#[test]
fn oval_emits_ellipse() {
assert!(render_live("|oval| { width: 80; height: 40; }\n").contains("<ellipse"));
}
#[test]
fn cyl_emits_ellipse_and_path() {
let svg = render_live("|cyl| { width: 60; height: 80; }\n");
assert!(svg.contains("<ellipse"), "{}", svg);
assert!(svg.contains("<path"), "{}", svg);
}
#[test]
fn poly_emits_polygon_with_user_points() {
assert!(render_live("|poly| { points: 0 0, 20 0, 10 20; }\n").contains("<polygon"));
}
#[test]
fn hero_renders_in_both_modes() {
let src = std::fs::read_to_string("samples/hero.lini").expect("read");
let live = lini::compile_str(&src).expect("live compile");
let baked = lini::compile_str_with(
&src,
&Options {
bake_vars: true,
..Default::default()
},
)
.expect("baked compile");
assert!(live.contains("var(--lini-"));
assert!(!baked.contains("@layer lini.defaults"));
assert!(live.starts_with("<svg"));
assert!(baked.starts_with("<svg"));
assert!(live.contains("lini-link"));
assert!(baked.contains("lini-link"));
}
#[test]
fn stroke_style_renders_dasharray() {
let svg = render_live("|box| \"d\" { width: 80; height: 40; stroke-style: dashed }\n");
assert!(svg.contains("stroke-dasharray"), "{}", svg);
}
#[test]
fn font_size_on_container_reaches_descendant_text() {
let svg = render_live("|group#g| \"hi\" { font-size: 10 }\n");
assert!(svg.contains("font-size: 10px"), "{}", svg);
}
#[test]
fn css_cascade_emits_rules_and_diffs() {
let src = r#"{
link: #666; link-width: 1;
|box| { fill: lightyellow; }
.loud { stroke: red; stroke-width: 2; }
.calm { stroke: teal; }
.wire { link: teal; }
}
|box#flat| "Plain"
|box#loud| "Loud" .loud
|box#mix| "Mix" .calm { fill: lavender; }
|group#crew| { font-size: 10; font-family: serif; } [
|caption| "Crew"
|box#tiny| "Tiny"
]
flat -> loud
loud --> mix .wire
"#;
let svg = lini::compile_str(src).expect("compile");
assert!(
svg.contains(".lini .lini-style-loud { stroke: red; stroke-width: 2; }"),
"{}",
svg
);
assert!(
svg.contains(".lini .lini-box { fill: lightyellow;"),
"element rule merged into the shape rule: {}",
svg
);
assert!(
svg.contains(".lini .lini-link { fill: none; stroke: #666;"),
"{}",
svg
);
assert!(
svg.contains(r#"lini-style-loud" data-id="loud" transform"#),
"loud node must carry no style attr: {}",
svg
);
assert!(svg.contains(r#"style="fill: lavender""#), "{}", svg);
assert!(
svg.contains(r#"style="font-family: serif; font-size: 10px""#),
"{}",
svg
);
assert!(
svg.contains(".lini .lini-link-dashed { stroke-dasharray: 4,3; }"),
"the operator dash must be stated once as a class rule: {}",
svg
);
assert!(
svg.contains(".lini .lini-style-wire { stroke: teal; }"),
"a link class's `link:` maps to a stroke rule: {}",
svg
);
let link_g = svg
.lines()
.find(|l| l.contains(r#"data-from="loud""#))
.expect("loud→mix link present");
assert!(
link_g.contains(r#"class="lini-link lini-link-dashed lini-style-wire""#),
"link must carry its dash + style classes: {}",
link_g
);
assert!(
!link_g.contains("stroke-dasharray"),
"the dash rides the class, not inline: {}",
link_g
);
assert!(
!link_g.contains("stroke: teal"),
".wire colour must ride the class rule, not inline: {}",
link_g
);
}
#[cfg(feature = "icons")]
#[test]
fn icon_renders_phosphor_paths_counter_scaled() {
let svg = render_live("|icon#x| { symbol: heart }\n");
assert!(
svg.contains(r#"transform="scale(0.125) translate(-128 -128)""#),
"{svg}"
);
assert!(svg.contains(r#"stroke-width="16""#), "{svg}");
assert!(svg.contains(r#"stroke-linecap="round""#), "{svg}");
assert!(svg.contains(r#"<path d="M"#), "{svg}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_two_tone_paints_body_fill_and_line_stroke() {
let svg = render_live("|icon#x| { symbol: heart; fill: --teal-wash; stroke: --teal-ink }\n");
assert!(
svg.contains(r#"fill="var(--lini-teal-wash)" stroke="none""#),
"{svg}"
);
assert!(svg.contains(r#"stroke="var(--lini-teal-ink)""#), "{svg}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_single_tone_drops_the_body_group() {
let svg = render_live("|icon#x| { symbol: heart; fill: none }\n");
assert!(
svg.contains(r#"fill="none" stroke="var(--lini-stroke)""#),
"{svg}"
);
assert!(!svg.contains(r#"stroke="none">"#), "no body group: {svg}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_solid_role_keeps_a_foreground_dot() {
let svg = render_live("|icon#x| { symbol: atom }\n");
assert!(svg.contains(r#"<circle cx="128" cy="128" r="12""#), "{svg}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_label_is_lini_text_never_stroked() {
let svg = render_live("|icon#x| { symbol: bell } [ \"3\" ]\n");
assert!(
svg.contains(r#"<text class="lini-text" x="0" y="0""#),
"{svg}"
);
assert!(
svg.contains(".lini-text { fill: currentColor; stroke: none;"),
"{svg}"
);
}
#[cfg(feature = "icons")]
#[test]
fn sign_is_a_larger_icon() {
let svg = render_live("|sign#x| { symbol: cloud; fit: auto }\n");
assert!(
svg.contains(r#"class="lini-node lini-sign lini-icon""#),
"{svg}"
);
assert!(
svg.contains(r#"transform="scale(0.25) translate(-128 -128)""#),
"{svg}"
);
}
#[cfg(feature = "icons")]
#[test]
fn sign_defaults_to_fit_contain() {
let svg = render_live("|sign#x| { symbol: shield }\n");
assert!(svg.contains(r#"scale(0.3478)"#), "{svg}");
assert!(!svg.contains("scale(0.25)"), "{svg}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_label_inherits_font_size_no_inline() {
let svg = render_live("|icon#x| { symbol: bell; width: 96; height: 96 } [ \"3\" ]\n");
assert!(
svg.contains(r#"<text class="lini-text" x="0" y="0">3</text>"#),
"{svg}"
);
assert!(svg.contains("font-size: 15px"), "{svg}");
}
#[cfg(feature = "icons")]
#[test]
fn a_long_label_grows_the_icon_uniformly() {
let short = render_live("|icon#x| { symbol: cloud } [ \"hi\" ]\n");
let long = render_live("|icon#x| { symbol: cloud } [ \"Storage Service\" ]\n");
assert!(
short.contains(r#"transform="scale(0.125) translate(-128 -128)""#),
"a short label keeps 32px: {short}"
);
assert!(
!long.contains(r#"scale(0.125)"#),
"a long label grows the symbol past 32px: {long}"
);
}
#[cfg(feature = "icons")]
#[test]
fn icon_masks_an_inherited_dash() {
let svg = render_live("|icon#x| { symbol: heart }\n");
let rule = svg
.lines()
.find(|l| l.contains(".lini-icon {"))
.expect(".lini-icon rule");
assert!(rule.contains("stroke-dasharray: none"), "{rule}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_fit_auto_keeps_the_grid_framing() {
let svg = render_live("|sign#x| { symbol: shield; fit: auto }\n");
assert!(
svg.contains(r#"transform="scale(0.25) translate(-128 -128)""#),
"{svg}"
);
}
#[cfg(feature = "icons")]
#[test]
fn icon_fit_contain_fills_and_recentres_on_the_glyph() {
let svg = render_live("|sign#x| { symbol: shield; fit: contain }\n");
assert!(
svg.contains(r#"transform="scale(0.3478) translate(-128 -140)""#),
"{svg}"
);
assert!(!svg.contains("scale(0.25)"), "contain enlarges: {svg}");
}
#[cfg(feature = "icons")]
#[test]
fn icon_fit_cover_uses_the_larger_scale() {
let svg = render_live("|sign#x| { symbol: shield; fit: cover }\n");
assert!(
svg.contains(r#"transform="scale(0.3636) translate(-128 -140)""#),
"{svg}"
);
}
#[cfg(feature = "icons")]
#[test]
fn icon_fit_stretch_is_non_uniform() {
let svg = render_live("|sign#x| { symbol: shield; fit: stretch }\n");
assert!(
svg.contains(r#"transform="scale(0.3636 0.3478) translate(-128 -140)""#),
"{svg}"
);
}
#[cfg(feature = "icons")]
#[test]
fn icon_fit_holds_the_stroke_weight() {
let auto = render_live("|sign#x| { symbol: shield; fit: auto }\n");
let contain = render_live("|sign#x| { symbol: shield; fit: contain }\n");
assert!(auto.contains(r#"stroke-width="8""#), "{auto}");
assert!(contain.contains(r#"stroke-width="5.75""#), "{contain}");
}
#[cfg(feature = "icons")]
#[test]
fn bad_fit_value_errors() {
let err = lini::compile_str("|icon#x| { symbol: heart; fit: squish }\n")
.expect_err("invalid fit value");
assert!(err.message.contains("fit"), "{}", err.message);
}
#[test]
fn image_fit_cover_sets_slice() {
let svg = render_live("|image#x| { src: \"a.png\"; width: 80; height: 40; fit: cover }\n");
assert!(
svg.contains(r#"preserveAspectRatio="xMidYMid slice""#),
"{svg}"
);
}
#[test]
fn image_fit_stretch_sets_none() {
let svg = render_live("|image#x| { src: \"a.png\"; width: 80; height: 40; fit: stretch }\n");
assert!(svg.contains(r#"preserveAspectRatio="none""#), "{svg}");
}
#[test]
fn image_fit_auto_omits_preserve_aspect_ratio() {
let svg = render_live("|image#x| { src: \"a.png\"; width: 80; height: 40 }\n");
assert!(svg.contains("<image "), "{svg}");
assert!(!svg.contains("preserveAspectRatio"), "{svg}");
}
#[test]
fn link_label_translate_is_applied_once() {
let svg = render_baked(
"{ layout: row; gap: 120 }\n|box#a|\n|box#b|\na -> b [ \"L\" { translate: 0 -10 } ]\n",
);
let tag = svg
.lines()
.find(|l| l.contains(r#"<text class="lini-link-label""#))
.expect("a link label");
assert!(tag.contains(r#"y="-10""#), "translate once → y=-10: {tag}");
assert!(!tag.contains(r#"y="-20""#), "not doubled: {tag}");
}
#[test]
fn link_label_supports_multiline_and_letter_spacing() {
let svg = render_baked("|box#a|\n|box#b|\na -> b [ \"AB\\nCD\" { letter-spacing: 5 } ]\n");
let label = svg
.split(r#"<text class="lini-link-label""#)
.nth(1)
.and_then(|s| s.split("</text>").next())
.expect("a link label");
assert!(label.contains("<tspan"), "multi-line tspans: {label}");
assert!(
label.contains(r#"dx="0 5""#),
"baked letter-spacing: {label}"
);
}