use std::collections::BTreeMap;
mod common;
use common::*;
#[test]
fn stroke_width_with_dimension_token_is_clean() {
let doc = doc_with(
vec![dim_token("size.stroke")],
vec![minimal_page(
"page.one",
vec![Node::Rect(Box::new(RectNode {
shadow: None,
filter: None,
mask: None,
id: "rect.one".to_owned(),
name: None,
role: None,
x: Some(pxv(0.0)),
y: Some(pxv(0.0)),
w: Some(pxv(50.0)),
h: Some(pxv(50.0)),
radius: None,
radius_tl: None,
radius_tr: None,
radius_br: None,
radius_bl: None,
style: None,
fill: None,
stroke: None,
stroke_width: Some(token_ref("size.stroke")),
stroke_alignment: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
border_top: None,
border_bottom: None,
border_left: None,
border_right: None,
border_width: None,
stroke_outer: None,
stroke_outer_width: None,
opacity: None,
visible: None,
locked: None,
rotate: None,
blend_mode: None,
blur: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
source_span: None,
unknown_props: BTreeMap::new(),
}))],
)],
);
let report = validate(&doc);
assert!(
report.diagnostics.is_empty(),
"expected no diagnostics, got: {:?}",
codes(&report)
);
}
#[test]
fn text_font_family_with_font_family_token_is_clean() {
let doc = doc_with(
vec![font_family_token("font.body")],
vec![minimal_page(
"page.one",
vec![Node::Text(Box::new(TextNode {
shadow: None,
filter: None,
mask: None,
id: "text.one".to_owned(),
name: None,
role: None,
x: Some(pxv(0.0)),
y: Some(pxv(0.0)),
w: Some(pxv(200.0)),
h: Some(pxv(40.0)),
align: None,
v_align: None,
direction: None,
overflow: None,
overflow_wrap: None,
style: None,
fill: None,
stroke: None,
stroke_width: None,
contrast_bg: None,
font_family: Some(token_ref("font.body")),
font_size: None,
font_size_min: None,
font_weight: None,
opacity: None,
visible: None,
locked: None,
selectable: None,
rotate: None,
blend_mode: None,
blur: None,
chain: None,
drop_cap_lines: None,
hyphenate: None,
widow_orphan: None,
tab_leader: None,
text_exclusion: None,
padding_left: None,
text_indent: None,
content_format: None,
src: None,
bullet: None,
bullet_gap: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
spans: vec![],
block_styles: Vec::new(),
source_span: None,
unknown_props: BTreeMap::new(),
}))],
)],
);
let report = validate(&doc);
assert!(
report.diagnostics.is_empty(),
"expected no diagnostics, got: {:?}",
codes(&report)
);
}
#[test]
fn ellipse_clean_doc_no_errors() {
let doc = doc_with(
vec![color_token("color.fill")],
vec![minimal_page(
"page.one",
vec![minimal_ellipse(
"ellipse.one",
Some(token_ref("color.fill")),
)],
)],
);
let report = validate(&doc);
assert!(
report.diagnostics.is_empty(),
"expected no diagnostics for clean ellipse doc, got: {:?}",
codes(&report)
);
assert!(!report.has_errors());
}
#[test]
fn ellipse_missing_w_produces_node_missing_geometry() {
let doc = doc_with(
vec![],
vec![minimal_page(
"page.one",
vec![Node::Ellipse(EllipseNode {
shadow: None,
filter: None,
mask: None,
id: "ellipse.no-w".to_owned(),
name: None,
role: None,
x: Some(pxv(0.0)),
y: Some(pxv(0.0)),
w: None, h: Some(pxv(100.0)),
rx: None,
ry: None,
style: None,
fill: None,
stroke: None,
stroke_width: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
opacity: None,
visible: None,
locked: None,
rotate: None,
blend_mode: None,
blur: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
source_span: None,
unknown_props: BTreeMap::new(),
})],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "node.missing_geometry"),
"codes: {:?}",
codes(&report)
);
assert!(report.has_errors());
}
#[test]
fn ellipse_fill_raw_literal_produces_raw_visual_literal() {
let doc = doc_with(
vec![],
vec![minimal_page(
"page.one",
vec![minimal_ellipse(
"ellipse.one",
Some(PropertyValue::Literal("#ff0000".to_owned())),
)],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "token.raw_visual_literal"),
"codes: {:?}",
codes(&report)
);
assert!(report.has_errors());
}
#[test]
fn ellipse_stroke_raw_literal_produces_raw_visual_literal() {
let doc = doc_with(
vec![],
vec![minimal_page(
"page.one",
vec![Node::Ellipse(EllipseNode {
shadow: None,
filter: None,
mask: None,
id: "ellipse.stroke-lit".to_owned(),
name: None,
role: None,
x: Some(pxv(0.0)),
y: Some(pxv(0.0)),
w: Some(pxv(100.0)),
h: Some(pxv(100.0)),
rx: None,
ry: None,
style: None,
fill: None,
stroke: Some(PropertyValue::Literal("#ff0000".to_owned())),
stroke_width: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
opacity: None,
visible: None,
locked: None,
rotate: None,
blend_mode: None,
blur: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
source_span: None,
unknown_props: BTreeMap::new(),
})],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "token.raw_visual_literal"),
"ellipse with raw literal stroke must produce token.raw_visual_literal; codes: {:?}",
codes(&report)
);
assert!(report.has_errors());
}
fn minimal_line(id: &str, stroke: Option<PropertyValue>) -> Node {
Node::Line(LineNode {
id: id.to_owned(),
name: None,
role: None,
x1: Some(px(0.0)),
y1: Some(px(0.0)),
x2: Some(px(100.0)),
y2: Some(px(0.0)),
style: None,
stroke,
stroke_width: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
opacity: None,
visible: None,
locked: None,
source_span: None,
unknown_props: BTreeMap::new(),
})
}
#[test]
fn line_clean_doc_no_errors() {
let doc = doc_with(
vec![color_token("color.rule")],
vec![minimal_page(
"page.one",
vec![minimal_line("line.one", Some(token_ref("color.rule")))],
)],
);
let report = validate(&doc);
assert!(
report.diagnostics.is_empty(),
"expected no diagnostics for clean line doc, got: {:?}",
codes(&report)
);
assert!(!report.has_errors());
}
#[test]
fn line_missing_x1_produces_node_missing_geometry() {
let doc = doc_with(
vec![],
vec![minimal_page(
"page.one",
vec![Node::Line(LineNode {
id: "line.no-x1".to_owned(),
name: None,
role: None,
x1: None, y1: Some(px(0.0)),
x2: Some(px(100.0)),
y2: Some(px(0.0)),
style: None,
stroke: None,
stroke_width: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
opacity: None,
visible: None,
locked: None,
source_span: None,
unknown_props: BTreeMap::new(),
})],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "node.missing_geometry"),
"codes: {:?}",
codes(&report)
);
assert!(report.has_errors());
}
#[test]
fn line_stroke_raw_literal_produces_raw_visual_literal() {
let doc = doc_with(
vec![],
vec![minimal_page(
"page.one",
vec![minimal_line(
"line.one",
Some(PropertyValue::Literal("#000000".to_owned())),
)],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "token.raw_visual_literal"),
"codes: {:?}",
codes(&report)
);
assert!(report.has_errors());
}
fn minimal_ellipse(id: &str, fill: Option<PropertyValue>) -> Node {
Node::Ellipse(EllipseNode {
shadow: None,
filter: None,
mask: None,
id: id.to_owned(),
name: None,
role: None,
x: Some(pxv(0.0)),
y: Some(pxv(0.0)),
w: Some(pxv(100.0)),
h: Some(pxv(100.0)),
rx: None,
ry: None,
style: None,
fill,
stroke: None,
stroke_width: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
opacity: None,
visible: None,
locked: None,
rotate: None,
blend_mode: None,
blur: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
source_span: None,
unknown_props: BTreeMap::new(),
})
}
#[test]
fn rect_negative_x_is_off_canvas() {
let doc = doc_with(
vec![],
vec![bounded_page(
"page.one",
100.0,
100.0,
vec![rect_at("rect.out", -20.0, 0.0, 50.0, 50.0)],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "layout.off_canvas"),
"expected off_canvas advisory; codes: {:?}",
codes(&report)
);
let diag = report
.diagnostics
.iter()
.find(|d| d.code == "layout.off_canvas")
.expect("must exist");
assert_eq!(diag.severity, Severity::Advisory);
assert_eq!(diag.subject_id.as_deref(), Some("rect.out"));
assert!(!report.has_errors());
}
#[test]
fn rect_fully_inside_no_off_canvas() {
let doc = doc_with(
vec![],
vec![bounded_page(
"page.one",
100.0,
100.0,
vec![rect_at("rect.in", 10.0, 10.0, 80.0, 80.0)],
)],
);
let report = validate(&doc);
assert!(
!has_code(&report, "layout.off_canvas"),
"rect fully inside should NOT get off_canvas; codes: {:?}",
codes(&report)
);
}
#[test]
fn rect_overflowing_right_edge_is_off_canvas() {
let doc = doc_with(
vec![],
vec![bounded_page(
"page.one",
100.0,
100.0,
vec![rect_at("rect.wide", 80.0, 0.0, 40.0, 50.0)],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "layout.off_canvas"),
"rect extending past right edge should be off_canvas; codes: {:?}",
codes(&report)
);
let diag = report
.diagnostics
.iter()
.find(|d| d.code == "layout.off_canvas")
.expect("must exist");
assert_eq!(diag.severity, Severity::Advisory);
assert!(!report.has_errors());
}
#[test]
fn rect_exactly_on_page_edge_no_off_canvas() {
let doc = doc_with(
vec![],
vec![bounded_page(
"page.one",
100.0,
100.0,
vec![rect_at("rect.edge", 0.0, 0.0, 100.0, 100.0)],
)],
);
let report = validate(&doc);
assert!(
!has_code(&report, "layout.off_canvas"),
"rect exactly on page boundary should NOT be off_canvas; codes: {:?}",
codes(&report)
);
}
fn rect_at_rotated(id: &str, x: f64, y: f64, w: f64, h: f64, rotate_deg: Option<f64>) -> Node {
let rotate = rotate_deg.map(|deg| Dimension {
value: deg,
unit: Unit::Deg,
});
Node::Rect(Box::new(RectNode {
shadow: None,
filter: None,
mask: None,
id: id.to_owned(),
name: None,
role: None,
x: Some(pxv(x)),
y: Some(pxv(y)),
w: Some(pxv(w)),
h: Some(pxv(h)),
radius: None,
radius_tl: None,
radius_tr: None,
radius_br: None,
radius_bl: None,
style: None,
fill: None,
stroke: None,
stroke_width: None,
stroke_alignment: None,
stroke_dash: None,
stroke_gap: None,
stroke_linecap: None,
border_top: None,
border_bottom: None,
border_left: None,
border_right: None,
border_width: None,
stroke_outer: None,
stroke_outer_width: None,
opacity: None,
visible: None,
locked: None,
rotate,
blend_mode: None,
blur: None,
anchor: None,
anchor_zone: None,
anchor_sibling: None,
anchor_edge: None,
anchor_gap: None,
anchor_parent: None,
source_span: None,
unknown_props: BTreeMap::new(),
}))
}
#[test]
fn rotated_aabb_off_canvas_fires() {
let doc = doc_with(
vec![],
vec![bounded_page(
"page.rot",
200.0,
200.0,
vec![rect_at_rotated(
"rect.rot",
0.0,
0.0,
160.0,
20.0,
Some(45.0),
)],
)],
);
let report = validate(&doc);
assert!(
has_code(&report, "layout.off_canvas"),
"rotated rect whose AABB exits page should fire off_canvas; codes: {:?}",
codes(&report)
);
let diag = report
.diagnostics
.iter()
.find(|d| d.code == "layout.off_canvas")
.expect("must exist");
assert_eq!(diag.severity, Severity::Advisory);
assert_eq!(diag.subject_id.as_deref(), Some("rect.rot"));
}
#[test]
fn unrotated_inside_page_no_off_canvas() {
let doc = doc_with(
vec![],
vec![bounded_page(
"page.norot",
200.0,
200.0,
vec![rect_at_rotated("rect.norot", 0.0, 0.0, 160.0, 20.0, None)],
)],
);
let report = validate(&doc);
assert!(
!has_code(&report, "layout.off_canvas"),
"unrotated rect inside page should NOT fire off_canvas; codes: {:?}",
codes(&report)
);
}
#[test]
fn geometry_token_ref_parses_validates_and_round_trips() {
let src = r##"zenith version=1 {
project id="proj.geomtok" name="GeomTok"
tokens format="zenith-token-v1" {
token id="dim.h" type="dimension" value=(px)120
token id="color.bg" type="color" value="#102030"
}
styles {
}
document id="doc.geomtok" title="GeomTok" {
page id="page.one" w=(px)640 h=(px)480 {
rect id="r.one" x=(px)0 y=(px)0 w=(px)100 h=(token)"dim.h" fill=(token)"color.bg"
}
}
}
"##;
let adapter = KdlAdapter;
let doc = adapter.parse(src.as_bytes()).expect("parse must succeed");
let rect = match &doc.body.pages[0].children[0] {
Node::Rect(r) => r,
other => panic!("expected Rect node, got {other:?}"),
};
assert_eq!(
rect.h,
Some(PropertyValue::TokenRef("dim.h".to_owned())),
"h=(token)\"dim.h\" must parse to a geometry token ref"
);
assert_eq!(
rect.w,
Some(PropertyValue::Dimension(px(100.0))),
"raw px geometry must still parse to a dimension literal"
);
let report = validate(&doc);
assert!(
!has_code(&report, "node.missing_geometry"),
"token-ref geometry must count as PRESENT; codes: {:?}",
codes(&report)
);
assert!(
!has_code(&report, "token.unused"),
"a token used only as geometry must be registered as referenced; codes: {:?}",
codes(&report)
);
assert!(
!has_code(&report, "token.raw_visual_literal"),
"geometry is not a visual prop; raw px must not trip raw_visual_literal; codes: {:?}",
codes(&report)
);
assert!(
!report.has_errors(),
"the geometry-token-ref document must validate cleanly; codes: {:?}",
codes(&report)
);
let formatted = zenith_core::format::format_document(&doc).expect("format must succeed");
let formatted_str = String::from_utf8(formatted).expect("formatted must be utf8");
assert!(
formatted_str.contains("h=(token)\"dim.h\""),
"formatter must emit h as a token ref; got:\n{formatted_str}"
);
assert!(
formatted_str.contains("w=(px)100"),
"formatter must emit raw px geometry byte-identically; got:\n{formatted_str}"
);
let doc2 = adapter
.parse(formatted_str.as_bytes())
.expect("re-parse after format");
let rect2 = match &doc2.body.pages[0].children[0] {
Node::Rect(r) => r,
other => panic!("expected Rect on re-parse, got {other:?}"),
};
assert_eq!(
rect2.h,
Some(PropertyValue::TokenRef("dim.h".to_owned())),
"geometry token ref must survive a format -> re-parse round-trip"
);
assert_eq!(rect2.w, Some(PropertyValue::Dimension(px(100.0))));
}