epiphany/spikes/editor-toolkit/round2-textkit/src/fixtures.rs

547 lines
20 KiB
Rust

//! The five committed fixture strings (recipe §2) and the precommitted
//! measured expectations (recipe §4) the generator checks its own shaped
//! output against.
//!
//! Every string below is a Rust string literal with every non-ASCII
//! codepoint escaped (recipe §2: "so the file is unambiguous under any
//! editor or normalization"). **Never re-record an expectation to make a
//! check pass**: [`check_against_recipe`] returning an error is this crate's
//! signal to stop and report the disagreement, not to edit the literal it
//! failed against.
use epiphany_core::{EventId, TypedObjectId};
use epiphany_layout_ir::{BoundingBox as RealBoundingBox, Point as RealPoint, Provenance};
use ttf_parser::GlyphId;
use crate::faces::LoadedFace;
use crate::identity::build_shaping_identity;
use crate::invariants;
use crate::shape::shape_text;
use crate::types::{
SpikeBoundingBox, SpikeGlyphStyle, SpikePoint, SpikeResolvedText, SpikeTextAlign,
};
use crate::{EM_SIZE_STAFF_SPACE, RUN_ORIGIN_STAFF};
/// One committed fixture: its id, W3 §5 purpose, and verbatim literal.
pub struct FixtureDef {
pub id: &'static str,
pub purpose: &'static str,
pub text: &'static str,
}
/// The five fixtures, in the recipe §2 table's order. This exact set, in
/// this exact order, is what `output::FixtureFile::validate` restates as a
/// literal roster (mirroring `round1-candidates/harness`'s `ROUND1_ROSTER`
/// discipline).
pub const FIXTURES: &[FixtureDef] = &[
FixtureDef {
id: "F-A",
purpose: "check 1 (faithful consumption), check 5 (accessibility)",
text: "Allegro affettuoso \u{2014} al fine",
},
FixtureDef {
id: "F-B",
purpose: "check 2 (fallback, forced)",
text: "Coro \u{05D0}\u{05D1}\u{05D2}",
},
FixtureDef {
id: "F-C",
purpose: "check 2 (uncovered codepoint)",
text: "Coro \u{0627}",
},
// **Not "check 3 (bidi)".** Ruled 2026-07-29 (recipe §1.2): check 3
// requires an Arabic/Latin run, no Arabic-capable face exists on this
// machine, and pin 9 makes an absent required face an environmental
// `NOT RUN`. F-D is Hebrew/Latin and cannot exercise contextual Arabic
// joining, so it is scored on its own **Supplementary** row and must never
// upgrade check 3 to PASS. The purpose string says so because this string
// is what `fixtures.json`, `FIXTURES_SUMMARY.md` and every generator's
// console output print — labelling it "check 3" there recreates exactly
// the scoring ambiguity the ruling forbids, whatever the recipe says
// elsewhere.
FixtureDef {
id: "F-D",
purpose: "SUPPLEMENTARY bidi evidence (Hebrew/Latin) — check 3 remains NOT RUN \
(no Arabic-capable face; recipe §1.2)",
text: "Allegro \u{05D0}\u{05D1}\u{05D2} con brio",
},
FixtureDef {
id: "F-E",
purpose: "check 4 (hit testing / caret)",
text: "Cafe\u{301} \u{2014} resume\u{301}",
},
];
/// Builds one fixture's `SpikeResolvedText`: shapes it against the resolved
/// faces, computes `bounds`/`reserved_box`/`origin`, and asserts every W3 §5
/// invariant before returning.
pub fn build_fixture(
def: &FixtureDef,
faces: &[LoadedFace],
fixture_ordinal: u64,
) -> SpikeResolvedText {
let shaped = shape_text(def.text, faces);
// Recipe §3's nominal `(1.6, 0.0)` is not itself on the `1/1024` grid
// (see `crate::quantize`'s doc comment) — invariant 5 requires every
// position this crate records to be, `origin` included, so it is
// quantized like everything else rather than kept as the raw literal.
let origin = SpikePoint::new(
crate::quantize::quantize_component(RUN_ORIGIN_STAFF.0 as f64),
crate::quantize::quantize_component(RUN_ORIGIN_STAFF.1 as f64),
);
let bounds = compute_bounds(&shaped.segments, faces, origin);
// Reserved-box policy (§3E: "a solver policy over bounds — padding, a
// minimum allocation"): this spike has no real solver, so the policy is
// the simplest defensible one — bounds padded by a fixed 0.1 staff space
// on every side — recorded as a *named* policy, not an unshaped guess.
const PAD: f64 = 0.1;
let reserved_box = SpikeBoundingBox {
left: crate::quantize::quantize_component(bounds.left - PAD),
bottom: crate::quantize::quantize_component(bounds.bottom - PAD),
right: crate::quantize::quantize_component(bounds.right + PAD),
top: crate::quantize::quantize_component(bounds.top + PAD),
};
let identity = build_shaping_identity(faces.iter().map(|f| f.identity.clone()).collect());
let source = TypedObjectId::Event(EventId::from_raw(0xF00D_0000 + fixture_ordinal as u128));
let real_provenance = Provenance::projected(source, Vec::new());
let rt = SpikeResolvedText {
provenance: (&real_provenance).into(),
text: def.text.to_string(),
shaping: identity,
segments: shaped.segments,
clusters: shaped.clusters,
bounds,
reserved_box,
origin,
align: SpikeTextAlign::Start,
style: SpikeGlyphStyle { rgba: 0x0000_00ff },
layer: 0,
};
let expect_unresolved = def.id == "F-C";
invariants::assert_all(def.id, &rt, expect_unresolved);
if def.id == "F-D" {
invariants::assert_direction_boundary_stops_differ("F-D", &rt, 8)
.expect("F-D byte 8 direction-boundary stops");
invariants::assert_direction_boundary_stops_differ("F-D", &rt, 14)
.expect("F-D byte 14 direction-boundary stops");
}
check_against_recipe(def.id, &rt).unwrap_or_else(|e| {
panic!(
"{}: measured shaping disagrees with recipe §4's precommitted expectation — STOPPING \
per the packet's rule against silent re-recording:\n{e}",
def.id
)
});
rt
}
/// Real-type ink bounding box over every positioned glyph across every
/// segment, computed from each glyph's *own resolving face*'s outline
/// bounds (`ttf_parser::Face::glyph_bounding_box`) — never estimated from
/// advances. A glyph with no outline (a space) contributes no extent but
/// still occupies pen advance, exactly as `PositionedGlyph::offset` already
/// records.
fn compute_bounds(
segments: &[crate::types::SpikeShapedSegment],
faces: &[LoadedFace],
origin: SpikePoint,
) -> SpikeBoundingBox {
let mut left = f64::INFINITY;
let mut bottom = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
let mut top = f64::NEG_INFINITY;
for seg in segments {
let Some(face_idx) = seg.face else { continue };
let loaded = &faces[face_idx as usize];
let rb_face = loaded.face();
let upem = rb_face.units_per_em() as f64;
let scale = EM_SIZE_STAFF_SPACE / upem;
for g in &seg.glyphs {
let Some(bbox) = rb_face.glyph_bounding_box(GlyphId(g.glyph_id as u16)) else {
continue;
};
let gx = origin.x + g.offset.x;
let gy = origin.y + g.offset.y;
left = left.min(gx + bbox.x_min as f64 * scale);
right = right.max(gx + bbox.x_max as f64 * scale);
bottom = bottom.min(gy + bbox.y_min as f64 * scale);
top = top.max(gy + bbox.y_max as f64 * scale);
}
}
if !left.is_finite() {
// No glyph produced ink (a degenerate all-unresolved fixture) — an
// empty box at the origin rather than an infinite one, matching
// `epiphany_layout_ir::BoundingBox::default()`'s zero convention.
let real_default: RealBoundingBox = RealBoundingBox::default();
return SpikeBoundingBox::from(real_default);
}
// Round-trip through the real `BoundingBox`/`Point` types (they carry no
// extra invariant beyond `f32` storage) so this function is honestly
// computing the *real* type's value, not a shape only this crate defines.
let _real_point_smoke_test = RealPoint::new(left as f32, bottom as f32);
SpikeBoundingBox {
left: crate::quantize::quantize_component(left),
bottom: crate::quantize::quantize_component(bottom),
right: crate::quantize::quantize_component(right),
top: crate::quantize::quantize_component(top),
}
}
/// Checks the shaped result against recipe §4's precommitted, measured
/// expectations. **A mismatch is stopped and reported, never silently
/// re-recorded** — this function's only job is to say which fact disagreed.
fn check_against_recipe(id: &str, rt: &SpikeResolvedText) -> Result<(), String> {
match id {
"F-A" => check_f_a(rt),
"F-B" => check_f_b(rt),
"F-C" => check_f_c(rt),
"F-D" => check_f_d(rt),
"F-E" => check_f_e(rt),
other => Err(format!(
"no recipe §4 check registered for fixture {other:?}"
)),
}
}
fn total_glyphs(rt: &SpikeResolvedText) -> usize {
rt.segments.iter().map(|s| s.glyphs.len()).sum()
}
/// F-A: 28 codepoints, 30 bytes, 26 glyphs, all face 0. `ff` ligature at
/// byte 9 -> gid 234; `fi` ligature at byte 26 -> gid 97; em dash -> gid 119.
fn check_f_a(rt: &SpikeResolvedText) -> Result<(), String> {
expect_eq("codepoints", rt.text.chars().count(), 28)?;
expect_eq("bytes", rt.text.len(), 30)?;
expect_eq("glyphs", total_glyphs(rt), 26)?;
// Recipe §4 does not literally write "1 segment" for F-A — it says "all
// face 0", from which a single segment follows (unidirectional Latin
// text, one face throughout, nothing to split on). This is a derived
// check, not a quoted number; `output::FixtureFile::validate` does not
// repeat it as a "recipe §4 literal" for exactly that reason.
expect_eq("segments", rt.segments.len(), 1)?;
if rt.segments[0].face != Some(0) {
return Err(format!(
"F-A segment 0 resolved to face {:?}, recipe says face 0",
rt.segments[0].face
));
}
expect_cluster_glyph("F-A ff ligature", rt, 9, 11, 234)?;
expect_cluster_glyph("F-A fi ligature", rt, 26, 28, 97)?;
// The em dash: U+2014 sits at byte offset 19 (after "Allegro affettuoso "
// — "Allegro " is 8 bytes, "affettuoso " is 11 bytes, 8+11=19).
expect_cluster_glyph("F-A em dash", rt, 19, 22, 119)?;
Ok(())
}
/// F-B: Latin head "Coro " (5 bytes) -> 5 glyphs on face 0; Hebrew tail
/// (6 bytes) -> 3 glyphs on face 1, RTL, clusters descending 4/2/0 (segment-
/// relative). Two segments, two faces.
fn check_f_b(rt: &SpikeResolvedText) -> Result<(), String> {
expect_eq("segments", rt.segments.len(), 2)?;
let head = &rt.segments[0];
let tail = &rt.segments[1];
expect_eq(
"F-B head bytes",
(head.source.end - head.source.start) as usize,
5,
)?;
expect_eq("F-B head glyphs", head.glyphs.len(), 5)?;
if head.face != Some(0) {
return Err(format!(
"F-B head resolved to face {:?}, recipe says face 0",
head.face
));
}
expect_eq(
"F-B tail bytes",
(tail.source.end - tail.source.start) as usize,
6,
)?;
expect_eq("F-B tail glyphs", tail.glyphs.len(), 3)?;
if tail.face != Some(1) {
return Err(format!(
"F-B tail resolved to face {:?}, recipe says face 1",
tail.face
));
}
if tail.direction != crate::types::SpikeTextDirection::Rtl {
return Err("F-B tail must be Rtl".to_string());
}
// Clusters covering the tail must exist at absolute bytes 5,7,9 (segment-
// relative 0,2,4), each a single grapheme/glyph, descending in the
// shaped glyph *array* order (checked via clusters' recorded glyph
// index order matching descending source offsets is implicit in the
// shaping; here we assert the three source spans exist).
for abs in [5u32, 7, 9] {
let found = rt
.clusters
.clusters
.iter()
.any(|c| c.source.start == abs && c.segment == 1);
if !found {
return Err(format!(
"F-B: no cluster starts at absolute byte {abs} in the Hebrew segment"
));
}
}
Ok(())
}
/// F-C: U+0627 resolves in neither face — an explicit unresolved cluster.
fn check_f_c(rt: &SpikeResolvedText) -> Result<(), String> {
let unresolved_count = rt.clusters.clusters.iter().filter(|c| !c.resolved).count();
if unresolved_count != 1 {
return Err(format!(
"F-C: expected exactly 1 unresolved cluster, found {unresolved_count}"
));
}
Ok(())
}
/// F-D: base level 0; visual runs `0..8` (Latn, face 0), `8..14` (Hebr,
/// face 1), `14..23` (Latn, face 0). Three segments.
fn check_f_d(rt: &SpikeResolvedText) -> Result<(), String> {
expect_eq("segments", rt.segments.len(), 3)?;
let expected = [
(0u32, 8u32, Some(0u32)),
(8, 14, Some(1)),
(14, 23, Some(0)),
];
for (i, (s, e, face)) in expected.into_iter().enumerate() {
let seg = &rt.segments[i];
if seg.source.start != s || seg.source.end != e {
return Err(format!(
"F-D segment[{i}] source {:?}, recipe says {s}..{e}",
seg.source
));
}
if seg.face != face {
return Err(format!(
"F-D segment[{i}] face {:?}, recipe says {face:?}",
seg.face
));
}
}
if rt.segments[1].direction != crate::types::SpikeTextDirection::Rtl {
return Err("F-D middle segment must be Rtl".to_string());
}
Ok(())
}
/// F-E: 15 codepoints, 19 bytes, 13 glyphs; `e`+U+0301 composes to gid 198
/// at byte 3 and again at byte 16; 13 graphemes.
fn check_f_e(rt: &SpikeResolvedText) -> Result<(), String> {
expect_eq("codepoints", rt.text.chars().count(), 15)?;
expect_eq("bytes", rt.text.len(), 19)?;
expect_eq("glyphs", total_glyphs(rt), 13)?;
let grapheme_count: u32 = rt.clusters.clusters.iter().map(|c| c.grapheme_count).sum();
expect_eq("graphemes", grapheme_count as usize, 13)?;
// e+U+0301 is 3 bytes (1 + 2); the composed cluster covering it should
// span exactly 3 bytes and carry 1 glyph (gid 198), at byte 3 and byte 16.
expect_composed_e_acute(rt, 3)?;
expect_composed_e_acute(rt, 16)?;
Ok(())
}
fn expect_composed_e_acute(rt: &SpikeResolvedText, start: u32) -> Result<(), String> {
let c = rt
.clusters
.clusters
.iter()
.find(|c| c.source.start == start)
.ok_or_else(|| format!("F-E: no cluster starts at byte {start}"))?;
if c.source.end - c.source.start != 3 {
return Err(format!(
"F-E: cluster at byte {start} spans {} bytes, recipe says 3 (e + U+0301)",
c.source.end - c.source.start
));
}
if c.glyph_indices.len() != 1 {
return Err(format!(
"F-E: cluster at byte {start} carries {} glyphs, recipe says 1 (composed)",
c.glyph_indices.len()
));
}
let seg = &rt.segments[c.segment];
let gid = seg.glyphs[c.glyph_indices[0] as usize].glyph_id;
if gid != 198 {
return Err(format!(
"F-E: cluster at byte {start} is gid {gid}, recipe says gid 198"
));
}
Ok(())
}
/// Asserts the (single) glyph named by the cluster starting at `start`
/// (spanning to `end`) is `expected_gid`.
fn expect_cluster_glyph(
label: &str,
rt: &SpikeResolvedText,
start: u32,
end: u32,
expected_gid: u32,
) -> Result<(), String> {
let c = rt
.clusters
.clusters
.iter()
.find(|c| c.source.start == start && c.source.end == end)
.ok_or_else(|| format!("{label}: no cluster spans {start}..{end}"))?;
if c.glyph_indices.len() != 1 {
return Err(format!(
"{label}: cluster {start}..{end} carries {} glyphs, expected exactly 1",
c.glyph_indices.len()
));
}
let seg = &rt.segments[c.segment];
let gid = seg.glyphs[c.glyph_indices[0] as usize].glyph_id;
if gid != expected_gid {
return Err(format!(
"{label}: cluster {start}..{end} is gid {gid}, recipe says gid {expected_gid}"
));
}
Ok(())
}
fn expect_eq(label: &str, actual: usize, expected: usize) -> Result<(), String> {
if actual != expected {
return Err(format!(
"{label}: measured {actual}, recipe §4 records {expected}"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::faces::{resolve_declared_chain, FaceResolution, LoadedFace};
/// Loads the real declared faces, or `None` (pin 14: environment
/// absence) — mirrors `output::tests::real_valid_file`'s pattern so
/// these mutation tests exercise the *real* recipe-§4 checks against
/// genuinely shaped output, not a hand-built stand-in.
fn real_faces() -> Option<Vec<LoadedFace>> {
let mut out = Vec::new();
for r in resolve_declared_chain() {
match r {
FaceResolution::Loaded(lf) => out.push(lf),
FaceResolution::Missing { .. } => return None,
}
}
Some(out)
}
fn require_faces() -> Vec<LoadedFace> {
real_faces().expect("this test requires the two declared faces to be present")
}
#[test]
fn check_f_a_kills_a_wrong_ligature_gid() {
let faces = require_faces();
let mut rt = build_fixture(&FIXTURES[0], &faces, 0);
let c = rt
.clusters
.clusters
.iter()
.find(|c| c.source.start == 9)
.unwrap()
.clone();
let seg = &mut rt.segments[c.segment];
seg.glyphs[c.glyph_indices[0] as usize].glyph_id = 1; // not gid 234
let err = check_f_a(&rt).unwrap_err();
assert!(err.contains("gid 234"), "{err}");
}
#[test]
fn check_f_a_kills_a_wrong_em_dash_gid() {
let faces = require_faces();
let mut rt = build_fixture(&FIXTURES[0], &faces, 0);
let c = rt
.clusters
.clusters
.iter()
.find(|c| c.source.start == 19)
.unwrap()
.clone();
let seg = &mut rt.segments[c.segment];
seg.glyphs[c.glyph_indices[0] as usize].glyph_id = 1;
let err = check_f_a(&rt).unwrap_err();
assert!(err.contains("gid 119"), "{err}");
}
#[test]
fn check_f_b_kills_a_swapped_face_assignment() {
let faces = require_faces();
let mut rt = build_fixture(&FIXTURES[1], &faces, 1);
rt.segments[1].face = Some(0); // Hebrew tail must be face 1, not 0
let err = check_f_b(&rt).unwrap_err();
assert!(err.contains("face 1"), "{err}");
}
#[test]
fn check_f_c_kills_a_dropped_unresolved_cluster() {
let faces = require_faces();
let mut rt = build_fixture(&FIXTURES[2], &faces, 2);
rt.clusters.clusters.retain(|c| c.resolved); // simulate silently dropping it
let err = check_f_c(&rt).unwrap_err();
assert!(err.contains("unresolved cluster"), "{err}");
}
#[test]
fn check_f_d_kills_a_wrong_segment_source_range() {
let faces = require_faces();
let mut rt = build_fixture(&FIXTURES[3], &faces, 3);
rt.segments[1].source = 9..14; // recipe says 8..14
let err = check_f_d(&rt).unwrap_err();
assert!(err.contains("recipe says 8..14"), "{err}");
}
#[test]
fn check_f_e_kills_a_wrong_composed_gid() {
let faces = require_faces();
let mut rt = build_fixture(&FIXTURES[4], &faces, 4);
let c = rt
.clusters
.clusters
.iter()
.find(|c| c.source.start == 3)
.unwrap()
.clone();
let seg = &mut rt.segments[c.segment];
seg.glyphs[c.glyph_indices[0] as usize].glyph_id = 1; // not gid 198
let err = check_f_e(&rt).unwrap_err();
assert!(err.contains("gid 198"), "{err}");
}
#[test]
fn fixture_roster_matches_the_recipe_table() {
let ids: Vec<&str> = FIXTURES.iter().map(|f| f.id).collect();
assert_eq!(ids, ["F-A", "F-B", "F-C", "F-D", "F-E"]);
assert_eq!(FIXTURES[0].text, "Allegro affettuoso \u{2014} al fine");
assert_eq!(FIXTURES[1].text, "Coro \u{05D0}\u{05D1}\u{05D2}");
assert_eq!(FIXTURES[2].text, "Coro \u{0627}");
assert_eq!(
FIXTURES[3].text,
"Allegro \u{05D0}\u{05D1}\u{05D2} con brio"
);
assert_eq!(FIXTURES[4].text, "Cafe\u{301} \u{2014} resume\u{301}");
}
/// Mutation-first: `expect_eq` must actually fail when the numbers
/// disagree, not just when they happen to agree.
#[test]
fn expect_eq_kills_a_disagreement() {
assert!(expect_eq("x", 5, 6).is_err());
assert!(expect_eq("x", 5, 5).is_ok());
}
}