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

968 lines
38 KiB
Rust

//! `fixtures.json`'s root document and its validator.
//!
//! [`FixtureFile::validate`] follows `round1-candidates/harness`'s
//! `OracleFile::validate` discipline exactly: every check compares the
//! loaded data against a **literal restated in this function**, never
//! against the file's own other fields. A validator that only checks a file
//! against itself accepts any self-consistent file — including one with a
//! fixture deleted, a text silently normalized, or a face hash quietly
//! changed to match a swapped-in font.
use serde::{Deserialize, Serialize};
use crate::a11y::{self, SpikeAccessibilityExpectation};
use crate::faces::LoadedFace;
use crate::identity::SpikeTextFaceIdentity;
use crate::types::SpikeResolvedText;
use crate::{invariants, EM_SIZE_STAFF_SPACE, QUANTIZE_GRID, TARGET_HEIGHT, TARGET_WIDTH};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenderTarget {
pub width: f64,
pub height: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceRecord {
pub chain_index: usize,
pub path: String,
pub identity: SpikeTextFaceIdentity,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FixtureRecord {
pub id: String,
pub purpose: String,
pub resolved: SpikeResolvedText,
/// The precommitted W3 §5 check-5 oracle for this fixture (recipe §8,
/// [`crate::a11y`]). Check 5 is disqualifying, so its expectation is
/// encoded in the artifact candidates consume rather than left as prose
/// that would be interpreted after a tree had been seen.
pub accessibility: SpikeAccessibilityExpectation,
}
/// `fixtures.json`'s root. Field order here is JSON key order (serde_json's
/// struct serialization is declaration order, not sorted), which is what
/// makes the output deterministic across regenerations without needing a
/// separate key-sort pass.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FixtureFile {
pub contract: String,
pub recipe: String,
pub em_size_staff_space: f64,
pub quantize_grid: f64,
pub target: RenderTarget,
pub faces: Vec<FaceRecord>,
pub fixtures: Vec<FixtureRecord>,
}
/// Fails rather than defaults if a fixture has no precommitted accessibility
/// note ([`crate::a11y::note_for`]) — a sixth fixture must not be able to
/// arrive with a blank check-5 expectation.
pub fn build_fixture_file(
faces: &[LoadedFace],
fixtures: Vec<(String, String, SpikeResolvedText)>,
) -> Result<FixtureFile, String> {
let mut records = Vec::with_capacity(fixtures.len());
for (id, purpose, resolved) in fixtures {
let accessibility = a11y::build_expectation(&id, &resolved.text)?;
records.push(FixtureRecord {
id,
purpose,
resolved,
accessibility,
});
}
Ok(FixtureFile {
contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14".to_string(),
recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md".to_string(),
em_size_staff_space: EM_SIZE_STAFF_SPACE,
quantize_grid: QUANTIZE_GRID,
target: RenderTarget {
width: TARGET_WIDTH,
height: TARGET_HEIGHT,
},
faces: faces
.iter()
.enumerate()
.map(|(i, f)| FaceRecord {
chain_index: i,
path: f.path.display().to_string(),
identity: f.identity.clone(),
})
.collect(),
fixtures: records,
})
}
/// The two declared faces' SHA-256 hashes, restated as literals (recipe §1
/// table) — the same pair `crate::faces::DECLARED_CHAIN` hard-codes, kept
/// independently here so a validator run against a *different* build of
/// this crate (or a hand-edited `fixtures.json`) still catches a hash that
/// silently drifted from the recipe, not just from this crate's own
/// current source.
const EXPECTED_FACE_HASHES_HEX: [&str; 2] = [
"44e64260716d8f2bbe412baa1ee99b7c995190ac4573177c24def0b9200438c7",
"058ea80864aef09a23f45cbec2bb5400bc3dfbdea01c3f10538a21fcb497fb74",
];
/// The exact five fixture ids, in order, and their verbatim literals
/// (recipe §2 table) — restated, not read from `crate::fixtures::FIXTURES`,
/// so a validator built from a *different* copy of this crate still catches
/// drift (the same reasoning `round1-candidates/harness`'s `ROUND1_ROSTER`
/// documents for not reading the roster back out of the oracle it checks).
const EXPECTED_FIXTURES: [(&str, &str); 5] = [
("F-A", "Allegro affettuoso \u{2014} al fine"),
("F-B", "Coro \u{05D0}\u{05D1}\u{05D2}"),
("F-C", "Coro \u{0627}"),
("F-D", "Allegro \u{05D0}\u{05D1}\u{05D2} con brio"),
("F-E", "Cafe\u{301} \u{2014} resume\u{301}"),
];
/// Each fixture's **purpose string**, restated verbatim — because this string
/// is not decoration. It is what `fixtures.json` carries, what
/// `FIXTURES_SUMMARY.md` prints, and what every generator writes to the
/// console next to that fixture's numbers, so it is the label a reader will
/// use when transcribing results into the Round 2 criterion table.
///
/// **F-D is the reason this check exists.** It used to read `"check 3
/// (bidi)"`, and after the 2026-07-29 ruling (recipe §1.2) that is a
/// contradiction the artifacts assert on every run: check 3 is `NOT RUN` for
/// every candidate (no Arabic-capable face; pin 9), and F-D is scored on a
/// separate **Supplementary** row that must never upgrade check 3 to PASS. A
/// ruling recorded only in the recipe, while the machine-readable artifact and
/// the console output still say "check 3", is a ruling that will be
/// contradicted by whichever record someone happens to read.
const EXPECTED_PURPOSES: [(&str, &str); 5] = [
(
"F-A",
"check 1 (faithful consumption), check 5 (accessibility)",
),
("F-B", "check 2 (fallback, forced)"),
("F-C", "check 2 (uncovered codepoint)"),
(
"F-D",
"SUPPLEMENTARY bidi evidence (Hebrew/Latin) — check 3 remains NOT RUN (no Arabic-capable \
face; recipe §1.2)",
),
("F-E", "check 4 (hit testing / caret)"),
];
/// Recipe §4's measured glyph/segment counts, restated (not derived from
/// `crate::fixtures::check_against_recipe`, which runs at generation time
/// against the *live* shaped result — this validates the *serialized*
/// file, which may have been produced by a different run or hand-inspected
/// copy).
///
/// Revision 1 listed only F-A, F-B and F-E, on the correct reasoning that a
/// validator must not invent a number the recipe never committed to. Recipe
/// revision 2 states the other two measured counts instead — every measured
/// count belongs on the record, and leaving two of five fixtures uncovered by
/// the one check that catches shaped-output drift was the worse of the two
/// options. Revision 2 adds F-C's 5 and F-D's 20, which revision 1 left
/// unstated in prose. The right fix was to state them in the recipe — every
/// measured count belongs on the record — rather than to leave two of five
/// fixtures uncovered by the one check that catches a shaped-output drift.
const EXPECTED_GLYPH_COUNTS: [(&str, usize); 5] = [
("F-A", 26),
("F-B", 8),
("F-C", 5),
("F-D", 20),
("F-E", 13),
];
/// Only F-B ("Two segments, two faces") and F-D ("Three segments") are
/// stated in prose; F-A and F-E's single-segment shape is a natural
/// consequence of unidirectional, single-face text but is not a number §4
/// writes down, so it is not asserted here as a "recipe §4" literal (it is
/// still checked at generation time in `crate::fixtures::check_f_a`/`_e`,
/// documented there as a derived — not quoted — expectation).
const EXPECTED_SEGMENT_COUNTS: [(&str, usize); 2] = [("F-B", 2), ("F-D", 3)];
/// SHA-256 over the whole artifact's canonical JSON — **the check that makes
/// this validator complete**, and the reason the checks above are no longer
/// the only thing standing between a tampered file and a candidate run.
///
/// Every check before this one is a *named* property: the em size, the target,
/// the face hashes, the five texts, the glyph and segment counts, the five W3
/// invariants, the check-5 oracle. Together they still accept an enormous
/// space of wrong files, because they say nothing about the individual glyph
/// ids, the individual quantized offsets, or the caret-stop positions —
/// tens of thousands of numbers that a candidate in packet 2B will be scored
/// against. Change F-A's ligature glyph id while keeping counts and indices
/// valid and every check above passes; the candidate then renders faithfully,
/// disagrees with the unchanged reference raster, and fails a test it should
/// have passed. That is not a hypothetical: it is the review finding this
/// constant exists to close.
///
/// **What it binds, and what that costs.** The digest covers the complete
/// serialized `FixtureFile`, `faces[].path` included — so it is bound to the
/// absolute font paths on the machine that generated it. That is deliberate
/// (pin 9 requires faces to be resolved from an *explicit path list*, and a
/// file whose paths changed is a file that may have been generated against
/// different bytes), and it means regenerating on a machine with fonts
/// installed elsewhere will legitimately produce a different digest. When that
/// happens the fix is to re-record the digest **after** confirming the two
/// face hashes are unchanged — never to relax the check.
///
/// Regenerate with `cargo run -p round2-textkit --bin generate`, which prints
/// the digest it produced next to the one compiled in.
const EXPECTED_ARTIFACT_DIGEST_HEX: &str =
"acc13c0d02624a0741cca5dffa7470a8971d3ecef5c6fb6f9e533ded684e7ed1";
/// The canonical serialization the digest is taken over: compact JSON, in
/// declaration order (serde's struct order), with no whitespace. Kept separate
/// from the pretty-printed form written to disk so that reformatting the file
/// on disk — which a human might do — does not change the identity, while
/// changing any *value* does.
pub fn canonical_bytes(file: &FixtureFile) -> Vec<u8> {
serde_json::to_vec(file).expect("FixtureFile is always serializable")
}
/// SHA-256 of [`canonical_bytes`], lowercase hex.
pub fn artifact_digest(file: &FixtureFile) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(canonical_bytes(file));
hex_encode(&h.finalize())
}
/// The digest this build expects, for `bin/generate` to print alongside the
/// one it just computed.
pub fn expected_artifact_digest() -> &'static str {
EXPECTED_ARTIFACT_DIGEST_HEX
}
/// Checks one fixture's precommitted check-5 oracle (recipe §8).
///
/// `expected_text` is the recipe §2 literal restated in [`EXPECTED_FIXTURES`]
/// — deliberately **not** `f.resolved.text`, so a file whose text and
/// accessible name were drifted together still fails here. The role tables
/// come from [`crate::a11y`]'s own constants, which are source literals in
/// this crate rather than fields of the file being checked, and so are the
/// same class of authority as `TARGET_WIDTH` or `QUANTIZE_GRID` above.
fn check_accessibility(
id: &str,
expected_text: &str,
a: &SpikeAccessibilityExpectation,
) -> Result<(), String> {
if a.name != expected_text {
return Err(format!(
"{id}: accessible name is {:?}, recipe §2's source string is {expected_text:?} — the \
accessibility tree must carry the source string, so this is where a normalized or \
re-shaped name is caught",
a.name
));
}
let expected_hex = a11y::hex_lower(expected_text.as_bytes());
if a.name_bytes_hex != expected_hex {
return Err(format!(
"{id}: accessible name_bytes_hex is {} , expected {expected_hex}",
a.name_bytes_hex
));
}
if a.name_byte_len != expected_text.len() {
return Err(format!(
"{id}: accessible name_byte_len is {}, expected {}",
a.name_byte_len,
expected_text.len()
));
}
if a.name_composition != a11y::NAME_COMPOSITION {
return Err(format!("{id}: accessible name_composition rule drifted"));
}
if a.accepted_roles != a11y::accepted_roles() {
return Err(format!(
"{id}: accepted_roles disagrees with crate::a11y::ACCEPTED_ROLE_TABLE — check 5 is \
disqualifying, and a widened accepted set is how a disqualifying check quietly stops \
disqualifying anything"
));
}
if a.prohibited_roles != a11y::prohibited_roles() {
return Err(format!(
"{id}: prohibited_roles disagrees with crate::a11y::PROHIBITED_ROLE_TABLE"
));
}
let expected_outcomes: Vec<String> = a11y::PROHIBITED_OUTCOMES
.iter()
.map(|s| s.to_string())
.collect();
if a.prohibited_outcomes != expected_outcomes {
return Err(format!(
"{id}: prohibited_outcomes disagrees with crate::a11y::PROHIBITED_OUTCOMES"
));
}
if a.note != a11y::note_for(id)? {
return Err(format!(
"{id}: accessibility note disagrees with crate::a11y::note_for"
));
}
Ok(())
}
impl FixtureFile {
/// Checks the loaded file against literals restated in this function —
/// never against the file's own other fields (see the module doc
/// comment). Returns the first disagreement found.
pub fn validate(&self) -> Result<(), String> {
if self.em_size_staff_space != EM_SIZE_STAFF_SPACE {
return Err(format!(
"em_size_staff_space is {}, not {EM_SIZE_STAFF_SPACE}",
self.em_size_staff_space
));
}
if self.quantize_grid != QUANTIZE_GRID {
return Err(format!(
"quantize_grid is {}, not {QUANTIZE_GRID}",
self.quantize_grid
));
}
if self.target.width != TARGET_WIDTH || self.target.height != TARGET_HEIGHT {
return Err(format!(
"target is {}x{}, not pin 4's {TARGET_WIDTH}x{TARGET_HEIGHT}",
self.target.width, self.target.height
));
}
if self.faces.len() != EXPECTED_FACE_HASHES_HEX.len() {
return Err(format!(
"{} faces recorded, recipe §1 declares {}",
self.faces.len(),
EXPECTED_FACE_HASHES_HEX.len()
));
}
for (i, expected_hex) in EXPECTED_FACE_HASHES_HEX.iter().enumerate() {
let face = &self.faces[i];
let actual_hex = hex_encode(&face.identity.file_hash);
if &actual_hex != expected_hex {
return Err(format!(
"face[{i}] hash {actual_hex} disagrees with recipe §1's recorded {expected_hex}"
));
}
}
if self.fixtures.len() != EXPECTED_FIXTURES.len() {
return Err(format!(
"{} fixtures recorded, recipe §2 names {}",
self.fixtures.len(),
EXPECTED_FIXTURES.len()
));
}
for (i, (expected_id, expected_text)) in EXPECTED_FIXTURES.iter().enumerate() {
let f = &self.fixtures[i];
if f.id != *expected_id {
return Err(format!(
"fixtures[{i}] id is {:?}, recipe §2 names {expected_id:?}",
f.id
));
}
if f.resolved.text != *expected_text {
return Err(format!(
"{}: text is {:?}, recipe §2's verbatim literal is {:?} — a silent \
normalization would show up exactly here",
f.id, f.resolved.text, expected_text
));
}
let (_, expected_purpose) = EXPECTED_PURPOSES[i];
if f.purpose != expected_purpose {
return Err(format!(
"{}: purpose is {:?}, expected {:?}. This string is the label a reader \
transcribes into the Round 2 criterion table, so a drifted purpose \
misclassifies a result even when every number below it is right — see \
EXPECTED_PURPOSES. For F-D specifically, restoring \"check 3 (bidi)\" \
contradicts the 2026-07-29 ruling (recipe §1.2): check 3 is NOT RUN, and \
F-D is a separate Supplementary row that must not upgrade it to PASS.",
f.id, f.purpose, expected_purpose
));
}
check_accessibility(&f.id, expected_text, &f.accessibility)?;
}
for (id, expected_segments) in EXPECTED_SEGMENT_COUNTS {
let f = self
.fixtures
.iter()
.find(|f| f.id == id)
.ok_or_else(|| format!("missing fixture {id}"))?;
if f.resolved.segments.len() != expected_segments {
return Err(format!(
"{id}: {} segments, recipe §4 records {expected_segments}",
f.resolved.segments.len()
));
}
}
for (id, expected_glyphs) in EXPECTED_GLYPH_COUNTS {
let f = self
.fixtures
.iter()
.find(|f| f.id == id)
.ok_or_else(|| format!("missing fixture {id}"))?;
let actual: usize = f.resolved.segments.iter().map(|s| s.glyphs.len()).sum();
if actual != expected_glyphs {
return Err(format!(
"{id}: {actual} total glyphs, recipe §4 records {expected_glyphs}"
));
}
}
// Re-run every W3 §5 invariant on every loaded fixture — the
// structural checks above establish this is *the recipe's* data;
// this establishes it is still *internally coherent* data.
for f in &self.fixtures {
let expect_unresolved = f.id == "F-C";
invariants::assert_utf8_boundaries(&f.id, &f.resolved)?;
invariants::assert_segments_partition_totally(&f.id, &f.resolved)?;
invariants::assert_clusters_carry_required_fields(&f.id, &f.resolved)?;
invariants::assert_unresolved_clusters_are_diagnostic(
&f.id,
&f.resolved,
expect_unresolved,
)?;
invariants::assert_positions_quantized(&f.id, &f.resolved)?;
}
// Last, on purpose. Every check above reports a *specific*
// disagreement ("F-A: 25 total glyphs, recipe §4 records 26"), which
// is far more useful than "the digest changed"; the digest is the
// backstop that catches everything the named checks do not name, so it
// must not pre-empt them.
let actual = artifact_digest(self);
if actual != EXPECTED_ARTIFACT_DIGEST_HEX {
return Err(format!(
"artifact digest is {actual}, expected {EXPECTED_ARTIFACT_DIGEST_HEX} — some \
field of this file differs from the recorded artifact in a way none of the \
named checks above covers (a glyph id, a quantized offset, a caret-stop \
position, a face path). Re-record the digest only after establishing why it \
changed."
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::faces::{resolve_declared_chain, FaceResolution};
use crate::fixtures::{build_fixture, FIXTURES};
/// Builds a real, valid `FixtureFile` end to end against the actual
/// declared faces on this machine — the same path `bin/generate.rs`
/// takes. If either declared face is absent, the test is skipped rather
/// than failed (pin 14: an environment absence, not a failure); on this
/// development machine both are present, so every mutation test below
/// actually runs.
fn real_valid_file() -> Option<FixtureFile> {
let resolved = resolve_declared_chain();
let mut loaded = Vec::new();
for r in resolved {
match r {
FaceResolution::Loaded(lf) => loaded.push(lf),
FaceResolution::Missing { .. } => return None,
}
}
let built: Vec<(String, String, SpikeResolvedText)> = FIXTURES
.iter()
.enumerate()
.map(|(i, def)| {
let rt = build_fixture(def, &loaded, i as u64);
(def.id.to_string(), def.purpose.to_string(), rt)
})
.collect();
Some(build_fixture_file(&loaded, built).expect("every fixture has a precommitted note"))
}
fn require_file() -> FixtureFile {
real_valid_file().expect(
"this test requires the two declared faces to be present on the machine running it",
)
}
#[test]
fn a_freshly_built_file_validates() {
require_file().validate().unwrap();
}
#[test]
fn validate_kills_a_wrong_em_size() {
let mut f = require_file();
f.em_size_staff_space = 0.64; // the recipe's original, now-superseded value
let err = f.validate().unwrap_err();
assert!(err.contains("em_size_staff_space"), "{err}");
}
#[test]
fn validate_kills_a_wrong_quantize_grid() {
let mut f = require_file();
f.quantize_grid = 256.0;
let err = f.validate().unwrap_err();
assert!(err.contains("quantize_grid"), "{err}");
}
#[test]
fn validate_kills_a_wrong_target_size() {
let mut f = require_file();
f.target.width = 1280.0;
let err = f.validate().unwrap_err();
assert!(err.contains("target"), "{err}");
}
#[test]
fn validate_kills_a_missing_face() {
let mut f = require_file();
f.faces.pop();
let err = f.validate().unwrap_err();
assert!(err.contains("faces"), "{err}");
}
#[test]
fn validate_kills_a_tampered_face_hash() {
let mut f = require_file();
f.faces[0].identity.file_hash[0] ^= 0xFF; // simulate a swapped-in font
let err = f.validate().unwrap_err();
assert!(err.contains("hash"), "{err}");
}
#[test]
fn validate_kills_a_missing_fixture() {
let mut f = require_file();
f.fixtures.pop();
let err = f.validate().unwrap_err();
assert!(err.contains("fixtures"), "{err}");
}
#[test]
fn validate_kills_a_wrong_fixture_id() {
let mut f = require_file();
f.fixtures[0].id = "F-Z".to_string();
let err = f.validate().unwrap_err();
assert!(err.contains("id"), "{err}");
}
#[test]
fn validate_kills_a_silently_normalized_text() {
let mut f = require_file();
// F-E's whole reason for existing is NFD; simulate an implementation
// that quietly normalized it to NFC before storing.
f.fixtures[4].resolved.text = "Café \u{2014} resumé".to_string();
let err = f.validate().unwrap_err();
assert!(err.contains("normalization"), "{err}");
}
#[test]
fn validate_kills_a_wrong_glyph_count() {
let mut f = require_file();
f.fixtures[0].resolved.segments[0].glyphs.pop(); // drop the last glyph of F-A
let err = f.validate().unwrap_err();
assert!(err.contains("glyphs"), "{err}");
}
#[test]
fn validate_kills_a_wrong_segment_count() {
let mut f = require_file();
// F-D (index 3) must have 3 segments. Duplicate its last (empty-face)
// segment rather than popping one, so total glyph count is untouched
// and this mutation is caught specifically by the segment-count
// check, not incidentally by the earlier glyph-count check.
let dup = f.fixtures[3].resolved.segments.last().unwrap().clone();
f.fixtures[3].resolved.segments.push(dup);
let err = f.validate().unwrap_err();
assert!(err.contains("segments"), "{err}");
}
#[test]
fn validate_kills_a_broken_invariant_on_a_loaded_fixture() {
let mut f = require_file();
f.fixtures[0].resolved.clusters.clusters[0]
.caret_stops
.clear();
let err = f.validate().unwrap_err();
assert!(err.contains("invariant 3"), "{err}");
}
// ---- the check-5 accessibility oracle (recipe §8) ----
#[test]
fn validate_kills_a_normalized_accessible_name() {
let mut f = require_file();
// The exact failure check 5 exists to catch: the tree exposes NFC for
// an NFD source. Both the string and its hex are mutated together, so
// this is not caught by the redundant-hex check but by the name check
// against the recipe literal.
f.fixtures[4].accessibility.name = "Caf\u{e9} \u{2014} resum\u{e9}".to_string();
f.fixtures[4].accessibility.name_bytes_hex =
a11y::hex_lower(f.fixtures[4].accessibility.name.as_bytes());
f.fixtures[4].accessibility.name_byte_len = f.fixtures[4].accessibility.name.len();
let err = f.validate().unwrap_err();
assert!(err.contains("accessible name"), "{err}");
}
#[test]
fn validate_kills_a_name_whose_hex_no_longer_matches_it() {
let mut f = require_file();
f.fixtures[0]
.accessibility
.name_bytes_hex
.replace_range(0..2, "ff");
let err = f.validate().unwrap_err();
assert!(err.contains("name_bytes_hex"), "{err}");
}
#[test]
fn validate_kills_an_accepted_role_set_widened_to_admit_an_image() {
let mut f = require_file();
f.fixtures[0].accessibility.accepted_roles[0]
.tokens
.push("Image".to_string());
let err = f.validate().unwrap_err();
assert!(err.contains("accepted_roles"), "{err}");
}
#[test]
fn validate_kills_a_dropped_prohibited_outcome() {
let mut f = require_file();
// Dropping "absent-from-tree" is the mutation that matters: it is the
// outcome a canvas toolkit produces by default, so removing it would
// let the most likely real failure through.
f.fixtures[0]
.accessibility
.prohibited_outcomes
.retain(|o| o != "absent-from-tree");
let err = f.validate().unwrap_err();
assert!(err.contains("prohibited_outcomes"), "{err}");
}
#[test]
fn validate_kills_a_swapped_accessibility_note() {
let mut f = require_file();
let other = f.fixtures[1].accessibility.note.clone();
f.fixtures[0].accessibility.note = other;
let err = f.validate().unwrap_err();
assert!(err.contains("note"), "{err}");
}
/// The exact mutation the 2026-07-29 ruling forbids: F-D relabelled back
/// to "check 3 (bidi)" in the artifact candidates and readers consume.
/// Everything else about the file stays valid, which is precisely why this
/// needs its own named check — the numbers would all be right and the
/// classification would be wrong.
#[test]
fn validate_kills_f_d_relabelled_as_check_3() {
let mut f = require_file();
assert_eq!(f.fixtures[3].id, "F-D", "anchor: index 3 must be F-D");
f.fixtures[3].purpose = "check 3 (bidi)".to_string();
let err = f.validate().unwrap_err();
assert!(err.contains("purpose"), "{err}");
assert!(err.contains("NOT RUN"), "{err}");
}
#[test]
fn validate_kills_any_swapped_purpose() {
let mut f = require_file();
let other = f.fixtures[1].purpose.clone();
f.fixtures[0].purpose = other;
let err = f.validate().unwrap_err();
assert!(err.contains("purpose"), "{err}");
}
/// The roster restated in this module must agree with the one
/// `crate::fixtures` builds from — two hand-maintained lists that silently
/// disagreed would make the validator check the file against a label no
/// generator ever writes.
#[test]
fn the_restated_purposes_match_the_fixture_definitions() {
for (i, def) in FIXTURES.iter().enumerate() {
let (id, purpose) = EXPECTED_PURPOSES[i];
assert_eq!(def.id, id);
assert_eq!(def.purpose, purpose, "purpose drift for {id}");
}
}
// ---- the whole-artifact digest ----
#[test]
fn validate_kills_a_changed_glyph_id() {
let mut f = require_file();
// The mutation the review named: F-A's ligature glyph replaced while
// every count, index and header stays valid. Nothing above this line
// sees it; the digest does.
let g = &mut f.fixtures[0].resolved.segments[0].glyphs[3];
assert_ne!(
g.glyph_id, 9999,
"anchor: the fixture must not already be 9999"
);
g.glyph_id = 9999;
let err = f.validate().unwrap_err();
assert!(err.contains("digest"), "{err}");
}
#[test]
fn validate_kills_a_moved_quantized_offset() {
let mut f = require_file();
// One grid unit — the smallest legal move. Still on the 1/1024 grid,
// so invariant 5 accepts it; the count checks accept it; the digest
// does not.
let g = &mut f.fixtures[0].resolved.segments[0].glyphs[3];
g.offset.x += 1.0 / crate::QUANTIZE_GRID;
let err = f.validate().unwrap_err();
assert!(err.contains("digest"), "{err}");
}
#[test]
fn validate_kills_a_changed_caret_stop_position() {
let mut f = require_file();
let s = &mut f.fixtures[0].resolved.clusters.clusters[0].caret_stops[0];
s.position.x += 1.0 / crate::QUANTIZE_GRID;
let err = f.validate().unwrap_err();
assert!(err.contains("digest"), "{err}");
}
#[test]
fn validate_kills_a_changed_face_path() {
let mut f = require_file();
f.faces[0].path = "/somewhere/else/texgyrepagella-regular.otf".to_string();
let err = f.validate().unwrap_err();
assert!(err.contains("digest"), "{err}");
}
#[test]
fn json_round_trip_preserves_validity() {
let f = require_file();
let json = serde_json::to_string_pretty(&f).unwrap();
let reloaded: FixtureFile = serde_json::from_str(&json).unwrap();
reloaded.validate().unwrap();
}
#[test]
fn summary_markdown_mentions_every_fixture_and_the_unicode_disagreement() {
let f = require_file();
let md = render_summary_markdown(&f);
for def in FIXTURES {
assert!(md.contains(def.id), "summary must mention {}", def.id);
}
assert!(
md.contains("disagree"),
"summary must surface the bidi/segmentation Unicode-version disagreement \
(crate::findings::W3_F2)"
);
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
/// A short, human-readable render of `FixtureFile` for `FIXTURES_SUMMARY.md`
/// — every fixture's segment/face/glyph/cluster facts, and the shaping
/// identity's two Unicode-component versions (recipe §6's "recorded twice
/// on purpose").
pub fn render_summary_markdown(file: &FixtureFile) -> String {
let mut out = String::new();
out.push_str("# Round 2 text fixtures — measured summary\n\n");
out.push_str(&format!(
"Generated by `round2-textkit`. Em size: `{}` staff space. Target: {}x{}.\n\n",
file.em_size_staff_space, file.target.width, file.target.height
));
out.push_str("## Faces\n\n");
for f in &file.faces {
out.push_str(&format!(
"- chain[{}] `{}` — family `{}`, version `{:?}`, sha256 `{}`\n",
f.chain_index,
f.path,
f.identity.family,
f.identity.version,
hex_encode(&f.identity.file_hash)
));
}
out.push('\n');
out.push_str("## Shaping identity (from the first fixture; identical for all)\n\n");
if let Some(first) = file.fixtures.first() {
let s = &first.resolved.shaping;
out.push_str(&format!(
"- shaper: `{}` `{}.{}.{}`\n",
s.shaper.0, s.shaper_version.major, s.shaper_version.minor, s.shaper_version.patch
));
out.push_str(&format!(
"- unicode-bidi: crate `{}`, Unicode `{}`\n",
s.unicode_bidi.crate_version,
s.unicode_bidi
.unicode_version
.as_deref()
.unwrap_or("(none reported)")
));
out.push_str(&format!(
"- unicode-segmentation: crate `{}`, Unicode `{}`\n",
s.unicode_segmentation.crate_version,
s.unicode_segmentation
.unicode_version
.as_deref()
.unwrap_or("(none reported)")
));
if s.unicode_bidi.unicode_version != s.unicode_segmentation.unicode_version {
out.push_str(
"- **the two Unicode-data versions disagree** — reported, not reconciled \
(`crate::findings::W3_F2`).\n",
);
}
}
out.push('\n');
out.push_str("## Fixtures\n\n");
for f in &file.fixtures {
let r = &f.resolved;
let total_glyphs: usize = r.segments.iter().map(|s| s.glyphs.len()).sum();
out.push_str(&format!("### {}{}\n\n", f.id, f.purpose));
out.push_str(&format!("text: `{:?}`\n\n", r.text));
out.push_str(&format!(
"{} codepoints, {} bytes, {} segments, {} glyphs, {} clusters\n\n",
r.text.chars().count(),
r.text.len(),
r.segments.len(),
total_glyphs,
r.clusters.clusters.len()
));
out.push_str("| segment | source | face | direction | script | glyphs |\n");
out.push_str("|---|---|---|---|---|---|\n");
for (i, seg) in r.segments.iter().enumerate() {
out.push_str(&format!(
"| {i} | {}..{} | {:?} | {:?} | {} | {} |\n",
seg.source.start,
seg.source.end,
seg.face,
seg.direction,
seg.script.0,
seg.glyphs.len()
));
}
out.push('\n');
out.push_str(&format!(
"check 5 (accessibility, disqualifying) — name must be exactly `{}` \
({} bytes, hex `{}`); {}\n\n",
f.accessibility.name,
f.accessibility.name_byte_len,
f.accessibility.name_bytes_hex,
f.accessibility.note
));
}
out.push_str("## Check-5 role vocabulary (identical for every fixture)\n\n");
if let Some(first) = file.fixtures.first() {
out.push_str("| platform | accepted | prohibited |\n|---|---|---|\n");
for (acc, pro) in first
.accessibility
.accepted_roles
.iter()
.zip(first.accessibility.prohibited_roles.iter())
{
out.push_str(&format!(
"| {} | {} | {} |\n",
acc.platform,
acc.tokens.join(", "),
pro.tokens.join(", ")
));
}
out.push_str(&format!(
"\nName composition: {}\n\nProhibited outcomes, whatever the role: {}\n",
first.accessibility.name_composition,
first.accessibility.prohibited_outcomes.join(", ")
));
}
out
}
/// Loads `fixtures.json` **and validates it**, in one call, because those two
/// steps must never be separable.
///
/// Round 1 learned this the hard way: the oracle's Rust types deserialized a
/// tampered file cleanly, and the fix was `deny_unknown_fields` on every type
/// *plus* a semantic `validate()` that every consumer calls before it renders.
/// Round 2's consumers arrive in packet 2B, so the entry point exists now,
/// before there is any consumer that could have forgotten it:
///
/// - `deny_unknown_fields` (on every deserialized type in this crate) catches
/// **structural** drift — serde ignores unknown fields by default, so
/// without it a field added to the file would load silently;
/// - [`FixtureFile::validate`] catches **semantic** drift, which is the
/// dangerous kind: a file that deserializes cleanly but no longer means what
/// Round 2 requires would be consumed faithfully and pass.
///
/// There is deliberately no public "load without validating".
pub fn load_fixtures(path: &std::path::Path) -> Result<FixtureFile, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read fixtures at {}: {e}", path.display()))?;
let file: FixtureFile = serde_json::from_str(&text)
.map_err(|e| format!("failed to parse fixtures at {}: {e}", path.display()))?;
file.validate()
.map_err(|e| format!("fixtures at {} failed validation: {e}", path.display()))?;
Ok(file)
}
#[cfg(test)]
mod load_tests {
use super::*;
fn fixtures_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures.json")
}
#[test]
fn the_committed_file_loads_and_validates() {
let p = fixtures_path();
if !p.exists() {
eprintln!(
"NOT RUN: {} absent — run `cargo run -p round2-textkit --bin generate` first",
p.display()
);
return;
}
load_fixtures(&p).unwrap();
}
/// Mutation: an unknown field must be refused, not ignored. Without
/// `deny_unknown_fields` serde drops it silently and the "drift is a load
/// error" claim above would be false.
#[test]
fn an_unknown_field_is_refused() {
let p = fixtures_path();
if !p.exists() {
return;
}
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
v.as_object_mut()
.unwrap()
.insert("smuggled_field".into(), serde_json::json!(1));
let err = serde_json::from_value::<FixtureFile>(v)
.unwrap_err()
.to_string();
assert!(err.contains("smuggled_field"), "{err}");
}
/// Mutation: a semantically drifted file must be refused by `validate`
/// even though it deserializes perfectly.
#[test]
fn a_semantically_drifted_file_is_refused() {
let p = fixtures_path();
if !p.exists() {
return;
}
let mut v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
v["em_size_staff_space"] = serde_json::json!(0.64);
let file: FixtureFile = serde_json::from_value(v).unwrap();
let err = file.validate().unwrap_err();
assert!(err.contains("em_size_staff_space"), "{err}");
}
}