Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions crates/byard-core/src/atlas/layout/retained_build_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,112 @@ fn text_content_is_part_of_the_layout_fingerprint() {
);
}

/// A family change moves the leaf's fingerprint; an unchanged one does not.
///
/// The negative half is the one that carries weight. A fingerprint that
/// invalidates on everything is trivially correct and useless: it would make
/// every text leaf re-measure on every frame, which is the cost the retained
/// path exists to remove. So this asserts both directions on the same leaf,
/// and the "nothing marked" half is the assertion that fails if production
/// stops taking the incremental path at all.
#[test]
fn a_family_change_marks_the_leaf_and_an_unchanged_one_does_not() {
let spec = |family: Option<&str>| TextLeaf {
content: "Handgloves".to_string(),
font_size: 14.0,
weight: 400,
family: family.map(std::sync::Arc::from),
width: None,
fallback: (10.0, 14.0),
};
let mut atlas = LayoutAtlas::new();
let t = atlas.add_text_leaf(spec(Some("Space Grotesk"))).unwrap();
atlas.set_root(t).unwrap();
atlas.compute(Viewport::new(200.0, 200.0)).unwrap();

// Same family, byte for byte: nothing to re-measure.
atlas.begin_retained_build();
let same = atlas.add_text_leaf(spec(Some("Space Grotesk"))).unwrap();
atlas.set_root(same).unwrap();
assert!(atlas.end_retained_build());
assert_eq!(
atlas.layout_dirty_targets().len(),
0,
"an unchanged family must not mark the leaf: the retained path is \
what keeps a steady scene from re-shaping every frame"
);

// A different family sets the same string to a different width, so the
// leaf has to be measured again.
atlas.begin_retained_build();
let moved = atlas.add_text_leaf(spec(Some("Manrope"))).unwrap();
atlas.set_root(moved).unwrap();
assert!(atlas.end_retained_build());
assert_eq!(
atlas.layout_dirty_targets().len(),
1,
"a changed family must mark its leaf for re-measurement"
);

// And dropping the family entirely is a change too, not a return to a
// neutral value that happens to hash the same as one of them.
atlas.begin_retained_build();
let dropped = atlas.add_text_leaf(spec(None)).unwrap();
atlas.set_root(dropped).unwrap();
assert!(atlas.end_retained_build());
assert_eq!(atlas.layout_dirty_targets().len(), 1, "family → none");
}

/// The family reaches the sizer, rather than being carried on the leaf and
/// dropped on the way to the measurement.
///
/// The exact shape of defect this project keeps paying for: everything is
/// plumbed, every bookkeeping assertion passes, and the value never arrives
/// where it does the work.
#[test]
fn the_family_on_a_leaf_reaches_the_sizer() {
#[derive(Default)]
struct Recording(Vec<Option<String>>);
impl crate::text::TextSizer for Recording {
fn measure(
&mut self,
_content: &str,
_font_size: f32,
_wrap: Option<f32>,
_weight: u16,
family: Option<&str>,
) -> (f32, f32) {
self.0.push(family.map(str::to_string));
(40.0, 14.0)
}
}

let mut atlas = LayoutAtlas::new();
let t = atlas
.add_text_leaf(TextLeaf {
content: "Handgloves".to_string(),
font_size: 14.0,
weight: 400,
family: Some(std::sync::Arc::from("Space Grotesk")),
width: None,
fallback: (10.0, 14.0),
})
.unwrap();
atlas.set_root(t).unwrap();
let mut sizer = Recording::default();
atlas
.compute_with_text(Viewport::new(200.0, 200.0), &mut sizer)
.unwrap();
assert!(
sizer
.0
.iter()
.any(|f| f.as_deref() == Some("Space Grotesk")),
"the sizer was asked to measure without the leaf's family: {:?}",
sizer.0
);
}

#[test]
fn recompute_dirty_with_text_reaches_the_sizer() {
// RFC-0032 §R5: the sizer-less `recompute_dirty` would size this leaf
Expand Down
41 changes: 41 additions & 0 deletions crates/byard-core/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3107,6 +3107,14 @@ mod paint_hash {
t.text.hash(&mut h);
f32s(&mut h, &[t.x, t.y, t.font_size]);
f32s(&mut h, &t.color);
// The face, both axes of it (RFC-0034). Both reach the shaper, so both
// decide this line's pixels, and a line judged clean is a line that
// keeps last frame's glyphs: a heading that turns bold renders
// correctly exactly once and then never again. Weight arrived here
// without this and family arrived the same way; the omission is the
// same one twice, which is why they are hashed together.
t.weight.hash(&mut h);
t.family.hash(&mut h);
// The wrap width, which is not a field of the line at all (RFC-0005
// default wrap keeps it in a parallel array). It breaks the lines, so
// two runs that differ only in it are two different pictures.
Expand Down Expand Up @@ -4479,6 +4487,39 @@ mod paint_digest_tests {
);
}

/// A line that changed only its weight or its family is a line that
/// changed (INV-26).
///
/// The digest decides whether a primitive is repainted by comparing its
/// own bytes at its own pool position. Weight reached the glyph run
/// without reaching this hash, and family arrived the same way; either
/// omission means a heading that turns bold, or a title that changes
/// typeface, is judged clean and keeps last frame's pixels. It renders
/// perfectly, once, and then never again.
#[test]
fn a_line_that_changed_only_its_weight_or_family_is_repainted() {
let heavier = TextLine {
weight: 700,
..line("a")
};
let mut d = PaintDigest::new();
let _ = digest_frame(&mut d, &[], &[line("a")]);
let f = digest_frame(&mut d, &[], std::slice::from_ref(&heavier));
assert!(f.texts()[0].dirty, "a weight change must repaint");

let other_face = TextLine {
family: Some(std::sync::Arc::from("Space Grotesk")),
..heavier
};
let f = digest_frame(&mut d, &[], std::slice::from_ref(&other_face));
assert!(f.texts()[0].dirty, "a family change must repaint");

// And the same line twice is still clean, so the two assertions above
// are not passing because everything is dirty.
let f = digest_frame(&mut d, &[], std::slice::from_ref(&other_face));
assert!(!f.texts()[0].dirty, "an unchanged line must stay clean");
}

#[test]
fn negative_zero_is_a_change() {
// `-0.0 == 0.0`, so a naive comparison reports this primitive clean,
Expand Down
12 changes: 12 additions & 0 deletions crates/byard-platform/tests/frame_budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,18 @@ fn a_steady_state_frame_stays_within_its_allocation_ceiling() {
cache that stopped hitting."
);
eprintln!("frame budget: {allocations} allocations (ceiling {MAX_ALLOCATIONS_PER_FRAME})");

// RFC-0034: the font table rides every frame, so it is on the per-frame
// path by construction. The reference scene declares no families, and a
// project that declares none must pay nothing at all: the ceiling above
// is what enforces that, and this says which table it was measuring, so a
// later change that starts building a fresh one per frame is read as the
// regression it is rather than as noise in the number.
assert!(
w.frame.fonts().is_empty(),
"the reference scene declares no fonts; the ceiling above was \
measured against a frame carrying some"
);
}

#[test]
Expand Down
Loading