use super::*;
fn lay_out(src: &str) -> LaidOut {
let tokens = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
layout(&program).expect("layout")
}
#[test]
fn empty_closed_primitive_is_two_paddings() {
let n = &lay_out("|box|\n").nodes[0];
assert!((n.bbox.w() - 42.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 42.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn explicit_dims_are_border_box() {
let n = &lay_out("|box| { width: 100; height: 50; }\n").nodes[0];
assert!((n.bbox.w() - 102.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 52.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn stroke_width_counts_toward_the_bbox() {
let n = &lay_out("|box| { width: 100; height: 50; stroke-width: 4; }\n").nodes[0];
assert!((n.bbox.w() - 104.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 54.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn label_auto_sizes_to_content_plus_padding() {
let n = &lay_out("|box| \"hi\"\n").nodes[0];
assert!(n.bbox.w() > 55.0 && n.bbox.w() < 65.0, "w={}", n.bbox.w());
}
#[test]
fn dims_are_independent_per_axis() {
let n = &lay_out("|box| \"hi\" { width: 200 }\n").nodes[0];
assert!((n.bbox.w() - 202.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 57.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn explicit_size_is_a_floor_not_a_clip() {
let grown = &lay_out("|box| \"a long label\" { width: 40 }\n").nodes[0];
assert!(
grown.bbox.w() > 60.0,
"floor grows to content: w={}",
grown.bbox.w()
);
let kept = &lay_out("|box| \"hi\" { width: 300 }\n").nodes[0];
assert!((kept.bbox.w() - 302.0).abs() < 0.01, "w={}", kept.bbox.w());
}
#[test]
fn asymmetric_padding_offsets_the_content() {
let off = &lay_out("|box| \"x\" { padding: 0 0 0 20 }\n").nodes[0];
assert!(
(off.children[0].cx - 10.0).abs() < 0.01,
"cx={}",
off.children[0].cx
);
let mid = &lay_out("|box| \"x\" { padding: 8 }\n").nodes[0];
assert!(
mid.children[0].cx.abs() < 0.01,
"centred: cx={}",
mid.children[0].cx
);
}
#[test]
fn oval_uses_width_height() {
let n = &lay_out("|oval| { width: 100; height: 50; }\n").nodes[0];
assert!((n.bbox.w() - 102.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 52.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn text_sizes_to_its_glyphs_without_padding() {
let n = &lay_out("\"hi\"\n").nodes[0];
assert!((n.bbox.w() - 18.0).abs() < 0.5, "w={}", n.bbox.w()); assert!((n.bbox.h() - 15.0).abs() < 0.5, "h={}", n.bbox.h());
}
#[test]
fn row_layout_stacks_horizontally() {
let l = lay_out(
"{ direction: row; gap: 10; }\n\
|box| { width: 100; height: 40; }\n\
|box| { width: 60; height: 40; }\n",
);
assert_eq!(l.nodes.len(), 2);
let dx = l.nodes[1].cx - l.nodes[0].cx;
assert!((dx - 92.0).abs() < 0.5, "dx={}", dx);
assert!((l.nodes[0].cy - l.nodes[1].cy).abs() < 0.01);
}
#[test]
fn column_layout_stacks_vertically() {
let l = lay_out(
"{ direction: column; gap: 20; }\n\
|box| { width: 100; height: 40; }\n\
|box| { width: 100; height: 60; }\n",
);
let dy = l.nodes[1].cy - l.nodes[0].cy;
assert!((dy - 72.0).abs() < 0.5, "dy={}", dy);
assert!((l.nodes[0].cx - l.nodes[1].cx).abs() < 0.01);
}
fn lay_out_err(src: &str) -> Error {
let tokens = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
match layout(&program) {
Ok(_) => panic!("expected a layout error"),
Err(e) => e,
}
}
#[test]
fn layout_row_and_column_are_removed() {
for dir in ["row", "column"] {
let err = lay_out_err(&format!("{{ layout: {dir}; }}\n|box|\n|box|\n"));
assert!(
err.message.contains(&format!("direction: {dir}")),
"msg={}",
err.message
);
}
}
#[test]
fn direction_radial_is_rejected_in_a_flow() {
let err = lay_out_err("{ direction: radial; }\n|box|\n|box|\n");
assert!(err.message.contains("chart"), "msg={}", err.message);
}
#[test]
fn viewbox_wraps_content_with_scene_padding() {
let l = lay_out("|box| { width: 100; height: 40; }\n");
assert!((l.viewbox.w - 142.0).abs() < 0.01, "w={}", l.viewbox.w);
assert!((l.viewbox.h - 82.0).abs() < 0.01, "h={}", l.viewbox.h);
}
#[test]
fn a_pages_only_scene_carries_its_physical_mm() {
let l = lay_out(
"|page| { sheet: a5 landscape } [ |drawing| { scale: 4 } [ |rect#r| { width: 10; height: 10 } ] ]\n",
);
let (w, h) = l.physical.expect("a pages-only scene prints true-scale");
assert!(
(w - 210.0).abs() < 0.5 && (h - 148.0).abs() < 0.5,
"{w}×{h} mm"
);
let d = lay_out("{ layout: drawing; density: 1 }\n|rect#r| { width: 10; height: 10 }\n");
assert_eq!(d.physical, None);
}
#[test]
fn caption_overlay_does_not_grow_the_group() {
let h = |src: &str| lay_out(src).nodes[0].bbox.h();
let plain = h("|group#g| [\n |box#a| { width: 80; height: 30; }\n]\n");
let capped = h("|group#g| [\n |caption| \"Cap\"\n |box#a| { width: 80; height: 30; }\n]\n");
assert!(
(capped - plain).abs() < 0.01,
"caption is an overlay, no extra height: plain={plain} capped={capped}"
);
}
#[test]
fn caption_sits_above_the_content() {
let l = lay_out("|group#g| [\n |caption| \"Cap\"\n |box#a| { width: 80; height: 30; }\n]\n");
let g = &l.nodes[0];
let cap = g
.children
.iter()
.find(|c| c.type_chain.iter().any(|t| t == "caption"))
.expect("caption child");
let a = g
.children
.iter()
.find(|c| c.id.as_deref() == Some("a"))
.expect("box child");
assert!(cap.cy < a.cy, "cap.cy={} a.cy={}", cap.cy, a.cy);
}
#[test]
fn justify_orders_children_start_center_end() {
let first_cx = |j: &str| {
let src = format!(
"|row#g| {{ width: 300; justify: {j} }} [\n |box#a| {{ width: 40; height: 20; }}\n |box#b| {{ width: 40; height: 20; }}\n]\n"
);
lay_out(&src).nodes[0].children[0].cx
};
let (start, center, end) = (first_cx("start"), first_cx("center"), first_cx("end"));
assert!(
start < center && center < end,
"start={start} center={center} end={end}"
);
}
#[test]
fn justify_evenly_spaces_children_equally() {
let l = lay_out(
"|row#g| { width: 300; justify: evenly } [\n |box#a| { width: 20; height: 20; }\n |box#b| { width: 20; height: 20; }\n |box#c| { width: 20; height: 20; }\n]\n",
);
let cx: Vec<f64> = l.nodes[0].children.iter().map(|c| c.cx).collect();
assert!(
((cx[1] - cx[0]) - (cx[2] - cx[1])).abs() < 0.01,
"centers {cx:?}"
);
}
#[test]
fn align_stretch_fills_the_cross_axis() {
let l = lay_out("|row#g| { height: 80; align: stretch } [\n |box#a| { width: 40; }\n]\n");
let a = &l.nodes[0].children[0];
assert!((a.bbox.h() - 80.0).abs() < 1.0, "a.h={}", a.bbox.h());
}
#[test]
fn no_slack_means_no_distribution() {
let span = |j: &str| {
let src = format!(
"|row#g| {{ justify: {j} }} [\n |box#a| {{ width: 40; height: 20; }}\n |box#b| {{ width: 40; height: 20; }}\n]\n"
);
let l = lay_out(&src);
l.nodes[0].children[1].cx - l.nodes[0].children[0].cx
};
assert!(
(span("start") - span("end")).abs() < 0.01,
"auto row: justify is a no-op"
);
}
#[test]
fn grid_fixed_columns_place_children_in_order() {
let l = lay_out(
"{ layout: grid; columns: 80, 80, 80; gap: 0; }\n\
|box#a| { width: 40; height: 40; }\n\
|box#b| { width: 40; height: 40; }\n\
|box#c| { width: 40; height: 40; }\n",
);
let cx: Vec<f64> = l.nodes.iter().map(|n| n.cx).collect();
assert!((cx[1] - cx[0] - 80.0).abs() < 0.5, "dx={}", cx[1] - cx[0]);
assert!((cx[2] - cx[1] - 80.0).abs() < 0.5);
assert!((l.nodes[0].cy - l.nodes[1].cy).abs() < 0.01);
}
#[test]
fn grid_repeat_makes_auto_columns_and_wraps() {
let l = lay_out(
"{ layout: grid; columns: repeat(2); }\n\
|box#a| { width: 30; height: 30; }\n\
|box#b| { width: 30; height: 30; }\n\
|box#c| { width: 30; height: 30; }\n",
);
assert!(l.nodes[2].cy > l.nodes[0].cy, "c below a");
}
#[test]
fn grid_cell_pins_placement() {
let l = lay_out(
"{ layout: grid; columns: repeat(3); }\n\
|box#a| { cell: 3 1; }\n\
|box#b|\n",
);
assert!(
l.nodes[0].cx > l.nodes[1].cx,
"a (col 3) right of b (col 1)"
);
}
#[test]
fn grid_cell_fills_its_track_under_stretch() {
let l = lay_out(
"{ layout: grid; columns: 120, 120; gap: 0; }\n\
|box#a| { justify: stretch; align: stretch; }\n\
|box#b|\n",
);
assert!(
(l.nodes[0].bbox.w() - 120.0).abs() < 1.0,
"a.w={}",
l.nodes[0].bbox.w()
);
}
#[test]
fn grid_rows_track_list_is_a_floor_implicit_rows_overflow() {
let l = lay_out(
"{ layout: grid; columns: 40, 40; rows: auto; }\n\
|box#a| { width: 30; height: 30; }\n\
|box#b| { width: 30; height: 30; }\n\
|box#c| { width: 30; height: 30; }\n\
|box#d| { width: 30; height: 30; }\n",
);
assert!(l.nodes[2].cy > l.nodes[0].cy, "c (row 2) below a (row 1)");
assert!(
(l.nodes[2].cy - l.nodes[3].cy).abs() < 0.01,
"c, d share row 2"
);
}
#[test]
fn grid_without_columns_is_an_error() {
let src = "{ layout: grid; }\n|box#a|\n|box#b|\n";
let tokens = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
assert!(layout(&program).is_err());
}
#[test]
fn table_fills_interior_gutters_no_frame() {
let l = lay_out("|table#t| { columns: 40, 40 } [\n \"a\" \"b\" \"c\" \"d\"\n]\n");
assert!(!l.nodes[0].gutters.is_empty(), "table has interior gutters");
assert!(
lay_out("|group#g| [ |box#x| ]\n").nodes[0]
.gutters
.is_empty()
);
}
#[test]
fn grid_gutters_stay_within_the_content_box() {
let l = lay_out(
"|table#t| { columns: 40, 40; gap: 20 } [\n \"a\"\n \"b\"\n \"c\"\n \"d\"\n]\n",
);
let t = &l.nodes[0];
let (hw, hh) = (t.bbox.w() / 2.0 + 0.01, t.bbox.h() / 2.0 + 0.01);
for (cx, cy, w, h) in &t.gutters {
assert!(cx.abs() + w / 2.0 <= hw, "gutter x {cx}±{} > {hw}", w / 2.0);
assert!(cy.abs() + h / 2.0 <= hh, "gutter y {cy}±{} > {hh}", h / 2.0);
}
}
#[test]
fn one_d_gutter_falls_between_flow_children() {
let l = lay_out(
"|row#g| { gap-fill: --stroke } [\n |box#a| { width: 30; height: 30; }\n |box#b| { width: 30; height: 30; }\n |box#c| { width: 30; height: 30; }\n]\n",
);
assert_eq!(
l.nodes[0].gutters.len(),
2,
"two gutters between three children"
);
}
#[test]
fn gap_fill_per_axis_selects_gutters() {
let rows_only = lay_out(
"|grid#g| { columns: 40, 40; gap: 4 0; gap-fill: --stroke } [\n \"a\" \"b\"\n \"c\" \"d\"\n]\n",
);
let (_, _, w, h) = rows_only.nodes[0].gutters[0];
assert_eq!(rows_only.nodes[0].gutters.len(), 1, "row gap → one gutter");
assert!(w > h, "horizontal gutter is wide: w={w} h={h}");
let cols_only = lay_out(
"|grid#g| { columns: 40, 40; gap: 0 4; gap-fill: --stroke } [\n \"a\" \"b\"\n \"c\" \"d\"\n]\n",
);
let (_, _, w2, h2) = cols_only.nodes[0].gutters[0];
assert_eq!(cols_only.nodes[0].gutters.len(), 1, "col gap → one gutter");
assert!(h2 > w2, "vertical gutter is tall: w={w2} h={h2}");
}
#[test]
fn scale_multiplies_the_shape_never_text_or_stroke() {
let plain = &lay_out("|box#a| \"hi\" { width: 100; height: 40 }\n").nodes[0];
let scaled = &lay_out("|box#a| \"hi\" { width: 100; height: 40; scale: 2 }\n").nodes[0];
assert!(
(scaled.bbox.w() - 202.0).abs() < 0.01,
"w={}",
scaled.bbox.w()
);
assert!(
(scaled.bbox.h() - 82.0).abs() < 0.01,
"h={}",
scaled.bbox.h()
);
assert!((scaled.children[0].bbox.w() - plain.children[0].bbox.w()).abs() < 0.01);
}
#[test]
fn scale_inherits_nearest_ancestor_wins() {
let l = lay_out(
"{ scale: 2 }\n|rect#a| { width: 50; height: 20 }\n|note#n| { width: 50; height: 20 }\n",
);
let a = &l.nodes[0];
assert!(
(a.bbox.w() - 102.0).abs() < 0.01,
"inherited: w={}",
a.bbox.w()
);
let n = &l.nodes[1];
assert!(
n.bbox.w() < 60.0,
"the note is sheet chrome: w={}",
n.bbox.w()
);
}
#[test]
fn translate_scales_by_the_parent() {
let nudge = |src: &str| {
let l = lay_out(src);
l.nodes[1].cx - l.nodes[0].cx
};
let plain = nudge(
"|rect#a| { width: 10; height: 10 }\n|rect#b| { width: 10; height: 10; translate: 5 0 }\n",
);
let scaled = nudge(
"{ scale: 3 }\n|rect#a| { width: 10; height: 10 }\n|rect#b| { width: 10; height: 10; translate: 5 0 }\n",
);
assert!((plain - 5.0).abs() < 0.01, "plain={plain}");
assert!((scaled - 15.0).abs() < 0.01, "scaled={scaled}");
}
#[test]
fn a_scaled_sketch_in_a_flow_doubles_its_geometry() {
let one =
&lay_out("|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close() }\n").nodes[0];
let two =
&lay_out("|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close(); scale: 2 }\n")
.nodes[0];
assert!((two.bbox.w() - one.bbox.w() - 40.0).abs() < 0.01);
assert!(
matches!(two.attrs.get("path"), Some(ResolvedValue::String(d)) if d.contains("80")),
"scaled path"
);
}
#[test]
fn scale_must_be_positive() {
let err = lay_out_err("|box#a| { scale: 0 }\n");
assert_eq!(err.message, "'scale' must be > 0");
}
#[test]
fn a_patterned_box_in_a_flow_unions_its_copies() {
let l = lay_out("|rect#a| { width: 20; height: 20; pattern: grid(3, 1, 30, 0) }\n");
let a = &l.nodes[0];
assert!((a.bbox.w() - 82.0).abs() < 0.01, "w={}", a.bbox.w());
assert_eq!(a.children.len(), 3, "three copies");
assert!(a.id.as_deref() == Some("a"), "the carrier keeps the id");
}
#[test]
fn a_filled_grid_cell_aligns_its_text_by_its_own_align() {
let text_cx = |a: &str| {
let src = format!(
"|grid#g| {{ columns: 200; align: stretch }} [\n |block#c| \"x\" {{ align: {a} }}\n]\n"
);
let l = lay_out(&src);
let text = &l.nodes[0].children[0].children[0];
assert_eq!(text.kind, NodeKind::Text);
text.cx
};
assert!(text_cx("start") < -50.0, "start: {}", text_cx("start"));
assert!(text_cx("end") > 50.0, "end: {}", text_cx("end"));
assert!(
text_cx("center").abs() < 5.0,
"center: {}",
text_cx("center")
);
}