mod helpers;
use helpers::{RenderFrameExt, make_block, make_typesetter};
use proptest::prelude::*;
use text_typeset::{CursorDisplay, TextFormat};
fn arb_text() -> impl Strategy<Value = String> {
proptest::string::string_regex(r"[a-zA-Z0-9 ]{1,60}").unwrap()
}
fn arb_word() -> impl Strategy<Value = String> {
proptest::string::string_regex(r"[a-zA-Z]{1,20}").unwrap()
}
proptest! {
#[test]
fn layout_paragraph_is_deterministic(
text in arb_text(),
width in 10.0f32..800.0,
) {
let mut ts1 = make_typesetter();
let mut ts2 = make_typesetter();
let r1 = ts1.layout_paragraph(&text, &TextFormat::default(), width, None);
let r2 = ts2.layout_paragraph(&text, &TextFormat::default(), width, None);
prop_assert_eq!(r1.line_count, r2.line_count);
prop_assert_eq!(r1.glyphs.len(), r2.glyphs.len());
prop_assert!((r1.width - r2.width).abs() < 0.01);
prop_assert!((r1.height - r2.height).abs() < 0.01);
}
}
proptest! {
#[test]
fn wider_layout_has_fewer_or_equal_lines(
text in arb_text(),
base in 40.0f32..400.0,
delta in 10.0f32..400.0,
) {
let mut ts = make_typesetter();
let narrow = ts.layout_paragraph(&text, &TextFormat::default(), base, None);
let wide = ts.layout_paragraph(&text, &TextFormat::default(), base + delta, None);
prop_assert!(
wide.line_count <= narrow.line_count,
"widening {} -> {} should not increase lines: {} -> {}",
base, base + delta, narrow.line_count, wide.line_count
);
}
}
proptest! {
#[test]
fn append_never_decreases_glyph_count(
base in arb_text(),
extra in arb_word(),
width in 50.0f32..800.0,
) {
let mut ts = make_typesetter();
let r1 = ts.layout_paragraph(&base, &TextFormat::default(), width, None);
let combined = format!("{} {}", base, extra);
let r2 = ts.layout_paragraph(&combined, &TextFormat::default(), width, None);
prop_assert!(
r2.glyphs.len() >= r1.glyphs.len(),
"append shrank glyph count: {} -> {}",
r1.glyphs.len(), r2.glyphs.len()
);
}
}
proptest! {
#[test]
fn character_geometry_length_matches_char_range(text in arb_text()) {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, &text)]);
let n = text.chars().count();
let geom = ts.character_geometry(1, 0, n);
prop_assert_eq!(
geom.len(), n,
"character_geometry must return exactly one entry per character"
);
}
}
#[test]
fn ligature_character_geometry_returns_one_entry_per_char() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "fi")]);
let geom = ts.character_geometry(1, 0, 2);
assert_eq!(
geom.len(),
2,
"ligature must not collapse character_geometry entries"
);
}
#[test]
fn ffi_ligature_character_geometry_returns_three_entries() {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, "ffi")]);
let geom = ts.character_geometry(1, 0, 3);
assert_eq!(geom.len(), 3, "ffi ligature must expose 3 character slots");
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
#[test]
fn hit_test_roundtrip_caret_is_near_hit_x(
text in "[a-zA-Z ]{3,30}",
x_frac in 0.0f32..1.0,
) {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, &text)]);
let layout_w = ts.layout_width();
let x = x_frac * layout_w;
let y = 8.0_f32; let hit = match ts.hit_test(x, y) {
Some(h) => h,
None => return Ok(()),
};
if !matches!(hit.region, text_typeset::HitRegion::Text) {
return Ok(());
}
let caret = ts.caret_rect(hit.position);
let caret_x = caret[0];
let diff = (caret_x - x).abs();
prop_assert!(
diff <= 32.0,
"caret_x {} far from hit x {} (diff {})",
caret_x, x, diff
);
}
}
proptest! {
#[test]
fn character_geometry_positions_are_monotonic(text in arb_text()) {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, &text)]);
let n = text.chars().count();
let geom = ts.character_geometry(1, 0, n);
for w in geom.windows(2) {
prop_assert!(
w[1].position >= w[0].position,
"position regressed: {} -> {}",
w[0].position, w[1].position
);
}
}
}
proptest! {
#[test]
fn collapsed_cursor_has_no_selection(
text in arb_text(),
pos in 0usize..200,
) {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, &text)]);
let max = text.chars().count();
let clamped = pos.min(max);
ts.set_cursor(&CursorDisplay {
position: clamped,
anchor: clamped,
affinity: text_typeset::CursorAffinity::Downstream,
visible: true,
selected_cells: vec![],
});
let frame = ts.render();
prop_assert_eq!(
frame.decoration_count(text_typeset::DecorationKind::Selection),
0,
"collapsed cursor produced selection rects"
);
}
}
proptest! {
#[test]
fn layout_paragraph_independent_of_viewport(
text in arb_text(),
width in 50.0f32..400.0,
vw1 in 100.0f32..1000.0,
vw2 in 100.0f32..1000.0,
) {
let mut ts = make_typesetter();
ts.set_viewport(vw1, 600.0);
let r1 = ts.layout_paragraph(&text, &TextFormat::default(), width, None);
ts.set_viewport(vw2, 600.0);
let r2 = ts.layout_paragraph(&text, &TextFormat::default(), width, None);
prop_assert_eq!(r1.line_count, r2.line_count);
prop_assert_eq!(r1.glyphs.len(), r2.glyphs.len());
}
}
proptest! {
#[test]
fn render_is_idempotent_without_mutation(text in arb_text()) {
let mut ts = make_typesetter();
ts.layout_blocks(vec![make_block(1, &text)]);
let (n1, d1) = {
let f = ts.render();
(f.glyph_count(), f.decorations.len())
};
let (n2, d2) = {
let f = ts.render();
(f.glyph_count(), f.decorations.len())
};
prop_assert_eq!(n1, n2, "glyph count changed between identical renders");
prop_assert_eq!(d1, d2, "decoration count changed between identical renders");
}
}
proptest! {
#[test]
fn content_width_set_then_read_round_trips(
explicit in 10.0f32..1200.0,
vw in 10.0f32..1200.0,
) {
let mut ts = make_typesetter();
ts.set_viewport(vw, 600.0);
ts.set_content_width(explicit);
prop_assert!(
(ts.layout_width() - explicit).abs() < 0.01,
"after set_content_width({}), layout_width() = {}",
explicit, ts.layout_width()
);
ts.set_content_width_auto();
let after_auto = ts.layout_width();
prop_assert!(after_auto > 0.0);
}
}