mod common;
use common::*;
use zenith_core::default_provider;
use zenith_scene::ir::SceneCommand;
use zenith_scene::{compile, compile_page};
#[test]
fn dropcap_emits_oversized_initial_and_indents_first_lines() {
let runs = dropcap_runs(Some(3), DROPCAP_BODY);
assert!(runs.len() > 4, "body must wrap to several lines: {runs:?}");
let body_font_size = 32.0_f32;
let box_left = 180.0_f64;
let (cap_x, _cap_y, cap_size) = runs[0];
assert!(
cap_size > body_font_size * 2.0,
"drop-cap font_size ({cap_size}) must be far larger than body ({body_font_size}); \
expected ≈ 3×line_height"
);
assert!(
(cap_x - box_left).abs() < 0.01,
"drop cap must sit at the box left edge ({box_left}); got {cap_x}"
);
let body: Vec<(f64, f64)> = runs[1..].iter().map(|&(x, y, _)| (x, y)).collect();
let mut line_min_x: std::collections::BTreeMap<i64, f64> = std::collections::BTreeMap::new();
for &(x, y) in &body {
let key = (y * 1000.0) as i64;
let e = line_min_x.entry(key).or_insert(x);
if x < *e {
*e = x;
}
}
let line_starts: Vec<f64> = line_min_x.values().copied().collect();
assert!(
line_starts.len() >= 4,
"need at least 4 body lines; got {}: {line_starts:?}",
line_starts.len()
);
for (i, &sx) in line_starts.iter().take(3).enumerate() {
assert!(
sx > box_left + 1.0,
"body line {i} must be indented right of box left ({box_left}); got {sx}"
);
}
assert!(
(line_starts[3] - box_left).abs() < 1.0,
"body line 4 must return to box left ({box_left}); got {}",
line_starts[3]
);
}
#[test]
fn dropcap_absent_is_byte_identical() {
let dc_attr = "";
let _ = dc_attr;
let none_runs = dropcap_runs(None, DROPCAP_BODY);
let none_runs2 = dropcap_runs(None, DROPCAP_BODY);
assert_eq!(
none_runs, none_runs2,
"no-dropcap render must be deterministic"
);
for (_, _, fs) in &none_runs {
assert!(
(*fs - 32.0).abs() < 0.01,
"no-dropcap node must emit only body-sized (32px) runs; got {fs}"
);
}
}
#[test]
fn dropcap_empty_text_no_panic_no_cap() {
let runs = dropcap_runs(Some(3), "");
assert!(
runs.is_empty(),
"empty text with drop-cap-lines must emit no glyph runs; got {runs:?}"
);
}
#[test]
fn dropcap_two_run_byte_identical() {
let src = r##"zenith version=1 {
project id="proj.dcr" name="DCR"
tokens format="zenith-token-v1" {}
styles {}
document id="doc.dcr" title="DCR" {
page id="page.dcr" w=(px)1800 h=(px)2700 {
text id="text.dcr" x=(px)180 y=(px)600 w=(px)600 h=(px)1200 align="justify" font-size=(px)32 drop-cap-lines=3 {
span "The quick brown fox jumps over the lazy dog and then keeps on running across the meadow."
}
}
}
}
"##;
let doc = parse(src);
let r1 = compile(&doc, &default_provider());
let r2 = compile(&doc, &default_provider());
assert_eq!(
r1.scene.commands, r2.scene.commands,
"two drop-cap renders must be byte-identical"
);
}
#[test]
fn widow_orphan_pulls_orphan_line_to_next_box() {
let (off0, off1) = widow_orphan_line_counts(false);
let (on0, on1) = widow_orphan_line_counts(true);
assert!(
(on0, on1) != (off0, off1),
"widow-orphan=2 must move the break: off=({off0},{off1}) on=({on0},{on1})"
);
assert!(
on0 <= off0,
"box 1 must not gain lines under widow/orphan: off0={off0} on0={on0}"
);
assert!(
on1 >= off1,
"box 2 must not lose lines under widow/orphan: off1={off1} on1={on1}"
);
assert_eq!(
on0 + on1,
off0 + off1,
"total lines must be preserved when the boundary moves"
);
}
#[test]
fn widow_orphan_off_is_deterministic() {
let doc_src = || {
let p1 = "alpha bravo charlie delta echo foxtrot golf hotel india juliet";
let p2 = "victor whiskey xray yankee zulu aurora borealis cascade delta estuary";
format!(
r##"zenith version=1 {{
project id="proj.wod" name="WOD"
tokens format="zenith-token-v1" {{}}
styles {{}}
document id="doc.wod" title="WOD" {{
page id="page.a" w=(px)1200 h=(px)2000 {{
text id="body.1" x=(px)100 y=(px)100 w=(px)900 h=(px)360 chain="ch" font-size=(px)40 overflow="visible" {{
span "{p1}\n{p2}"
}}
}}
page id="page.b" w=(px)1200 h=(px)2000 {{
text id="body.2" x=(px)100 y=(px)100 w=(px)900 h=(px)900 chain="ch" font-size=(px)40 overflow="visible" {{
}}
}}
}}
}}
"##
)
};
let doc = parse(&doc_src());
let r1 = compile_page(&doc, &default_provider(), 0, None)
.scene
.commands;
let r2 = compile_page(&doc, &default_provider(), 0, None)
.scene
.commands;
assert_eq!(
r1, r2,
"widow/orphan-off chain render must be deterministic"
);
}
#[test]
fn baseline_grid_none_is_byte_identical() {
let doc = baseline_grid_doc("");
let r1 = compile(&doc, &default_provider());
let r2 = compile(&doc, &default_provider());
assert_eq!(
r1.scene.commands, r2.scene.commands,
"grid-absent render must be deterministic / unchanged"
);
assert!(
!r1.diagnostics
.iter()
.any(|d| d.code == "baseline-grid.snap_failed"),
"no snap diagnostic without a grid"
);
assert!(
glyph_run_ys(&r1.scene.commands).len() >= 2,
"test text must wrap into multiple lines"
);
}
#[test]
fn baseline_grid_snaps_first_baseline_to_grid() {
let g = 14.0;
let snapped = compile(
&baseline_grid_doc("baseline-grid=(px)14"),
&default_provider(),
);
let plain = compile(&baseline_grid_doc(""), &default_provider());
let snapped_ys = glyph_run_ys(&snapped.scene.commands);
let plain_ys = glyph_run_ys(&plain.scene.commands);
let first_snapped = *snapped_ys.first().expect("snapped node emits a run");
let first_plain = *plain_ys.first().expect("plain node emits a run");
let rem = first_snapped % g;
assert!(
rem.abs() < 1e-6 || (g - rem).abs() < 1e-6,
"first baseline {first_snapped} must be a multiple of {g}"
);
assert!(
first_snapped >= first_plain - 1e-9,
"snapped baseline moves DOWN (≥ natural): {first_snapped} vs {first_plain}"
);
assert!(
first_snapped - first_plain < g + 1e-9,
"snap moves down by less than one full grid cell"
);
}
#[test]
fn baseline_grid_uniform_advance_is_multiple_of_pitch() {
let g = 14.0;
let r = compile(
&baseline_grid_doc("baseline-grid=(px)14"),
&default_provider(),
);
let ys = distinct_line_ys(&r.scene.commands);
assert!(ys.len() >= 2, "need ≥2 wrapped lines to check advance");
let advance = ys[1] - ys[0];
assert!(advance > 0.0, "advance must be positive; got {advance}");
let mult = advance / g;
assert!(
(mult - mult.round()).abs() < 1e-6 && mult.round() >= 1.0,
"advance {advance} must be a positive integer multiple of {g}"
);
for w in ys.windows(2) {
let d = w[1] - w[0];
assert!(
(d - advance).abs() < 1e-6,
"all line advances equal; got {d} vs {advance}"
);
let rem = d % g;
assert!(
rem.abs() < 1e-6 || (g - rem).abs() < 1e-6,
"advance {d} must be a multiple of {g}"
);
}
}
#[test]
fn baseline_grid_snap_failed_when_line_height_exceeds_pitch() {
let tight = compile(
&baseline_grid_doc("baseline-grid=(px)14"),
&default_provider(),
);
let tight_diags: Vec<_> = tight
.diagnostics
.iter()
.filter(|d| d.code == "baseline-grid.snap_failed")
.collect();
assert_eq!(
tight_diags.len(),
1,
"exactly one snap_failed advisory per affected node; got {:?}",
tight.diagnostics
);
assert!(
tight_diags[0].message.contains("col1"),
"diagnostic names the node id"
);
let loose = compile(
&baseline_grid_doc("baseline-grid=(px)40"),
&default_provider(),
);
assert!(
!loose
.diagnostics
.iter()
.any(|d| d.code == "baseline-grid.snap_failed"),
"no snap_failed when line-height ≤ grid pitch"
);
}
#[test]
fn bullet_none_is_byte_identical() {
let no_bullet_src = r#"zenith version=1 {
project id="proj.bn" name="BN"
tokens format="zenith-token-v1" {}
styles {}
document id="doc.bn" title="BN" {
page id="page.bn" w=(px)1280 h=(px)720 {
text id="t.nb" x=(px)100 y=(px)100 w=(px)200 h=(px)300 overflow="clip" align="start" {
span "Revenue grew twelve percent year over year the strongest result since the restructuring."
}
}
}
}
"#;
let empty_bullet_src = no_bullet_src.replace(
r#"overflow="clip" align="start""#,
r#"overflow="clip" align="start" bullet="""#,
);
let a = compile(&parse(no_bullet_src), &default_provider());
let b = compile(&parse(&empty_bullet_src), &default_provider());
assert_eq!(
a.scene.commands, b.scene.commands,
"empty bullet string must emit identical command stream to no-bullet node"
);
}
#[test]
fn bullet_indents_all_text_lines_and_draws_marker() {
let src = r#"zenith version=1 {
project id="proj.bi" name="BI"
tokens format="zenith-token-v1" {}
styles {}
document id="doc.bi" title="BI" {
page id="page.bi" w=(px)1280 h=(px)720 {
text id="t.bi" x=(px)160 y=(px)200 w=(px)300 h=(px)400 overflow="clip" align="start" bullet="•" {
span "Revenue grew twelve percent year over year the strongest result since the restructuring."
}
}
}
}
"#;
let text_x = 160.0_f64;
let result = compile(&parse(src), &default_provider());
let glyph_runs: Vec<(f64, f64)> = result
.scene
.commands
.iter()
.filter_map(|c| {
if let SceneCommand::DrawGlyphRun { x, y, .. } = c {
Some((*x, *y))
} else {
None
}
})
.collect();
assert!(
!glyph_runs.is_empty(),
"expected at least one DrawGlyphRun, got none"
);
let (first_x, _) = glyph_runs[0];
assert!(
(first_x - text_x).abs() < 1.0,
"marker DrawGlyphRun must be at x ≈ text_x ({text_x}), got x={first_x}"
);
let body_runs: Vec<f64> = glyph_runs[1..].iter().map(|(x, _)| *x).collect();
assert!(
!body_runs.is_empty(),
"expected body glyph runs after the marker"
);
for &bx in &body_runs {
assert!(
bx > text_x + 0.5,
"body glyph run at x={bx} must be indented past text_x={text_x}"
);
}
let mut by_line: std::collections::BTreeMap<i64, Vec<f64>> = std::collections::BTreeMap::new();
for (x, y) in &glyph_runs[1..] {
let key = y.round() as i64;
by_line.entry(key).or_default().push(*x);
}
let lines: Vec<Vec<f64>> = by_line.into_values().collect();
assert!(
lines.len() >= 2,
"expected at least 2 wrapped body lines for the long sentence, got {}",
lines.len()
);
let line0_x = lines[0].iter().cloned().fold(f64::INFINITY, f64::min);
let line1_x = lines[1].iter().cloned().fold(f64::INFINITY, f64::min);
assert!(
(line0_x - line1_x).abs() < 1.5,
"continuation line x ({line1_x}) must align with first text line x ({line0_x})"
);
}
#[test]
fn single_line_bullet_draws_marker_and_indents() {
let src = r#"zenith version=1 {
project id="proj.sb" name="SB"
tokens format="zenith-token-v1" {}
styles {}
document id="doc.sb" title="SB" {
page id="page.sb" w=(px)1280 h=(px)720 {
text id="t.sb" x=(px)160 y=(px)200 w=(px)900 h=(px)80 overflow="clip" align="start" bullet="•" {
span "Short item"
}
}
}
}
"#;
let text_x = 160.0_f64;
let result = compile(&parse(src), &default_provider());
let glyph_runs: Vec<f64> = result
.scene
.commands
.iter()
.filter_map(|c| match c {
SceneCommand::DrawGlyphRun { x, .. } => Some(*x),
_ => None,
})
.collect();
assert!(
glyph_runs.len() >= 2,
"single-line bullet must emit a marker run plus body run(s); got {}",
glyph_runs.len()
);
assert!(
(glyph_runs[0] - text_x).abs() < 1.0,
"marker must be at x ≈ text_x ({text_x}), got {}",
glyph_runs[0]
);
for &bx in &glyph_runs[1..] {
assert!(
bx > text_x + 0.5,
"single-line bullet body run at x={bx} must be indented past text_x={text_x}"
);
}
}
#[test]
fn bullet_gap_widens_the_column() {
let base = r#"zenith version=1 {
project id="proj.bg" name="BG"
tokens format="zenith-token-v1" {}
styles {}
document id="doc.bg" title="BG" {
page id="page.bg" w=(px)1280 h=(px)720 {
text id="t.bg" x=(px)100 y=(px)100 w=(px)500 h=(px)400 overflow="clip" align="start" bullet="•" {
span "Revenue grew twelve percent year over year the strongest result since the restructuring."
}
}
}
}
"#;
let wide = base.replace(r#"bullet="•""#, r#"bullet="•" bullet-gap=(px)80"#);
let default_result = compile(&parse(base), &default_provider());
let wide_result = compile(&parse(&wide), &default_provider());
let first_body_x = |cmds: &[SceneCommand]| -> f64 {
let mut marker_seen = false;
for c in cmds {
if let SceneCommand::DrawGlyphRun { x, .. } = c {
if marker_seen {
return *x;
}
marker_seen = true;
}
}
0.0
};
let default_x = first_body_x(&default_result.scene.commands);
let wide_x = first_body_x(&wide_result.scene.commands);
assert!(
wide_x > default_x + 1.0,
"wide bullet-gap should push text column further right: default={default_x}, wide={wide_x}"
);
}
#[test]
fn bullet_marker_measured_independent_of_size() {
let make_src = |size: u32| {
format!(
r#"zenith version=1 {{
project id="proj.bm" name="BM"
tokens format="zenith-token-v1" {{}}
styles {{}}
document id="doc.bm" title="BM" {{
page id="page.bm" w=(px)1280 h=(px)720 {{
text id="t.bm" x=(px)100 y=(px)100 w=(px)500 h=(px)500 overflow="clip" align="start" font-size=(px){size} bullet="•" {{
span "Revenue grew twelve percent year over year the strongest result since the restructuring."
}}
}}
}}
}}
"#
)
};
let first_body_x = |cmds: &[SceneCommand]| -> f64 {
let mut marker_seen = false;
for c in cmds {
if let SceneCommand::DrawGlyphRun { x, .. } = c {
if marker_seen {
return *x;
}
marker_seen = true;
}
}
0.0
};
let text_x = 100.0_f64;
let r_small = compile(&parse(&make_src(14)), &default_provider());
let r_large = compile(&parse(&make_src(32)), &default_provider());
let x_small = first_body_x(&r_small.scene.commands);
let x_large = first_body_x(&r_large.scene.commands);
assert!(
x_large > x_small,
"larger font should produce a wider bullet M: small={x_small}, large={x_large}"
);
assert!(
x_small > text_x + 0.5,
"small-font body must be indented (got x_small={x_small})"
);
assert!(
x_large > text_x + 0.5,
"large-font body must be indented (got x_large={x_large})"
);
let check_continuation = |cmds: &[SceneCommand], label: &str| {
let body_runs: Vec<(f64, f64)> = cmds
.iter()
.skip_while(|c| !matches!(c, SceneCommand::DrawGlyphRun { .. }))
.skip(1) .filter_map(|c| {
if let SceneCommand::DrawGlyphRun { x, y, .. } = c {
Some((*x, *y))
} else {
None
}
})
.collect();
let mut by_line: std::collections::BTreeMap<i64, Vec<f64>> =
std::collections::BTreeMap::new();
for (x, y) in &body_runs {
by_line.entry(y.round() as i64).or_default().push(*x);
}
let lines: Vec<Vec<f64>> = by_line.into_values().collect();
if lines.len() >= 2 {
let l0 = lines[0].iter().cloned().fold(f64::INFINITY, f64::min);
let l1 = lines[1].iter().cloned().fold(f64::INFINITY, f64::min);
assert!(
(l0 - l1).abs() < 2.0,
"{label}: continuation x ({l1}) must align with first line x ({l0})"
);
}
};
check_continuation(&r_small.scene.commands, "small-font");
check_continuation(&r_large.scene.commands, "large-font");
}