Packet 2B apparatus: the neutral candidatekit and the check-5 oracle
Pin 13 requires an oracle committed and reviewed before any candidate is
scored against it. Both halves land here, before C1 or C2 exists, so
neither candidate can shape what it will be measured by.
round2-candidatekit is the only code the two candidates share. It loads
and validates Packet 2A's fixtures, probe table, reference rasters and
regions; it renders nothing, resolves no hit test, and builds no
accessibility node -- a dependency deny-list test reads its own manifest
at test time to keep it that way, including the dotted-subtable and
target-cfg forms a line scanner would otherwise miss. Check 3 is pinned
NOT RUN by the recipe's 1.2 ruling: a report claiming Pass or Fail for it
is refused, and the supplementary F-D field is one the scoring function
never reads, so it cannot reach the criterion cell by construction rather
than by convention. A check-5 NOT RUN now requires typed evidence that the
platform bus was unreachable, because Round 0 already proved it reachable
for both candidates -- "we did not build it" is scope, and scope is not
environmental.
round2-a11y-oracle derives every byte string a live AT-SPI readback must
compare against, from the digest-validated fixtures alone: the expected
name three ways, the at-spi2 role sets, a precommitted alternative form
per prohibited outcome, the visual-order form, and the per-segment source
atoms. The fixtures digest is unchanged at acc13c0d, so Packet 2A stays
frozen.
verify.py gains a check-5 mode whose classifier is a pure function, unit
tested without a bus, and which fails closed on platform, digest, fixture
completeness, the name/hex/length triple, atom concatenation, and any
string that would classify as two different outcomes.
The scoring policy, settled over eight review rounds and worth stating
because most of it was won by finding the opposite in the code first:
- An accepted-role exact match wins from anywhere in the forest. It used
to be first-match, so tree order decided a disqualifying check.
- Composition is scoped to one run subtree and outranks any per-node
diagnosis. Application-wide concatenation both passed a run half-drawn
as a canvas and failed a correct split run because the window carried
a label.
- Contributor order stays significant, since 8.1 requires logical order;
only non-contributor order is required not to matter.
- name-empty means an attempted static-text exposure without a name.
absent-from-tree means a drawing-only or unrelated tree, and nothing
else -- it is the outcome 8.3 says this check will most likely catch,
and it was nearly unreachable while gated on role presence.
- A byte-exact PASS outranks an alternative-form match. F-C's
"drops unresolved codepoints" form is its own first segment, so a
legitimate per-run split was being failed on a check that eliminates.
That collision is structural for any two-segment fixture with one
unresolved segment, not an F-C accident.
Recorded as a finding, not fixed: the recipe's claim that visual-order
assembly diverges for F-D "and only there" is false -- F-B diverges too,
and any non-palindromic RTL run of two or more graphemes will. F-D's real
distinguishing property is an interior RTL run. The same stale wording is
compiled into fixtures.json and covered by its frozen digest, so
correcting it would move acc13c0d out from under Packet 2A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz
This commit is contained in:
parent
b622498c19
commit
694d135bfc
|
|
@ -3807,6 +3807,27 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "round2-a11y-oracle"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"round2-textkit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unicode-normalization",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "round2-candidatekit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"round2-diff",
|
||||
"round2-textkit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "round2-diff"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ members = [
|
|||
"round2-textkit",
|
||||
"round2-svgref",
|
||||
"round2-reference",
|
||||
"round2-candidatekit",
|
||||
"round2-a11y-oracle",
|
||||
]
|
||||
|
||||
# a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
__pycache__/
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "round2-a11y-oracle"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# Packet 2B-A (ROUND2_TEXT_RECIPE.md §8, spec/CONTRACT_EDITOR_T4_SPIKE.md pin
|
||||
# 13): the check-5 accessibility oracle's *comparison-data* half. This crate
|
||||
# reads the already-committed, already-validated `round2-textkit/fixtures.json`
|
||||
# (via `round2_textkit::output::load_fixtures`, which validates it against its
|
||||
# embedded digest — see that crate) and writes
|
||||
# `round2-a11y-oracle/a11y_expectations.json`: per fixture, the exact accepted/
|
||||
# prohibited at-spi2 roles and the exact byte strings each `PROHIBITED_OUTCOMES`
|
||||
# classification would produce, so `a11y-verifier/verify.py`'s live-tree mode
|
||||
# compares observed bytes against precommitted bytes rather than guessing what
|
||||
# a wrong name "looks like".
|
||||
#
|
||||
# This crate must exist and be reviewed *before* either Round 2 candidate
|
||||
# builds a tree (pin 13): if a candidate's own tree shaped what this oracle
|
||||
# expects, the oracle would no longer be neutral evidence.
|
||||
#
|
||||
# Deliberately no rendering, windowing, or accessibility crate here — this
|
||||
# crate never touches a live tree; that is `a11y-verifier/verify.py`'s job.
|
||||
|
||||
[dependencies]
|
||||
round2-textkit = { path = "../round2-textkit" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Pinned to the same version already resolved transitively (epiphany-core /
|
||||
# epiphany-ops both already pull it), so adding this dependency changes
|
||||
# nothing in Cargo.lock's resolution.
|
||||
unicode-normalization = "=0.1.25"
|
||||
# Same version round2-textkit shapes fixtures with, for the same reason it
|
||||
# pins it: grapheme-boundary derivation here must agree with the grapheme
|
||||
# boundaries `crate::hittest`'s caret stops were built from.
|
||||
unicode-segmentation = "=1.13.3"
|
||||
|
||||
[[bin]]
|
||||
name = "generate_a11y_expectations"
|
||||
path = "src/bin/generate_a11y_expectations.rs"
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13",
|
||||
"recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8",
|
||||
"platform": "at-spi2",
|
||||
"source_fixtures_digest": "acc13c0d02624a0741cca5dffa7470a8971d3ecef5c6fb6f9e533ded684e7ed1",
|
||||
"fixtures": [
|
||||
{
|
||||
"fixture_id": "F-A",
|
||||
"expected_name": "Allegro affettuoso — al fine",
|
||||
"expected_name_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65",
|
||||
"expected_name_byte_len": 30,
|
||||
"accepted_roles": [
|
||||
"label",
|
||||
"static",
|
||||
"text",
|
||||
"paragraph"
|
||||
],
|
||||
"prohibited_roles": [
|
||||
"image",
|
||||
"canvas",
|
||||
"filler",
|
||||
"panel",
|
||||
"unknown"
|
||||
],
|
||||
"source_atoms": [
|
||||
"Allegro affettuoso — al fine"
|
||||
],
|
||||
"alternative_forms": {
|
||||
"name-is-shaped-glyphs": [
|
||||
"Allegro afettuoso — al fne",
|
||||
"Allegro affettuoso — al fine"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fixture_id": "F-B",
|
||||
"expected_name": "Coro אבג",
|
||||
"expected_name_hex": "436f726f20d790d791d792",
|
||||
"expected_name_byte_len": 11,
|
||||
"accepted_roles": [
|
||||
"label",
|
||||
"static",
|
||||
"text",
|
||||
"paragraph"
|
||||
],
|
||||
"prohibited_roles": [
|
||||
"image",
|
||||
"canvas",
|
||||
"filler",
|
||||
"panel",
|
||||
"unknown"
|
||||
],
|
||||
"source_atoms": [
|
||||
"Coro ",
|
||||
"אבג"
|
||||
],
|
||||
"alternative_forms": {},
|
||||
"visual_order_name": "Coro גבא",
|
||||
"visual_order_name_hex": "436f726f20d792d791d790"
|
||||
},
|
||||
{
|
||||
"fixture_id": "F-C",
|
||||
"expected_name": "Coro ا",
|
||||
"expected_name_hex": "436f726f20d8a7",
|
||||
"expected_name_byte_len": 7,
|
||||
"accepted_roles": [
|
||||
"label",
|
||||
"static",
|
||||
"text",
|
||||
"paragraph"
|
||||
],
|
||||
"prohibited_roles": [
|
||||
"image",
|
||||
"canvas",
|
||||
"filler",
|
||||
"panel",
|
||||
"unknown"
|
||||
],
|
||||
"source_atoms": [
|
||||
"Coro ",
|
||||
"ا"
|
||||
],
|
||||
"alternative_forms": {
|
||||
"name-drops-unresolved-codepoints": [
|
||||
"Coro "
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fixture_id": "F-D",
|
||||
"expected_name": "Allegro אבג con brio",
|
||||
"expected_name_hex": "416c6c6567726f20d790d791d79220636f6e206272696f",
|
||||
"expected_name_byte_len": 23,
|
||||
"accepted_roles": [
|
||||
"label",
|
||||
"static",
|
||||
"text",
|
||||
"paragraph"
|
||||
],
|
||||
"prohibited_roles": [
|
||||
"image",
|
||||
"canvas",
|
||||
"filler",
|
||||
"panel",
|
||||
"unknown"
|
||||
],
|
||||
"source_atoms": [
|
||||
"Allegro ",
|
||||
"אבג",
|
||||
" con brio"
|
||||
],
|
||||
"alternative_forms": {},
|
||||
"visual_order_name": "Allegro גבא con brio",
|
||||
"visual_order_name_hex": "416c6c6567726f20d792d791d79020636f6e206272696f"
|
||||
},
|
||||
{
|
||||
"fixture_id": "F-E",
|
||||
"expected_name": "Café — resumé",
|
||||
"expected_name_hex": "43616665cc8120e2809420726573756d65cc81",
|
||||
"expected_name_byte_len": 19,
|
||||
"accepted_roles": [
|
||||
"label",
|
||||
"static",
|
||||
"text",
|
||||
"paragraph"
|
||||
],
|
||||
"prohibited_roles": [
|
||||
"image",
|
||||
"canvas",
|
||||
"filler",
|
||||
"panel",
|
||||
"unknown"
|
||||
],
|
||||
"source_atoms": [
|
||||
"Café — resumé"
|
||||
],
|
||||
"alternative_forms": {
|
||||
"name-normalized": [
|
||||
"Café — resumé"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
//! `generate_a11y_expectations` — Packet 2B-A's entry point.
|
||||
//!
|
||||
//! Loads `round2-textkit/fixtures.json` (validating it against its own
|
||||
//! embedded digest via `round2_textkit::output::load_fixtures`), derives this
|
||||
//! machine's check-5 comparison data for all five fixtures, and writes
|
||||
//! `round2-a11y-oracle/a11y_expectations.json`.
|
||||
//!
|
||||
//! Exit behavior mirrors `round2-textkit`'s own `bin/generate`: a missing
|
||||
//! `fixtures.json` (never generated, or generated on a machine without the
|
||||
//! declared faces) is reported and this binary exits non-zero rather than
|
||||
//! writing a partial or empty file — pin 13's ordering requires the oracle to
|
||||
//! exist and be reviewed before a candidate consumes it, so silently writing
|
||||
//! nothing would be worse than a loud failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let textkit_dir = manifest_dir
|
||||
.parent()
|
||||
.expect("round2-a11y-oracle has a parent directory")
|
||||
.join("round2-textkit");
|
||||
let fixtures_path = textkit_dir.join("fixtures.json");
|
||||
|
||||
let fixtures = round2_textkit::output::load_fixtures(&fixtures_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}: {e} — run `cargo run -p round2-textkit --bin generate` first",
|
||||
fixtures_path.display()
|
||||
)
|
||||
});
|
||||
|
||||
let expectations = round2_a11y_oracle::build_expectations_file(&fixtures);
|
||||
|
||||
let out_path = manifest_dir.join("a11y_expectations.json");
|
||||
let json = serde_json::to_string_pretty(&expectations)
|
||||
.expect("ExpectationsFile is always serializable");
|
||||
std::fs::write(&out_path, format!("{json}\n"))
|
||||
.unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
|
||||
|
||||
println!(
|
||||
"wrote {} ({} fixtures, platform {})",
|
||||
out_path.display(),
|
||||
expectations.fixtures.len(),
|
||||
expectations.platform
|
||||
);
|
||||
for f in &expectations.fixtures {
|
||||
println!(
|
||||
" {}: {} alternative form(s), visual_order_name {}",
|
||||
f.fixture_id,
|
||||
f.alternative_forms.len(),
|
||||
if f.visual_order_name.is_some() {
|
||||
"present"
|
||||
} else {
|
||||
"omitted (identical to expected_name)"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
//! Findings routed back to `spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md`,
|
||||
//! discovered while building this crate.
|
||||
//!
|
||||
//! Recorded here — not only in a review conversation — so whoever next
|
||||
//! revises the recipe finds it in the artifact rather than a chat transcript,
|
||||
//! the same discipline `round2_textkit::findings` uses for the findings it
|
||||
//! routes back to the W3 `.tex` amendment. These are findings *about the
|
||||
//! recipe's own prose*, not about `epiphany-layout-ir`, so they are recorded
|
||||
//! here rather than in `round2_textkit::findings`.
|
||||
//!
|
||||
//! This crate does not edit `ROUND2_TEXT_RECIPE.md` — that document belongs
|
||||
//! to the coordinator's commit and a separate review thread.
|
||||
|
||||
/// Recipe §8.1 claims: "a tree assembled by walking the visual runs left to
|
||||
/// right produces a different string, and only there \[F-D\]."
|
||||
///
|
||||
/// That is false under this crate's own generated data. F-B diverges the
|
||||
/// same way: its logical name is `"Coro אבג"` and
|
||||
/// `round2_a11y_oracle::visual_order_form` produces `"Coro גבא"` for it — a
|
||||
/// real, non-empty `visual_order_name` entry in `a11y_expectations.json`,
|
||||
/// exactly the same mechanism F-D exercises.
|
||||
///
|
||||
/// The general shape, not just the one counterexample: under
|
||||
/// [`crate::visual_order_form`]'s model (concatenate segments in stored
|
||||
/// order, reversing an `Rtl` segment's own text by grapheme), **any
|
||||
/// non-palindromic** RTL run of two or more graphemes diverges under a
|
||||
/// visual-order walk, because reversing a grapheme sequence is a no-op
|
||||
/// exactly when that sequence is a palindrome (a repeated single grapheme,
|
||||
/// e.g. `"aa"`, is a palindrome and is therefore **not** a counterexample to
|
||||
/// this narrower claim — it was a counterexample to the unqualified "any RTL
|
||||
/// run of two or more graphemes" claim an earlier revision of this finding
|
||||
/// made). F-D is not the *unique* case; it is the case where the RTL run is
|
||||
/// *interior* to the string (`"Allegro "` ... `"אבג"` ... `" con brio"`)
|
||||
/// rather than trailing (`"Coro "` ... `"אבג"`), which is why F-D's
|
||||
/// divergence reads as obviously wrong to a human glancing at it and F-B's —
|
||||
/// a suffix silently reversed — reads as more easily missed. That
|
||||
/// readability difference is a real reason to prefer F-D as the check-5
|
||||
/// accessibility exemplar; it is not a reason to claim F-B does not exhibit
|
||||
/// the same property.
|
||||
///
|
||||
/// The recipe should either say "F-D and F-B" at §8.1, or drop the
|
||||
/// uniqueness claim and state the actual distinguishing property: F-D is the
|
||||
/// fixture where the RTL run is interior, not the fixture where the
|
||||
/// divergence uniquely occurs.
|
||||
///
|
||||
/// ## The same stale claim is also baked into a digest-bound artifact
|
||||
///
|
||||
/// The recipe's prose is not the only place this claim lives.
|
||||
/// `round2-textkit/src/a11y.rs`'s `note_for("F-D")` reads: "the concatenation
|
||||
/// is logical-order, so a tree built by walking the visual runs left to
|
||||
/// right fails here and only here" — the identical uniqueness claim, in
|
||||
/// code. That note is compiled into every generated `fixtures.json` as
|
||||
/// `fixtures[3].accessibility.note`, and `fixtures.json`'s own
|
||||
/// `EXPECTED_ARTIFACT_DIGEST_HEX` (`round2_textkit::output`) binds the whole
|
||||
/// serialized file, note text included, to `acc13c0d…` — a frozen,
|
||||
/// user-reviewed artifact (Packet 2A). Editing the note's wording to correct
|
||||
/// the claim would change that digest and break every consumer pinned to it,
|
||||
/// which is a strictly larger and differently-scoped change than this
|
||||
/// finding.
|
||||
///
|
||||
/// **This half of the finding is tracked, not fixed**, and is recorded
|
||||
/// explicitly so a later reader does not "helpfully" edit
|
||||
/// `round2-textkit/src/a11y.rs`'s F-D note on the strength of this finding
|
||||
/// alone and silently move `acc13c0d…` out from under Packet 2A. Fixing it
|
||||
/// is a decision for whoever owns that digest and that packet's re-freeze,
|
||||
/// not a drive-by edit from this crate.
|
||||
pub const RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D: &str = "ROUND2_TEXT_RECIPE.md §8.1 claims \
|
||||
visual-order-walk assembly produces a different string \"and only there [F-D]\". It does \
|
||||
not: F-B's visual_order_name (\"Coro גבא\") also differs from its expected_name (\"Coro \
|
||||
אבג\"), and under this crate's visual_order_form, any non-palindromic RTL run of two or more \
|
||||
graphemes diverges the same way (a repeated-grapheme run like \"aa\" is a palindrome and does \
|
||||
not diverge, which is why the claim is qualified). F-D is not unique in exhibiting the \
|
||||
divergence; it is the fixture where the RTL run is interior to the string rather than \
|
||||
trailing, which is why the divergence is more obviously wrong to a reader. The recipe should \
|
||||
say \"F-D and F-B\" or state the interior-run property instead of a uniqueness claim. The \
|
||||
identical stale claim is also baked into round2-textkit/src/a11y.rs's note_for(\"F-D\") \
|
||||
(\"fails here and only here\"), which is compiled into fixtures.json and covered by its \
|
||||
frozen EXPECTED_ARTIFACT_DIGEST_HEX (acc13c0d...) — that half is TRACKED, NOT FIXED here, \
|
||||
because correcting it would move the digest and break Packet 2A; do not edit that note on \
|
||||
the strength of this finding alone.";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The finding must actually name both fixtures — a mutation that
|
||||
/// silently dropped one of them from the constant would still compile
|
||||
/// and would still "record a finding," just not the right one.
|
||||
#[test]
|
||||
fn the_finding_names_both_f_d_and_f_b() {
|
||||
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("F-D"));
|
||||
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("F-B"));
|
||||
}
|
||||
|
||||
/// The universal claim must be qualified — an unqualified "any RTL run
|
||||
/// of two or more graphemes diverges" is false (a palindromic run does
|
||||
/// not), which is exactly the over-claim B3 asked to be narrowed.
|
||||
#[test]
|
||||
fn the_finding_qualifies_the_claim_as_non_palindromic() {
|
||||
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("non-palindromic"));
|
||||
}
|
||||
|
||||
/// The digest-bound, tracked-not-fixed half of the finding must name the
|
||||
/// actual frozen digest prefix and say explicitly that it is not fixed
|
||||
/// here — a reader skimming only for "is this fixed" must not be able to
|
||||
/// mistake "recorded" for "corrected."
|
||||
#[test]
|
||||
fn the_finding_names_the_frozen_digest_and_says_tracked_not_fixed() {
|
||||
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("acc13c0d"));
|
||||
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("TRACKED, NOT FIXED"));
|
||||
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("a11y.rs"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,971 @@
|
|||
//! Packet 2B-A: the check-5 accessibility oracle's comparison-data half.
|
||||
//!
|
||||
//! `spec/CONTRACT_EDITOR_T4_SPIKE.md` pin 13 requires this oracle to be
|
||||
//! committed and reviewed **before any candidate builds a tree against it** —
|
||||
//! if a candidate wrote the verifier, the oracle would be shaped by that
|
||||
//! candidate's tree, which is exactly what pin 13 forbids. This crate is
|
||||
//! therefore a separate, candidate-neutral packet from either Round 2
|
||||
//! candidate: it reads the already-committed, already-validated
|
||||
//! `round2-textkit/fixtures.json` (`ROUND2_TEXT_RECIPE.md` §8;
|
||||
//! `round2_textkit::a11y`) and derives every byte string a live AT-SPI
|
||||
//! readback would need to compare against, so the verifier's classification
|
||||
//! is a comparison against precommitted data, never a heuristic guess about
|
||||
//! what a wrong name "looks like" (`ROUND2_TEXT_RECIPE.md` §8.1).
|
||||
//!
|
||||
//! ## What is derived, and what is restated
|
||||
//!
|
||||
//! `expected_name` / `expected_name_hex` / `expected_name_byte_len` and the
|
||||
//! at-spi2 accepted/prohibited role sets are **restated** — they already
|
||||
//! exist verbatim on each fixture's `SpikeAccessibilityExpectation`
|
||||
//! (`round2_textkit::a11y`), computed and validated there. This crate does
|
||||
//! not recompute them from `resolved.text` a second time; it reads the
|
||||
//! oracle's own already-validated fields, the same discipline
|
||||
//! `round2-textkit::output::FixtureFile::validate` uses for everything else.
|
||||
//!
|
||||
//! `alternative_forms` and `visual_order_name` are **derived** here, from
|
||||
//! `SpikeResolvedText`'s own segment and cluster data — never hard-coded to a
|
||||
//! particular codepoint or glyph id, so the derivation is reproducible from
|
||||
//! `fixtures.json` alone and does not silently drift from it:
|
||||
//!
|
||||
//! * **`name-normalized`** — the NFC normalization of `text`
|
||||
//! (`unicode-normalization`). Differs only for F-E (recipe §2: F-E is
|
||||
//! deliberately NFD).
|
||||
//! * **`name-drops-unresolved-codepoints`** — `text` with every segment whose
|
||||
//! `face` is `None` removed (`SpikeShapedSegment::face`, `W3-F3`). Differs
|
||||
//! only for F-C, whose U+0627 is covered by neither declared face.
|
||||
//! * **`name-is-shaped-glyphs`** — two independently derived forms, both
|
||||
//! modelling "the tree exposes what was drawn rather than what was said":
|
||||
//! a cluster-collapse form ([`shaped_glyphs_form`]) and, where derivable, a
|
||||
//! standard-ligature presentation-form substitution
|
||||
//! ([`shaped_glyphs_presentation_form`]). F-A's `ff`/`fi` ligatures are the
|
||||
//! case this fixture set exercises for both. See each function's doc
|
||||
//! comment for exactly what it does and does not derive from the fixture
|
||||
//! record.
|
||||
//! * **`visual_order_name`** — concatenates every segment's source text in
|
||||
//! the *stored* (logical) segment order, but reverses an `Rtl` segment's
|
||||
//! own text by extended grapheme cluster before appending it. This
|
||||
//! reproduces "a tree assembled by walking the visual runs left to right"
|
||||
//! (recipe §8.1) for every fixture in this set, all of which nest at most
|
||||
//! one `Rtl` run inside an `Ltr` base paragraph (recipe §4: base level 0,
|
||||
//! Hebrew segments at level 1) — a single odd-level run does not change
|
||||
//! the *order* of the run sequence under UAX#9 reordering, only the
|
||||
//! *internal* order of that run's own text. **This is not a general bidi
|
||||
//! run-reordering implementation**; it is correct for this fixture set and
|
||||
//! would need revisiting for a fixture with nested embedding levels beyond
|
||||
//! 0/1, which none of F-A..F-E have (measured, recipe §4). It also
|
||||
//! diverges from the recipe's own claim about which fixture this
|
||||
//! is unique to — see [`findings::RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D`].
|
||||
//!
|
||||
//! ## Fail-closed on a colliding classification (O1)
|
||||
//!
|
||||
//! An earlier version of this crate could emit the *same string* under two
|
||||
//! different `PROHIBITED_OUTCOMES` names for one fixture — F-C's unresolved
|
||||
//! cluster produced `"Coro "` under both `name-drops-unresolved-codepoints`
|
||||
//! and `name-is-shaped-glyphs`, because "drop the unresolved codepoint" and
|
||||
//! "collapse a zero-glyph cluster" were, for that cluster, the same
|
||||
//! operation. Which classification a verifier reported was then an artifact
|
||||
//! of `BTreeMap` iteration (alphabetical) order, not a property of the
|
||||
//! observation — the oracle was returning two different confident answers
|
||||
//! for one input. That is fixed two ways, and both are load-bearing:
|
||||
//!
|
||||
//! 1. [`shaped_glyphs_form`] no longer collapses a *fully* unresolved
|
||||
//! cluster (zero glyphs) — collapsing to "what was drawn" presumes
|
||||
//! something was drawn; a wholly unresolved cluster's only legitimate
|
||||
//! classification is `name-drops-unresolved-codepoints`. This is enough
|
||||
//! to make F-C's two forms genuinely equal to `expected_name` again (no
|
||||
//! codepoint was shape-collapsed), so `name-is-shaped-glyphs` is correctly
|
||||
//! omitted for F-C by the ordinary omit-if-identical rule.
|
||||
//! 2. [`build_expectation`] additionally **refuses to build** a fixture whose
|
||||
//! candidate forms collide across two different outcome names, panicking
|
||||
//! and naming the fixture and both outcomes — a generation-time backstop
|
||||
//! for any future fixture or derivation that reintroduces the same
|
||||
//! ambiguity, independent of whether fix 1 above happens to prevent it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use round2_textkit::a11y::PROHIBITED_OUTCOMES;
|
||||
use round2_textkit::output::{FixtureFile, FixtureRecord};
|
||||
use round2_textkit::types::{SpikeResolvedText, SpikeTextDirection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub mod findings;
|
||||
|
||||
/// The one platform row this oracle emits: this machine's live AT client is
|
||||
/// AT-SPI2 (recipe §8.2, round0-evidence's precedent). Candidates targeting
|
||||
/// another platform stay covered by the recipe's own table; encoding all five
|
||||
/// rows here would not make them checkable on a machine that cannot reach
|
||||
/// them.
|
||||
pub const PLATFORM: &str = "at-spi2";
|
||||
|
||||
/// One fixture's precommitted check-5 comparison data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FixtureExpectation {
|
||||
pub fixture_id: String,
|
||||
/// The exact source string, restated from
|
||||
/// `SpikeAccessibilityExpectation::name` (`round2_textkit::a11y`), not
|
||||
/// recomputed — see the module doc comment.
|
||||
pub expected_name: String,
|
||||
pub expected_name_hex: String,
|
||||
pub expected_name_byte_len: usize,
|
||||
/// This machine's platform row (`at-spi2`) of recipe §8.2's accepted-role
|
||||
/// table, restated from the fixture's own
|
||||
/// `SpikeAccessibilityExpectation::accepted_roles`.
|
||||
pub accepted_roles: Vec<String>,
|
||||
pub prohibited_roles: Vec<String>,
|
||||
/// D1: the per-segment source texts, in the segments' own stored
|
||||
/// (logical, ascending-source) order — e.g. F-C's `["Coro ", "ا"]`,
|
||||
/// F-D's `["Allegro ", "אבג", " con brio"]`. §8.1 explicitly permits "a
|
||||
/// tree that exposes one text node per direction run," and §8.3
|
||||
/// requires an unresolved codepoint (F-C's `ا`) to appear in the name
|
||||
/// regardless of whether it drew ink — but a lone unresolved segment can
|
||||
/// be a single character, which falls below any reasonable
|
||||
/// coincidence-guarded length floor a verifier-side substring rule would
|
||||
/// use. This field lets the verifier match a node's name against a
|
||||
/// precommitted exact component instead of guessing from length alone —
|
||||
/// the same "precommitted comparison data, not a heuristic" discipline
|
||||
/// this whole struct already uses everywhere else. `"".join(source_atoms)
|
||||
/// == expected_name` always holds (see `source_atoms`'s own doc comment
|
||||
/// and its test coverage).
|
||||
pub source_atoms: Vec<String>,
|
||||
/// Keyed by a `PROHIBITED_OUTCOMES` name; every precommitted string that
|
||||
/// classification would produce for this fixture, matched if the
|
||||
/// observed name equals **any** entry in the list (O2: a single outcome
|
||||
/// can have more than one plausible precommitted rendering — e.g.
|
||||
/// `name-is-shaped-glyphs` carries both a cluster-collapse form and a
|
||||
/// standard-ligature presentation-form substitution for F-A). An outcome
|
||||
/// absent from this map produced no form distinguishable from
|
||||
/// `expected_name` for this fixture (see the module doc comment) and so
|
||||
/// cannot classify anything. The same string never appears under two
|
||||
/// different outcome keys for one fixture — [`build_expectation`]
|
||||
/// refuses to build a file where it would (O1).
|
||||
pub alternative_forms: BTreeMap<String, Vec<String>>,
|
||||
/// The concatenation a tree assembled by walking visual runs left to
|
||||
/// right would produce, only when it differs from `expected_name` (§8.1).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visual_order_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visual_order_name_hex: Option<String>,
|
||||
}
|
||||
|
||||
/// The complete artifact `a11y_expectations.json` carries.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExpectationsFile {
|
||||
pub contract: String,
|
||||
pub recipe: String,
|
||||
pub platform: String,
|
||||
/// Traceability to the exact `fixtures.json` this file was derived from
|
||||
/// (`round2_textkit::output::artifact_digest`) — so a verifier run
|
||||
/// against a stale copy of either file is a detectable mismatch rather
|
||||
/// than a silent one, the same discipline `fixtures.json` itself uses for
|
||||
/// the two declared face hashes.
|
||||
pub source_fixtures_digest: String,
|
||||
pub fixtures: Vec<FixtureExpectation>,
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// NFC normalization of `text`. §8.3's `name-normalized`: F-E's NFD source is
|
||||
/// the only fixture where this differs from `text`.
|
||||
pub fn nfc_form(text: &str) -> String {
|
||||
text.nfc().collect()
|
||||
}
|
||||
|
||||
/// `text` with every segment whose `face` is `None` removed, in the
|
||||
/// segments' own stored (logical, ascending-source) order. §8.3's
|
||||
/// `name-drops-unresolved-codepoints`: F-C's U+0627 (covered by neither
|
||||
/// declared face) is the only case in this fixture set.
|
||||
///
|
||||
/// Derived entirely from `resolved.segments[*].face` and `.source` — never
|
||||
/// from a hard-coded codepoint, so a future fixture with a different
|
||||
/// uncovered span is handled the same way without a code change.
|
||||
pub fn drop_unresolved_codepoints_form(resolved: &SpikeResolvedText) -> String {
|
||||
let mut out = String::new();
|
||||
for seg in &resolved.segments {
|
||||
if seg.face.is_none() {
|
||||
continue;
|
||||
}
|
||||
let start = seg.source.start as usize;
|
||||
let end = seg.source.end as usize;
|
||||
out.push_str(&resolved.text[start..end]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The per-segment source texts, in the segments' own stored (logical,
|
||||
/// ascending-source) order (D1). Every segment contributes an atom,
|
||||
/// resolved or not — F-C's unresolved `ا` is included exactly like any
|
||||
/// other segment, because the property this field exists to let a verifier
|
||||
/// check ("does some node's name match one exact source component") is
|
||||
/// just as true for an unresolved segment as a resolved one, and singling
|
||||
/// it out would be exactly the kind of per-fixture special case this crate
|
||||
/// avoids elsewhere.
|
||||
///
|
||||
/// Derived entirely from `resolved.segments[*].source` — never from a
|
||||
/// hard-coded codepoint or fixture id, so a future fixture's own segment
|
||||
/// boundaries are picked up the same way without a code change.
|
||||
/// `source_atoms(resolved).concat() == resolved.text` always holds, because
|
||||
/// W3 invariant 2 (asserted elsewhere in this pipeline) requires segment
|
||||
/// source ranges to partition the whole string totally, in logical order.
|
||||
pub fn source_atoms(resolved: &SpikeResolvedText) -> Vec<String> {
|
||||
resolved
|
||||
.segments
|
||||
.iter()
|
||||
.map(|seg| {
|
||||
let start = seg.source.start as usize;
|
||||
let end = seg.source.end as usize;
|
||||
resolved.text[start..end].to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The run's text as a tree exposing "what was drawn" rather than "what was
|
||||
/// said" would read it, by collapsing each cluster to as many leading
|
||||
/// graphemes as it has glyphs. §8.3's `name-is-shaped-glyphs`: F-A's `ff`/`fi`
|
||||
/// ligatures are the case this fixture set exercises.
|
||||
///
|
||||
/// Walks `resolved.clusters.clusters` in its own documented ascending-source
|
||||
/// order (`SpikeClusterMap`'s doc comment). For a cluster whose glyph count is
|
||||
/// **strictly between zero and** its `grapheme_count` — a genuine ligature
|
||||
/// drew fewer, but more than zero, glyphs than there are graphemes to
|
||||
/// report — only that many leading graphemes of the cluster's own source text
|
||||
/// are kept. A cluster with `glyphs == graphemes` (ordinary) or `glyphs == 0`
|
||||
/// (**wholly unresolved** — O1: nothing was drawn, so there is no partial
|
||||
/// "what was drawn" to report; that is `name-drops-unresolved-codepoints`'s
|
||||
/// classification, not this one) contributes its whole source text unchanged.
|
||||
/// Nothing here is specific to `ff`/`fi`: the rule is "one reportable unit per
|
||||
/// glyph, when at least one glyph exists," derived purely from each cluster's
|
||||
/// own `glyph_indices.len()` and `grapheme_count`.
|
||||
pub fn shaped_glyphs_form(resolved: &SpikeResolvedText) -> String {
|
||||
let mut out = String::new();
|
||||
for cluster in &resolved.clusters.clusters {
|
||||
let start = cluster.source.start as usize;
|
||||
let end = cluster.source.end as usize;
|
||||
let chunk = &resolved.text[start..end];
|
||||
let glyph_count = cluster.glyph_indices.len() as u32;
|
||||
if glyph_count > 0 && glyph_count < cluster.grapheme_count {
|
||||
let kept: String = chunk.graphemes(true).take(glyph_count as usize).collect();
|
||||
out.push_str(&kept);
|
||||
} else {
|
||||
out.push_str(chunk);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The standard Unicode Latin ligature presentation forms (Alphabetic
|
||||
/// Presentation Forms block, U+FB00-U+FB06) that a shaper's default `liga`
|
||||
/// feature can produce. This table is **fixed Unicode data, not derived from
|
||||
/// `fixtures.json`** — this crate deliberately carries no font/cmap
|
||||
/// dependency (see the crate doc comment on why: it never touches a live
|
||||
/// tree, and adding one here would be the wrong layer for it), so there is no
|
||||
/// way to derive "this glyph id denotes U+FB00" from the fixture record
|
||||
/// alone. What **is** derived from the fixture, for every entry
|
||||
/// [`shaped_glyphs_presentation_form`] produces, is *which* clusters this
|
||||
/// table applies to (the same glyph-count-vs-grapheme-count ligature
|
||||
/// detection [`shaped_glyphs_form`] uses) and *what source text* each one
|
||||
/// spans; the table is only ever consulted as a lookup keyed by that
|
||||
/// already-derived source text, never used to invent a cluster boundary of
|
||||
/// its own.
|
||||
const LATIN_LIGATURE_PRESENTATION_FORMS: &[(&str, char)] = &[
|
||||
("ff", '\u{FB00}'),
|
||||
("fi", '\u{FB01}'),
|
||||
("fl", '\u{FB02}'),
|
||||
("ffi", '\u{FB03}'),
|
||||
("ffl", '\u{FB04}'),
|
||||
("st", '\u{FB06}'),
|
||||
];
|
||||
|
||||
/// A second, independently plausible rendering of "the tree exposes what was
|
||||
/// drawn" (§8.3's `name-is-shaped-glyphs`): a tree that reverse-mapped glyph
|
||||
/// ids through a cmap would most plausibly emit the *standard ligature
|
||||
/// presentation-form codepoint* (e.g. U+FB00 for `ff`) rather than
|
||||
/// [`shaped_glyphs_form`]'s truncate-to-glyph-count text. Returns `None` if
|
||||
/// this fixture has no ligature cluster, **or** if it has one whose source
|
||||
/// text is not in [`LATIN_LIGATURE_PRESENTATION_FORMS`] — this function never
|
||||
/// guesses a codepoint it cannot look up.
|
||||
pub fn shaped_glyphs_presentation_form(resolved: &SpikeResolvedText) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut substituted_any = false;
|
||||
for cluster in &resolved.clusters.clusters {
|
||||
let start = cluster.source.start as usize;
|
||||
let end = cluster.source.end as usize;
|
||||
let chunk = &resolved.text[start..end];
|
||||
let glyph_count = cluster.glyph_indices.len() as u32;
|
||||
let is_ligature = glyph_count > 0 && glyph_count < cluster.grapheme_count;
|
||||
if is_ligature {
|
||||
match LATIN_LIGATURE_PRESENTATION_FORMS
|
||||
.iter()
|
||||
.find(|(seq, _)| *seq == chunk)
|
||||
{
|
||||
Some((_, presentation_char)) => {
|
||||
out.push(*presentation_char);
|
||||
substituted_any = true;
|
||||
}
|
||||
// A ligature cluster whose source text has no known
|
||||
// presentation-form codepoint: this function cannot honestly
|
||||
// produce a full-string answer, so it produces none at all
|
||||
// rather than a partially-substituted guess.
|
||||
None => return None,
|
||||
}
|
||||
} else {
|
||||
out.push_str(chunk);
|
||||
}
|
||||
}
|
||||
substituted_any.then_some(out)
|
||||
}
|
||||
|
||||
/// The concatenation a tree assembled by walking the run's visual runs left
|
||||
/// to right would produce (§8.1). See the module doc comment for exactly
|
||||
/// what this does and does not model.
|
||||
pub fn visual_order_form(resolved: &SpikeResolvedText) -> String {
|
||||
let mut out = String::new();
|
||||
for seg in &resolved.segments {
|
||||
let start = seg.source.start as usize;
|
||||
let end = seg.source.end as usize;
|
||||
let chunk = &resolved.text[start..end];
|
||||
match seg.direction {
|
||||
SpikeTextDirection::Rtl => {
|
||||
let reversed: String = chunk.graphemes(true).rev().collect();
|
||||
out.push_str(&reversed);
|
||||
}
|
||||
SpikeTextDirection::Ltr => out.push_str(chunk),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Groups a fixture's candidate `(outcome, form)` pairs into
|
||||
/// `alternative_forms`, in three steps:
|
||||
///
|
||||
/// 1. drop any candidate whose form is byte-identical to `expected_name` (it
|
||||
/// cannot classify anything — see the module doc comment);
|
||||
/// 2. **refuse** (panic, naming `fixture_id` and both outcomes) if the same
|
||||
/// remaining form string is produced by two *different* outcome names —
|
||||
/// O1's fail-closed backstop, independent of whichever derivation bug did
|
||||
/// or did not cause it;
|
||||
/// 3. otherwise group by outcome, deduplicating repeated identical forms
|
||||
/// within one outcome's own list (the same classification derived twice is
|
||||
/// not a collision), and drop any outcome left with an empty list.
|
||||
///
|
||||
/// Kept as its own function, separate from [`build_expectation`], so it has a
|
||||
/// unit test that can hand-construct a collision without needing a real
|
||||
/// `FixtureRecord` to provoke one.
|
||||
fn group_alternative_forms(
|
||||
fixture_id: &str,
|
||||
expected_name: &str,
|
||||
candidates: Vec<(&'static str, String)>,
|
||||
) -> BTreeMap<String, Vec<String>> {
|
||||
let mut owner_of: BTreeMap<String, &'static str> = BTreeMap::new();
|
||||
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
|
||||
for (outcome, form) in candidates {
|
||||
debug_assert!(
|
||||
PROHIBITED_OUTCOMES.contains(&outcome),
|
||||
"{outcome} must be one of round2_textkit::a11y::PROHIBITED_OUTCOMES"
|
||||
);
|
||||
if form == expected_name {
|
||||
continue;
|
||||
}
|
||||
match owner_of.get(&form) {
|
||||
Some(&existing_outcome) if existing_outcome != outcome => {
|
||||
panic!(
|
||||
"{fixture_id}: alternative forms {existing_outcome:?} and {outcome:?} both \
|
||||
produce {form:?} — an oracle that returns two different classifications for \
|
||||
the same observed string must fail closed, not pick one by BTreeMap \
|
||||
iteration order (O1). Fix the derivation so the two outcomes do not collide, \
|
||||
or establish that only one of them legitimately applies to this fixture."
|
||||
);
|
||||
}
|
||||
Some(_) => {
|
||||
// Same outcome producing an identical form a second time
|
||||
// (e.g. two independent derivations that happen to agree) —
|
||||
// not a collision, just redundant; skip the duplicate.
|
||||
}
|
||||
None => {
|
||||
owner_of.insert(form.clone(), outcome);
|
||||
grouped.entry(outcome.to_string()).or_default().push(form);
|
||||
}
|
||||
}
|
||||
}
|
||||
grouped
|
||||
}
|
||||
|
||||
/// Builds one fixture's [`FixtureExpectation`] from its already-validated
|
||||
/// `FixtureRecord`.
|
||||
///
|
||||
/// `expected_name` and the role sets are restated from
|
||||
/// `record.accessibility`, not recomputed from `record.resolved.text` — that
|
||||
/// field was already checked against the recipe §2 literal by
|
||||
/// `FixtureFile::validate` (via `load_fixtures`) before this function ever
|
||||
/// runs, so re-deriving it here would be a second, redundant source of
|
||||
/// truth rather than a check.
|
||||
///
|
||||
/// Panics (via [`group_alternative_forms`]) if two different outcome names
|
||||
/// would classify the same observed string for this fixture (O1).
|
||||
pub fn build_expectation(record: &FixtureRecord) -> FixtureExpectation {
|
||||
let a = &record.accessibility;
|
||||
let resolved = &record.resolved;
|
||||
|
||||
let accepted_roles = a
|
||||
.accepted_roles
|
||||
.iter()
|
||||
.find(|m| m.platform == PLATFORM)
|
||||
.unwrap_or_else(|| panic!("{}: no {PLATFORM} row in accepted_roles", record.id))
|
||||
.tokens
|
||||
.clone();
|
||||
let prohibited_roles = a
|
||||
.prohibited_roles
|
||||
.iter()
|
||||
.find(|m| m.platform == PLATFORM)
|
||||
.unwrap_or_else(|| panic!("{}: no {PLATFORM} row in prohibited_roles", record.id))
|
||||
.tokens
|
||||
.clone();
|
||||
|
||||
let mut candidates: Vec<(&'static str, String)> = vec![
|
||||
("name-normalized", nfc_form(&a.name)),
|
||||
(
|
||||
"name-drops-unresolved-codepoints",
|
||||
drop_unresolved_codepoints_form(resolved),
|
||||
),
|
||||
("name-is-shaped-glyphs", shaped_glyphs_form(resolved)),
|
||||
];
|
||||
if let Some(presentation) = shaped_glyphs_presentation_form(resolved) {
|
||||
candidates.push(("name-is-shaped-glyphs", presentation));
|
||||
}
|
||||
let alternative_forms = group_alternative_forms(&record.id, &a.name, candidates);
|
||||
|
||||
let visual = visual_order_form(resolved);
|
||||
let (visual_order_name, visual_order_name_hex) = if visual != a.name {
|
||||
let hex = hex_lower(visual.as_bytes());
|
||||
(Some(visual), Some(hex))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
FixtureExpectation {
|
||||
fixture_id: record.id.clone(),
|
||||
expected_name: a.name.clone(),
|
||||
expected_name_hex: a.name_bytes_hex.clone(),
|
||||
expected_name_byte_len: a.name_byte_len,
|
||||
accepted_roles,
|
||||
prohibited_roles,
|
||||
source_atoms: source_atoms(resolved),
|
||||
alternative_forms,
|
||||
visual_order_name,
|
||||
visual_order_name_hex,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the complete [`ExpectationsFile`] from an already-loaded, already-
|
||||
/// validated `FixtureFile` (`round2_textkit::output::load_fixtures`).
|
||||
pub fn build_expectations_file(file: &FixtureFile) -> ExpectationsFile {
|
||||
ExpectationsFile {
|
||||
contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13".to_string(),
|
||||
recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8".to_string(),
|
||||
platform: PLATFORM.to_string(),
|
||||
source_fixtures_digest: round2_textkit::output::artifact_digest(file),
|
||||
fixtures: file.fixtures.iter().map(build_expectation).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace};
|
||||
use round2_textkit::fixtures::{build_fixture, FIXTURES};
|
||||
use round2_textkit::output::build_fixture_file;
|
||||
|
||||
/// Builds a real `FixtureFile` end to end against the actual declared
|
||||
/// faces on this machine, the same path `round2-textkit`'s own tests and
|
||||
/// `bin/generate.rs` take. `None` (test skipped, not failed — pin 14) if
|
||||
/// either declared face is absent; on this development machine both are
|
||||
/// present.
|
||||
fn real_fixture_file() -> Option<FixtureFile> {
|
||||
let resolved = resolve_declared_chain();
|
||||
let mut loaded: Vec<LoadedFace> = 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_fixture_file().expect(
|
||||
"this test requires the two round2-textkit declared faces to be present on the \
|
||||
machine running it",
|
||||
)
|
||||
}
|
||||
|
||||
fn expectation_for<'a>(exp: &'a ExpectationsFile, id: &str) -> &'a FixtureExpectation {
|
||||
exp.fixtures
|
||||
.iter()
|
||||
.find(|f| f.fixture_id == id)
|
||||
.unwrap_or_else(|| panic!("no expectation built for {id}"))
|
||||
}
|
||||
|
||||
/// F-E's NFC form must differ from its (NFD) source text — the whole
|
||||
/// reason F-E exists (recipe §2). If `nfc_form` stopped normalizing, or
|
||||
/// F-E's source stopped being NFD, this fails.
|
||||
#[test]
|
||||
fn f_e_nfc_form_differs_from_its_text() {
|
||||
let file = require_file();
|
||||
let f_e = file.fixtures.iter().find(|f| f.id == "F-E").unwrap();
|
||||
let nfc = nfc_form(&f_e.resolved.text);
|
||||
assert_ne!(
|
||||
nfc, f_e.resolved.text,
|
||||
"F-E's NFC form must differ from its NFD source"
|
||||
);
|
||||
// And it must actually surface in alternative_forms, keyed correctly.
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-E");
|
||||
assert_eq!(
|
||||
e.alternative_forms.get("name-normalized"),
|
||||
Some(&vec![nfc]),
|
||||
"F-E must carry a name-normalized alternative form equal to its NFC"
|
||||
);
|
||||
}
|
||||
|
||||
/// F-C's dropped-codepoint form must be shorter than its source by
|
||||
/// *exactly* the byte span of its unresolved (face: None) segment — not
|
||||
/// merely shorter by some amount.
|
||||
#[test]
|
||||
fn f_c_dropped_codepoint_form_is_shorter_by_exactly_the_unresolved_span() {
|
||||
let file = require_file();
|
||||
let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap();
|
||||
let unresolved_span: usize = f_c
|
||||
.resolved
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|s| s.face.is_none())
|
||||
.map(|s| (s.source.end - s.source.start) as usize)
|
||||
.sum();
|
||||
assert!(
|
||||
unresolved_span > 0,
|
||||
"anchor: F-C must have at least one unresolved segment"
|
||||
);
|
||||
let dropped = drop_unresolved_codepoints_form(&f_c.resolved);
|
||||
assert_eq!(
|
||||
f_c.resolved.text.len() - dropped.len(),
|
||||
unresolved_span,
|
||||
"F-C's dropped-codepoint form must be shorter by exactly its unresolved span"
|
||||
);
|
||||
assert_ne!(dropped, f_c.resolved.text);
|
||||
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-C");
|
||||
assert_eq!(
|
||||
e.alternative_forms.get("name-drops-unresolved-codepoints"),
|
||||
Some(&vec![dropped])
|
||||
);
|
||||
}
|
||||
|
||||
/// O1's regression lock: F-C must carry exactly one alternative-outcome
|
||||
/// classification (`name-drops-unresolved-codepoints`), never a second,
|
||||
/// colliding `name-is-shaped-glyphs` entry for the same string. Before
|
||||
/// the O1 fix, [`shaped_glyphs_form`] collapsed F-C's wholly-unresolved
|
||||
/// cluster to nothing, which is byte-identical to the dropped-codepoint
|
||||
/// form — this pins that `name-is-shaped-glyphs` is now correctly absent
|
||||
/// for F-C (because it is byte-identical to `expected_name` once
|
||||
/// zero-glyph clusters are left untouched), not merely that it happens
|
||||
/// to agree with the other outcome.
|
||||
#[test]
|
||||
fn f_c_carries_no_shaped_glyphs_alternative_form() {
|
||||
let file = require_file();
|
||||
let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap();
|
||||
assert_eq!(
|
||||
shaped_glyphs_form(&f_c.resolved),
|
||||
f_c.resolved.text,
|
||||
"anchor: with the O1 fix, F-C's cluster-collapse form must equal its source text"
|
||||
);
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-C");
|
||||
assert!(
|
||||
!e.alternative_forms.contains_key("name-is-shaped-glyphs"),
|
||||
"F-C must not carry a name-is-shaped-glyphs alternative form: {:?}",
|
||||
e.alternative_forms
|
||||
);
|
||||
assert_eq!(e.alternative_forms.len(), 1);
|
||||
}
|
||||
|
||||
/// F-A's shaped-glyphs forms must differ from its source text — the
|
||||
/// `ff`/`fi` ligature case §8.3 names — and both the cluster-collapse
|
||||
/// form and the standard-ligature presentation-form substitution (O2)
|
||||
/// must be present in the list.
|
||||
#[test]
|
||||
fn f_a_shaped_glyphs_forms_differ_from_its_text() {
|
||||
let file = require_file();
|
||||
let f_a = file.fixtures.iter().find(|f| f.id == "F-A").unwrap();
|
||||
let has_ligature_cluster = f_a
|
||||
.resolved
|
||||
.clusters
|
||||
.clusters
|
||||
.iter()
|
||||
.any(|c| (c.glyph_indices.len() as u32) < c.grapheme_count);
|
||||
assert!(
|
||||
has_ligature_cluster,
|
||||
"anchor: F-A must have at least one cluster with fewer glyphs than graphemes"
|
||||
);
|
||||
let collapsed = shaped_glyphs_form(&f_a.resolved);
|
||||
assert_ne!(collapsed, f_a.resolved.text);
|
||||
let presentation = shaped_glyphs_presentation_form(&f_a.resolved).expect(
|
||||
"F-A's ligature clusters (ff, fi) are both in LATIN_LIGATURE_PRESENTATION_FORMS",
|
||||
);
|
||||
assert_ne!(presentation, f_a.resolved.text);
|
||||
assert_ne!(
|
||||
presentation, collapsed,
|
||||
"the two shaped-glyphs forms must be genuinely distinct renderings"
|
||||
);
|
||||
assert!(
|
||||
presentation.contains('\u{FB00}'),
|
||||
"F-A's presentation form must substitute U+FB00 for the ff ligature: {presentation:?}"
|
||||
);
|
||||
assert!(
|
||||
presentation.contains('\u{FB01}'),
|
||||
"F-A's presentation form must substitute U+FB01 for the fi ligature: {presentation:?}"
|
||||
);
|
||||
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-A");
|
||||
let forms = e
|
||||
.alternative_forms
|
||||
.get("name-is-shaped-glyphs")
|
||||
.expect("F-A must carry a name-is-shaped-glyphs entry");
|
||||
assert!(forms.contains(&collapsed), "{forms:?}");
|
||||
assert!(forms.contains(&presentation), "{forms:?}");
|
||||
assert_eq!(forms.len(), 2, "{forms:?}");
|
||||
}
|
||||
|
||||
/// F-D's visual-order form must differ from its logical text — the
|
||||
/// composition trap §8.1 names.
|
||||
#[test]
|
||||
fn f_d_visual_order_form_differs_from_its_logical_text() {
|
||||
let file = require_file();
|
||||
let f_d = file.fixtures.iter().find(|f| f.id == "F-D").unwrap();
|
||||
let has_rtl_segment = f_d
|
||||
.resolved
|
||||
.segments
|
||||
.iter()
|
||||
.any(|s| matches!(s.direction, SpikeTextDirection::Rtl));
|
||||
assert!(has_rtl_segment, "anchor: F-D must have an Rtl segment");
|
||||
let visual = visual_order_form(&f_d.resolved);
|
||||
assert_ne!(visual, f_d.resolved.text);
|
||||
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-D");
|
||||
assert_eq!(e.visual_order_name.as_ref(), Some(&visual));
|
||||
assert_eq!(
|
||||
e.visual_order_name_hex.as_deref(),
|
||||
Some(hex_lower(visual.as_bytes()).as_str())
|
||||
);
|
||||
}
|
||||
|
||||
/// D1: F-C's source atoms must be exactly its two segments, and the
|
||||
/// unresolved one must stand alone as a single character — the specific
|
||||
/// case a verifier-side length-2 substring rule cannot catch, and the
|
||||
/// whole reason this field exists.
|
||||
#[test]
|
||||
fn f_c_source_atoms_are_its_two_segments_one_of_them_single_character() {
|
||||
let file = require_file();
|
||||
let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap();
|
||||
assert_eq!(
|
||||
f_c.resolved.segments.len(),
|
||||
2,
|
||||
"anchor: F-C must have two segments"
|
||||
);
|
||||
let expected: Vec<String> = f_c
|
||||
.resolved
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| f_c.resolved.text[s.source.start as usize..s.source.end as usize].to_string())
|
||||
.collect();
|
||||
let atoms = source_atoms(&f_c.resolved);
|
||||
assert_eq!(atoms, expected);
|
||||
|
||||
let (_, unresolved_atom) = f_c
|
||||
.resolved
|
||||
.segments
|
||||
.iter()
|
||||
.zip(atoms.iter())
|
||||
.find(|(s, _)| s.face.is_none())
|
||||
.expect("anchor: F-C must have an unresolved segment");
|
||||
assert_eq!(
|
||||
unresolved_atom.chars().count(),
|
||||
1,
|
||||
"F-C's unresolved atom must be exactly one character: {unresolved_atom:?}"
|
||||
);
|
||||
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-C");
|
||||
assert_eq!(e.source_atoms, atoms);
|
||||
}
|
||||
|
||||
/// D1: F-D's source atoms must be exactly its three segments.
|
||||
#[test]
|
||||
fn f_d_source_atoms_are_its_three_segments() {
|
||||
let file = require_file();
|
||||
let f_d = file.fixtures.iter().find(|f| f.id == "F-D").unwrap();
|
||||
assert_eq!(
|
||||
f_d.resolved.segments.len(),
|
||||
3,
|
||||
"anchor: F-D must have three segments"
|
||||
);
|
||||
let expected: Vec<String> = f_d
|
||||
.resolved
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| f_d.resolved.text[s.source.start as usize..s.source.end as usize].to_string())
|
||||
.collect();
|
||||
let atoms = source_atoms(&f_d.resolved);
|
||||
assert_eq!(atoms, expected);
|
||||
|
||||
let exp = build_expectations_file(&file);
|
||||
let e = expectation_for(&exp, "F-D");
|
||||
assert_eq!(e.source_atoms, atoms);
|
||||
}
|
||||
|
||||
/// Every fixture's source atoms must concatenate back to its own
|
||||
/// `expected_name` — the general partition property `source_atoms`'s own
|
||||
/// doc comment claims, checked here on the real generated data rather
|
||||
/// than only asserted in prose.
|
||||
#[test]
|
||||
fn source_atoms_concatenate_to_expected_name_for_every_fixture() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
for f in &exp.fixtures {
|
||||
let joined: String = f.source_atoms.concat();
|
||||
assert_eq!(
|
||||
joined, f.expected_name,
|
||||
"{}: source_atoms must concatenate to expected_name",
|
||||
f.fixture_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative form byte-identical to `expected_name` must be omitted
|
||||
/// entirely, never present with a value equal to the expectation — the
|
||||
/// mutation this guards against is a verifier that reports a "match" as
|
||||
/// a diagnosed FAIL because a no-op entry happened to be present.
|
||||
#[test]
|
||||
fn identical_alternative_forms_are_omitted_not_recorded_as_equal() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
for f in &exp.fixtures {
|
||||
for (outcome, forms) in &f.alternative_forms {
|
||||
assert!(
|
||||
!forms.is_empty(),
|
||||
"{}: {outcome} must not be present with an empty list",
|
||||
f.fixture_id
|
||||
);
|
||||
for form in forms {
|
||||
assert_ne!(
|
||||
form, &f.expected_name,
|
||||
"{}: alternative form {outcome} must not be recorded when byte-identical \
|
||||
to expected_name",
|
||||
f.fixture_id
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(v) = &f.visual_order_name {
|
||||
assert_ne!(v, &f.expected_name, "{}: visual_order_name", f.fixture_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No fixture's `alternative_forms` may contain the same string under two
|
||||
/// different outcome keys (O1) — re-checked here on the real, generated
|
||||
/// data, in addition to [`group_alternative_forms_refuses_a_collision`]'s
|
||||
/// synthetic unit test.
|
||||
#[test]
|
||||
fn no_fixture_has_the_same_form_under_two_outcomes() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
for f in &exp.fixtures {
|
||||
let mut seen: BTreeMap<&String, &String> = BTreeMap::new();
|
||||
for (outcome, forms) in &f.alternative_forms {
|
||||
for form in forms {
|
||||
if let Some(existing) = seen.insert(form, outcome) {
|
||||
panic!(
|
||||
"{}: {form:?} appears under both {existing:?} and {outcome:?}",
|
||||
f.fixture_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every alternative-form key must be one of `PROHIBITED_OUTCOMES` — a
|
||||
/// typo'd or invented key would silently fail to classify anything the
|
||||
/// verifier actually checks for.
|
||||
#[test]
|
||||
fn every_alternative_form_key_is_a_prohibited_outcome() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
for f in &exp.fixtures {
|
||||
for outcome in f.alternative_forms.keys() {
|
||||
assert!(
|
||||
PROHIBITED_OUTCOMES.contains(&outcome.as_str()),
|
||||
"{}: {outcome:?} is not in PROHIBITED_OUTCOMES",
|
||||
f.fixture_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The at-spi2 role rows restated here must equal
|
||||
/// `round2_textkit::a11y`'s own at-spi2 row — this is the platform this
|
||||
/// machine's live AT-SPI2 client actually queries (recipe §8.2,
|
||||
/// round0-evidence's precedent).
|
||||
#[test]
|
||||
fn accepted_and_prohibited_roles_match_the_atspi2_row() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
let expected_accepted: Vec<String> = round2_textkit::a11y::ACCEPTED_ROLE_TABLE
|
||||
.iter()
|
||||
.find(|(p, _)| *p == PLATFORM)
|
||||
.unwrap()
|
||||
.1
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let expected_prohibited: Vec<String> = round2_textkit::a11y::PROHIBITED_ROLE_TABLE
|
||||
.iter()
|
||||
.find(|(p, _)| *p == PLATFORM)
|
||||
.unwrap()
|
||||
.1
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
for f in &exp.fixtures {
|
||||
assert_eq!(f.accepted_roles, expected_accepted);
|
||||
assert_eq!(f.prohibited_roles, expected_prohibited);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON round-trips without loss — the shape a consumer other than this
|
||||
/// crate (`a11y-verifier/verify.py`) will actually read.
|
||||
#[test]
|
||||
fn json_round_trip_preserves_the_expectations() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
let json = serde_json::to_string_pretty(&exp).unwrap();
|
||||
let reloaded: ExpectationsFile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(reloaded, exp);
|
||||
}
|
||||
|
||||
/// All five fixtures must be present, in order.
|
||||
#[test]
|
||||
fn all_five_fixtures_are_present_in_order() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
let ids: Vec<&str> = exp.fixtures.iter().map(|f| f.fixture_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["F-A", "F-B", "F-C", "F-D", "F-E"]);
|
||||
}
|
||||
|
||||
/// B2: `source_fixtures_digest` must equal
|
||||
/// `round2_textkit::output::expected_artifact_digest()` — the same
|
||||
/// literal `round2-textkit`'s own `bin/generate` prints and its
|
||||
/// `FixtureFile::validate` checks the *loaded* file against. This is the
|
||||
/// generation-time half of B2's staleness guard: if `fixtures.json` ever
|
||||
/// legitimately changes (a new frozen digest), this test catches that
|
||||
/// `round2-a11y-oracle` was not regenerated against it, at test time,
|
||||
/// before `a11y-verifier/verify.py`'s `--expect-source-digest` check
|
||||
/// would ever catch it live.
|
||||
#[test]
|
||||
fn source_fixtures_digest_matches_round2_textkit_expected_digest() {
|
||||
let file = require_file();
|
||||
let exp = build_expectations_file(&file);
|
||||
assert_eq!(
|
||||
exp.source_fixtures_digest,
|
||||
round2_textkit::output::expected_artifact_digest()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- O1: group_alternative_forms, exercised directly (no live fixture
|
||||
// data required, so the collision-refusal logic itself is under test
|
||||
// regardless of whether any current fixture happens to trigger it). ----
|
||||
|
||||
#[test]
|
||||
fn group_alternative_forms_refuses_a_collision() {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
group_alternative_forms(
|
||||
"F-TEST",
|
||||
"expected",
|
||||
vec![
|
||||
("name-normalized", "same-string".to_string()),
|
||||
("name-is-shaped-glyphs", "same-string".to_string()),
|
||||
],
|
||||
)
|
||||
});
|
||||
let err = result.expect_err("a collision between two outcomes must panic");
|
||||
let msg = err
|
||||
.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||
.expect("panic payload must be a string");
|
||||
assert!(msg.contains("F-TEST"), "{msg}");
|
||||
assert!(msg.contains("name-normalized"), "{msg}");
|
||||
assert!(msg.contains("name-is-shaped-glyphs"), "{msg}");
|
||||
}
|
||||
|
||||
/// Mutation guard: the same outcome producing the same form twice (e.g.
|
||||
/// two derivations that happen to agree) must NOT panic — only a
|
||||
/// cross-outcome collision is refused. Without this test, a mutation that
|
||||
/// made the collision check fire on any duplicate (not just a
|
||||
/// cross-outcome one) would still pass
|
||||
/// `group_alternative_forms_refuses_a_collision` above.
|
||||
#[test]
|
||||
fn group_alternative_forms_deduplicates_a_same_outcome_repeat_without_panicking() {
|
||||
let grouped = group_alternative_forms(
|
||||
"F-TEST",
|
||||
"expected",
|
||||
vec![
|
||||
("name-normalized", "same-string".to_string()),
|
||||
("name-normalized", "same-string".to_string()),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
grouped.get("name-normalized"),
|
||||
Some(&vec!["same-string".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_alternative_forms_omits_forms_identical_to_expected_name() {
|
||||
let grouped = group_alternative_forms(
|
||||
"F-TEST",
|
||||
"expected",
|
||||
vec![
|
||||
("name-normalized", "expected".to_string()),
|
||||
("name-is-shaped-glyphs", "different".to_string()),
|
||||
],
|
||||
);
|
||||
assert!(!grouped.contains_key("name-normalized"));
|
||||
assert_eq!(
|
||||
grouped.get("name-is-shaped-glyphs"),
|
||||
Some(&vec!["different".to_string()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "round2-candidatekit"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# Packet 2B-0 (ROUND2_TEXT_RECIPE.md, spec/CONTRACT_EDITOR_T4_SPIKE.md pins
|
||||
# 8, 9, 10, 13, 14): the ONLY code shared between the two Round 2 text
|
||||
# candidates (C1 = egui+lyon, C2 = vello). See src/lib.rs's crate doc
|
||||
# comment for the neutrality boundary this crate exists to hold — it loads
|
||||
# and validates Packet 2A's fixtures/probes/reference apparatus, and defines
|
||||
# the shared report shape and scoring rule both candidates are measured
|
||||
# against. It does NOT render, resolve hit tests, or build accessibility
|
||||
# trees.
|
||||
#
|
||||
# tests/dependency_deny_list.rs enforces the boundary by reading THIS file
|
||||
# at test time, not by convention: it fails if a rendering, windowing, GPU,
|
||||
# or platform-accessibility crate is ever added to [dependencies] below.
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
round2-textkit = { path = "../round2-textkit" }
|
||||
round2-diff = { path = "../round2-diff" }
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
//! Loads and validates the candidate-neutral apparatus Packet 2A built:
|
||||
//! fixtures, the hit-test probe table, and the per-fixture reference raster
|
||||
//! + regions. Every failure here names the specific file and what was wrong
|
||||
//! with it — see [`load_all`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use round2_diff::GlyphRegion;
|
||||
use round2_textkit::hittest::HitTestProbeFile;
|
||||
use round2_textkit::output::FixtureFile;
|
||||
|
||||
/// Pin 4's offscreen target, restated as a literal (the same discipline
|
||||
/// every other crate in this workspace uses: a loader checks a file against
|
||||
/// a stated constant, never trusts the file to agree with itself).
|
||||
pub const WIDTH: u32 = 1920;
|
||||
pub const HEIGHT: u32 = 1080;
|
||||
const EXPECTED_RGBA_LEN: usize = (WIDTH as usize) * (HEIGHT as usize) * 4;
|
||||
|
||||
/// The on-disk shape of one entry in `<id>.regions.json`
|
||||
/// (`round2-reference/output/`), matching the fields `round2-reference`'s
|
||||
/// own `RegionRecord` writes. Deserialized here rather than depended on
|
||||
/// directly, because `round2-reference` pulls in `round2-svgref`, which
|
||||
/// pulls in `resvg`/`usvg`/`tiny-skia` — exactly the rendering dependencies
|
||||
/// this crate's neutrality boundary forbids. The region *files* are neutral
|
||||
/// data; the crate that produced them is not.
|
||||
///
|
||||
/// **This is an implicit cross-crate schema with no shared type** —
|
||||
/// `round2-reference`'s own `RegionRecord` and this one are two
|
||||
/// independent hand-written structs that happen to agree on field names.
|
||||
/// `deny_unknown_fields` is what turns a future drift between them into a
|
||||
/// *named parse error at this crate's boundary* rather than a silently
|
||||
/// ignored field: without it, serde drops unknown fields by default, and a
|
||||
/// field `round2-reference` starts writing (or renames) would pass through
|
||||
/// here unnoticed.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RegionRecord {
|
||||
label: String,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
x1: u32,
|
||||
y1: u32,
|
||||
}
|
||||
|
||||
impl From<RegionRecord> for GlyphRegion {
|
||||
fn from(r: RegionRecord) -> Self {
|
||||
GlyphRegion {
|
||||
label: r.label,
|
||||
x0: r.x0,
|
||||
y0: r.y0,
|
||||
x1: r.x1,
|
||||
y1: r.y1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One fixture's reference apparatus: the rasterized reference image
|
||||
/// (already length-checked), its D4 regions (already checked non-empty),
|
||||
/// and the paths they were loaded from (traceability for a `FAIL`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReferenceFixture {
|
||||
pub fixture_id: String,
|
||||
pub reference_rgba: Vec<u8>,
|
||||
pub regions: Vec<GlyphRegion>,
|
||||
pub rgba_path: PathBuf,
|
||||
pub regions_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Every candidate-neutral input Packet 2A built, loaded and validated in
|
||||
/// one call ([`load_all`]).
|
||||
#[derive(Debug)]
|
||||
pub struct NeutralInputs {
|
||||
pub fixtures: FixtureFile,
|
||||
pub hittest_probes: HitTestProbeFile,
|
||||
/// Keyed by fixture id (`F-A`..`F-E`).
|
||||
pub reference: BTreeMap<String, ReferenceFixture>,
|
||||
}
|
||||
|
||||
/// Loads `fixtures.json`, `hittest_probes.json`, and every fixture's
|
||||
/// reference raster + regions, from the standard Packet 2A layout under
|
||||
/// `spike_root` (`round2-textkit/fixtures.json`,
|
||||
/// `round2-textkit/hittest_probes.json`,
|
||||
/// `round2-reference/output/<id>.rgba`,
|
||||
/// `round2-reference/output/<id>.regions.json`).
|
||||
///
|
||||
/// Every failure names the specific file and what was wrong with it:
|
||||
///
|
||||
/// - `fixtures.json` / `hittest_probes.json`: read/parse errors, or a
|
||||
/// [`round2_textkit::output::FixtureFile::validate`] /
|
||||
/// [`round2_textkit::hittest::HitTestProbeFile::validate`] failure
|
||||
/// (digest mismatch, probe-table drift, ...) — propagated verbatim; those
|
||||
/// loaders already name the path and the specific disagreement.
|
||||
/// - `<id>.rgba`: refused if its length is not exactly `1920 * 1080 * 4`
|
||||
/// bytes ([`WIDTH`] x [`HEIGHT`] x 4 RGBA8), naming the file and the
|
||||
/// actual length.
|
||||
/// - `<id>.regions.json`: refused if missing, unparsable, or **empty**.
|
||||
/// This crate refuses an empty region list itself, naming the file,
|
||||
/// rather than silently handing it to `round2_diff::diff` — which also
|
||||
/// refuses an empty list (`diff` panics on nothing, it returns an `Err`),
|
||||
/// but with a message that has no idea which file on disk was empty.
|
||||
pub fn load_all(spike_root: &Path) -> Result<NeutralInputs, String> {
|
||||
let fixtures_path = spike_root.join("round2-textkit/fixtures.json");
|
||||
let fixtures = round2_textkit::output::load_fixtures(&fixtures_path)?;
|
||||
|
||||
let hittest_path = spike_root.join("round2-textkit/hittest_probes.json");
|
||||
let hittest_probes = round2_textkit::hittest::load_hittest_probes(&hittest_path, &fixtures)?;
|
||||
|
||||
let mut reference = BTreeMap::new();
|
||||
for f in &fixtures.fixtures {
|
||||
let rgba_path = spike_root
|
||||
.join("round2-reference/output")
|
||||
.join(format!("{}.rgba", f.id));
|
||||
let rgba = std::fs::read(&rgba_path).map_err(|e| {
|
||||
format!(
|
||||
"{}: failed to read reference raster: {e}",
|
||||
rgba_path.display()
|
||||
)
|
||||
})?;
|
||||
if rgba.len() != EXPECTED_RGBA_LEN {
|
||||
return Err(format!(
|
||||
"{}: reference raster is {} bytes, expected exactly {EXPECTED_RGBA_LEN} \
|
||||
({WIDTH}x{HEIGHT} RGBA8) — a short or padded buffer cannot be sampled safely",
|
||||
rgba_path.display(),
|
||||
rgba.len()
|
||||
));
|
||||
}
|
||||
|
||||
let regions_path = spike_root
|
||||
.join("round2-reference/output")
|
||||
.join(format!("{}.regions.json", f.id));
|
||||
let regions_text = std::fs::read_to_string(®ions_path).map_err(|e| {
|
||||
format!(
|
||||
"{}: failed to read region file: {e}",
|
||||
regions_path.display()
|
||||
)
|
||||
})?;
|
||||
let records: Vec<RegionRecord> = serde_json::from_str(®ions_text).map_err(|e| {
|
||||
format!(
|
||||
"{}: failed to parse region file: {e}",
|
||||
regions_path.display()
|
||||
)
|
||||
})?;
|
||||
if records.is_empty() {
|
||||
return Err(format!(
|
||||
"{}: region list is empty — refusing here, before this could reach \
|
||||
round2_diff::diff (which also refuses an empty region list, but with a message \
|
||||
that does not name which file on disk was empty)",
|
||||
regions_path.display()
|
||||
));
|
||||
}
|
||||
let regions: Vec<GlyphRegion> = records.into_iter().map(GlyphRegion::from).collect();
|
||||
|
||||
reference.insert(
|
||||
f.id.clone(),
|
||||
ReferenceFixture {
|
||||
fixture_id: f.id.clone(),
|
||||
reference_rgba: rgba,
|
||||
regions,
|
||||
rgba_path,
|
||||
regions_path,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(NeutralInputs {
|
||||
fixtures,
|
||||
hittest_probes,
|
||||
reference,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The real spike workspace root: this crate's manifest directory is
|
||||
/// `spikes/editor-toolkit/round2-candidatekit`, one level below root.
|
||||
fn real_spike_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn read_real(rel: &str) -> Vec<u8> {
|
||||
std::fs::read(real_spike_root().join(rel))
|
||||
.unwrap_or_else(|e| panic!("failed to read real {rel}: {e}"))
|
||||
}
|
||||
|
||||
/// A fresh, uniquely named directory under the OS temp dir (never under
|
||||
/// the repo working tree, so these tests cannot leave stray files for
|
||||
/// `git status` to notice), laid out like a spike root's
|
||||
/// `round2-textkit/` + `round2-reference/output/` — enough for
|
||||
/// `load_all` to be pointed at it.
|
||||
fn scratch_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"round2-candidatekit-test-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("round2-textkit")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("round2-reference/output")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write(path: &Path, bytes: &[u8]) {
|
||||
std::fs::write(path, bytes)
|
||||
.unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
|
||||
}
|
||||
|
||||
/// Copies the real, committed, valid `fixtures.json` and
|
||||
/// `hittest_probes.json` into `dir` — the two files every scenario
|
||||
/// below needs unmutated so the failure under test is isolated to the
|
||||
/// one file each test actually breaks.
|
||||
fn seed_valid_fixtures_and_hittest(dir: &Path) {
|
||||
write(
|
||||
&dir.join("round2-textkit/fixtures.json"),
|
||||
&read_real("round2-textkit/fixtures.json"),
|
||||
);
|
||||
write(
|
||||
&dir.join("round2-textkit/hittest_probes.json"),
|
||||
&read_real("round2-textkit/hittest_probes.json"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_succeeds_against_the_real_committed_apparatus() {
|
||||
let inputs = load_all(&real_spike_root()).expect("real apparatus must load");
|
||||
assert_eq!(inputs.fixtures.fixtures.len(), 5);
|
||||
assert_eq!(inputs.reference.len(), 5);
|
||||
for id in ["F-A", "F-B", "F-C", "F-D", "F-E"] {
|
||||
assert!(inputs.reference.contains_key(id), "missing {id}");
|
||||
let rf = &inputs.reference[id];
|
||||
assert_eq!(rf.reference_rgba.len(), EXPECTED_RGBA_LEN);
|
||||
assert!(!rf.regions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Required kill: a `.rgba` of the wrong length is refused, naming the
|
||||
/// file.
|
||||
#[test]
|
||||
fn a_wrong_length_rgba_is_refused_and_the_file_is_named() {
|
||||
let dir = scratch_dir("wrong-length-rgba");
|
||||
seed_valid_fixtures_and_hittest(&dir);
|
||||
write(
|
||||
&dir.join("round2-reference/output/F-A.rgba"),
|
||||
&vec![0u8; 100],
|
||||
);
|
||||
let err = load_all(&dir).unwrap_err();
|
||||
assert!(err.contains("F-A.rgba"), "{err}");
|
||||
assert!(err.contains("100 bytes"), "{err}");
|
||||
assert!(err.contains("8294400"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Required kill: an empty region list is refused here — with a message
|
||||
/// naming this file — rather than silently reaching
|
||||
/// `round2_diff::diff`.
|
||||
#[test]
|
||||
fn an_empty_region_list_is_refused_before_it_could_reach_diff() {
|
||||
let dir = scratch_dir("empty-regions");
|
||||
seed_valid_fixtures_and_hittest(&dir);
|
||||
write(
|
||||
&dir.join("round2-reference/output/F-A.rgba"),
|
||||
&vec![0u8; EXPECTED_RGBA_LEN],
|
||||
);
|
||||
write(&dir.join("round2-reference/output/F-A.regions.json"), b"[]");
|
||||
let err = load_all(&dir).unwrap_err();
|
||||
assert!(err.contains("F-A.regions.json"), "{err}");
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A missing region file (never written at all, as opposed to written
|
||||
/// empty) is refused and named — the other half of "missing region
|
||||
/// file" in the required API's failure list, distinct from the
|
||||
/// empty-but-present case above.
|
||||
#[test]
|
||||
fn a_missing_region_file_is_refused_and_named() {
|
||||
let dir = scratch_dir("missing-regions");
|
||||
seed_valid_fixtures_and_hittest(&dir);
|
||||
write(
|
||||
&dir.join("round2-reference/output/F-A.rgba"),
|
||||
&vec![0u8; EXPECTED_RGBA_LEN],
|
||||
);
|
||||
// F-A.regions.json is deliberately never written.
|
||||
let err = load_all(&dir).unwrap_err();
|
||||
assert!(err.contains("F-A.regions.json"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Required kill: a tampered `fixtures.json` digest is refused. Uses
|
||||
/// the same mutation `round2-textkit`'s own
|
||||
/// `validate_kills_a_changed_glyph_id` test does (change one glyph id
|
||||
/// deep inside a fixture, leaving every named/counted field valid) —
|
||||
/// only the whole-artifact digest catches it, which is exactly why this
|
||||
/// crate's loader must not skip that check.
|
||||
#[test]
|
||||
fn a_tampered_fixtures_digest_is_refused() {
|
||||
let dir = scratch_dir("tampered-digest");
|
||||
let mut tampered = round2_textkit::output::load_fixtures(
|
||||
&real_spike_root().join("round2-textkit/fixtures.json"),
|
||||
)
|
||||
.expect("real fixtures.json must load");
|
||||
let g = &mut tampered.fixtures[0].resolved.segments[0].glyphs[3];
|
||||
g.glyph_id = 9999;
|
||||
let json = serde_json::to_string_pretty(&tampered).unwrap();
|
||||
write(&dir.join("round2-textkit/fixtures.json"), json.as_bytes());
|
||||
let err = load_all(&dir).unwrap_err();
|
||||
assert!(err.contains("digest"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// F5: `<id>.regions.json` is an implicit contract between
|
||||
/// `round2-reference` (which writes it) and this crate (which reads
|
||||
/// it), with no shared type. An extra field must be refused **by
|
||||
/// name**, not silently dropped — that is what turns a future schema
|
||||
/// drift into a named parse error here instead of quiet data loss.
|
||||
#[test]
|
||||
fn an_unknown_field_in_a_region_record_is_refused_by_name() {
|
||||
let json = serde_json::json!([{
|
||||
"label": "x",
|
||||
"x0": 0,
|
||||
"y0": 0,
|
||||
"x1": 1,
|
||||
"y1": 1,
|
||||
"smuggled_field": 1
|
||||
}]);
|
||||
let err = serde_json::from_value::<Vec<RegionRecord>>(json)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("smuggled_field"), "{err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
//! # round2-candidatekit — Packet 2B-0: the candidate-neutral apparatus, and
|
||||
//! **nothing else**.
|
||||
//!
|
||||
//! `spec/CONTRACT_EDITOR_T4_SPIKE.md` Round 2 scores criterion 3 (text) via
|
||||
//! the five checks `spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` (W3) §5 names.
|
||||
//! Packet 2A built every piece of candidate-neutral apparatus those checks
|
||||
//! are measured against (fixtures, the hit-test probe table, the reference
|
||||
//! rasters and D4 regions, the accessibility oracle). This crate is Packet
|
||||
//! 2B-0: it is what the two Round 2 candidates — **C1** (egui + lyon) and
|
||||
//! **C2** (vello) — both depend on, so that neither one re-derives fixture
|
||||
//! loading, and neither one gets to define the scoring rule for itself.
|
||||
//!
|
||||
//! ## The neutrality boundary — this is the point of the crate
|
||||
//!
|
||||
//! The user's ruling, verbatim: **"Share only neutral fixture/oracle
|
||||
//! loading. Rendering, hit testing, and accessibility integration remain
|
||||
//! candidate-owned."**
|
||||
//!
|
||||
//! This crate **MAY** contain:
|
||||
//!
|
||||
//! - Loading and validating fixtures, the probe table, the reference
|
||||
//! rasters and region files, and the a11y expectations
|
||||
//! ([`inputs::load_all`]).
|
||||
//! - The shared *report* data shape both candidates emit, and its
|
||||
//! serialization ([`report::CandidateReport`] and its constituent types).
|
||||
//! - The scoring rule that turns per-check outcomes into the criterion cell
|
||||
//! ([`scoring::criterion_cell`], [`scoring::is_eligible`]).
|
||||
//!
|
||||
//! This crate **MUST NOT** contain:
|
||||
//!
|
||||
//! - Any rendering, rasterization, path/outline conversion, or
|
||||
//! tessellation.
|
||||
//! - Any hit-test *resolution* — i.e. nothing that answers "which byte
|
||||
//! offset does this device point select". Loading the expected answers
|
||||
//! ([`round2_textkit::hittest::HitTestProbeFile`]) is neutral; computing
|
||||
//! them is the candidate's job and the thing check 4 measures. This crate
|
||||
//! only carries the *shape* of a recorded comparison
|
||||
//! ([`report::HitTestProbeResult`]) — it never resolves one.
|
||||
//! - Any accessibility node construction or platform-adapter code. This
|
||||
//! crate only carries the *shape* of observed evidence
|
||||
//! ([`report::A11yEvidence`]) against the precommitted oracle
|
||||
//! ([`round2_textkit::a11y`]) — it never builds a tree.
|
||||
//!
|
||||
//! `tests/dependency_deny_list.rs` enforces what code review can miss: it
|
||||
//! reads this crate's own `Cargo.toml` at test time and fails if `egui`,
|
||||
//! `eframe`, `egui-wgpu`, `lyon`, `lyon_path`, `lyon_tessellation`, `vello`,
|
||||
//! `wgpu`, `winit`, `accesskit`, `accesskit_winit`, `tiny-skia`, `resvg`, or
|
||||
//! `usvg` is ever named in `[dependencies]`.
|
||||
//!
|
||||
//! ## What this crate does not decide
|
||||
//!
|
||||
//! [`scoring::criterion_cell`] implements the contract's outcome rule; it
|
||||
//! does not implement W3 §5 itself, and it is not the place check 3's
|
||||
//! `NOT RUN` ruling was *made* — that ruling is `ROUND2_TEXT_RECIPE.md`
|
||||
//! §1.2, and this crate only encodes and enforces its consequences.
|
||||
|
||||
pub mod inputs;
|
||||
pub mod outcome;
|
||||
pub mod report;
|
||||
pub mod scoring;
|
||||
|
||||
pub use inputs::{load_all, NeutralInputs, ReferenceFixture};
|
||||
pub use outcome::CheckOutcome;
|
||||
pub use report::{
|
||||
A11yEvidence, AdapterStatus, BusUnreachableEvidence, CandidateReport, CostRecord,
|
||||
DependencyDelta, DiffReportRecord, HitTestProbeResult, LocByPart, RegionMassRecord, ReportPart,
|
||||
};
|
||||
pub use scoring::{
|
||||
criterion_cell, is_eligible, CellOutcome, CHECK_3_RULING, DISQUALIFYING_CHECKS,
|
||||
ROUND0_READBACK_EVIDENCE, ROUND_PLATFORM,
|
||||
};
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
//! The per-check outcome type both candidates report against.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// One check's outcome. Exactly three states, and **both non-`Pass` states
|
||||
/// carry a reason**: pin 14 requires an environmental `NotRun` to record
|
||||
/// *why* it could not run, and a bare `Fail` with no reason would be
|
||||
/// exactly the unfalsifiable report `round1-oracle`'s discipline exists to
|
||||
/// forbid. There is deliberately no unit-only `NotRun` or `Fail` variant —
|
||||
/// a candidate cannot report "did not pass" without saying why.
|
||||
///
|
||||
/// **An empty or whitespace-only reason is a bare reason wearing a
|
||||
/// string.** The checked constructors ([`CheckOutcome::fail`],
|
||||
/// [`CheckOutcome::not_run`]) and this type's `Deserialize` impl both
|
||||
/// reject one — those are the two paths a candidate actually uses to
|
||||
/// produce a `CandidateReport` (build it in Rust, or read one back from
|
||||
/// JSON). The variants' payloads stay `pub` because a fully private field
|
||||
/// would need a getter/setter pair that adds ceremony without closing any
|
||||
/// path a candidate is expected to take; the invalid state is
|
||||
/// unconstructible through construction *and* deserialization, which is
|
||||
/// what "a reason is required" needs to mean in practice.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub enum CheckOutcome {
|
||||
Pass,
|
||||
Fail(String),
|
||||
NotRun(String),
|
||||
}
|
||||
|
||||
impl CheckOutcome {
|
||||
/// Checked constructor: rejects an empty-or-whitespace-only reason.
|
||||
pub fn fail(reason: impl Into<String>) -> Result<Self, String> {
|
||||
let reason = reason.into();
|
||||
if reason.trim().is_empty() {
|
||||
return Err(
|
||||
"CheckOutcome::fail: reason must not be empty or whitespace-only — a Fail with \
|
||||
no reason is exactly the unfalsifiable report this type exists to forbid"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(CheckOutcome::Fail(reason))
|
||||
}
|
||||
|
||||
/// Checked constructor: rejects an empty-or-whitespace-only reason.
|
||||
pub fn not_run(reason: impl Into<String>) -> Result<Self, String> {
|
||||
let reason = reason.into();
|
||||
if reason.trim().is_empty() {
|
||||
return Err(
|
||||
"CheckOutcome::not_run: reason must not be empty or whitespace-only — pin 14 \
|
||||
requires the environmental cause to be recorded, not merely gestured at"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(CheckOutcome::NotRun(reason))
|
||||
}
|
||||
|
||||
/// Ordering used by [`crate::scoring::criterion_cell`]'s worst-of-five
|
||||
/// rule: `Pass` < `NotRun` < `Fail`. Higher is worse.
|
||||
pub(crate) fn severity_rank(&self) -> u8 {
|
||||
match self {
|
||||
CheckOutcome::Pass => 0,
|
||||
CheckOutcome::NotRun(_) => 1,
|
||||
CheckOutcome::Fail(_) => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pass(&self) -> bool {
|
||||
matches!(self, CheckOutcome::Pass)
|
||||
}
|
||||
|
||||
pub fn is_fail(&self) -> bool {
|
||||
matches!(self, CheckOutcome::Fail(_))
|
||||
}
|
||||
|
||||
pub fn is_not_run(&self) -> bool {
|
||||
matches!(self, CheckOutcome::NotRun(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire shape `CheckOutcome` deserializes through — identical variants
|
||||
/// and payloads, `#[serde(deny_unknown_fields)]` for the same structural-
|
||||
/// drift reason every deserializable type in this workspace uses it, kept
|
||||
/// as a **separate, private** type so [`CheckOutcome`]'s own `Deserialize`
|
||||
/// impl can run [`CheckOutcome::fail`]/[`CheckOutcome::not_run`]'s
|
||||
/// empty-reason check on the way through, which `#[derive(Deserialize)]`
|
||||
/// on `CheckOutcome` directly could not do.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum CheckOutcomeWire {
|
||||
Pass,
|
||||
Fail(String),
|
||||
NotRun(String),
|
||||
}
|
||||
|
||||
impl TryFrom<CheckOutcomeWire> for CheckOutcome {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(wire: CheckOutcomeWire) -> Result<Self, String> {
|
||||
match wire {
|
||||
CheckOutcomeWire::Pass => Ok(CheckOutcome::Pass),
|
||||
CheckOutcomeWire::Fail(reason) => CheckOutcome::fail(reason),
|
||||
CheckOutcomeWire::NotRun(reason) => CheckOutcome::not_run(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CheckOutcome {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = CheckOutcomeWire::deserialize(deserializer)?;
|
||||
CheckOutcome::try_from(wire).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn severity_orders_pass_below_not_run_below_fail() {
|
||||
assert!(
|
||||
CheckOutcome::Pass.severity_rank() < CheckOutcome::NotRun("x".into()).severity_rank()
|
||||
);
|
||||
assert!(
|
||||
CheckOutcome::NotRun("x".into()).severity_rank()
|
||||
< CheckOutcome::Fail("x".into()).severity_rank()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicates_agree_with_the_variant() {
|
||||
assert!(CheckOutcome::Pass.is_pass());
|
||||
assert!(!CheckOutcome::Pass.is_fail());
|
||||
assert!(!CheckOutcome::Pass.is_not_run());
|
||||
|
||||
assert!(CheckOutcome::Fail("x".into()).is_fail());
|
||||
assert!(!CheckOutcome::Fail("x".into()).is_pass());
|
||||
|
||||
assert!(CheckOutcome::NotRun("x".into()).is_not_run());
|
||||
assert!(!CheckOutcome::NotRun("x".into()).is_pass());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_json() {
|
||||
for outcome in [
|
||||
CheckOutcome::Pass,
|
||||
CheckOutcome::Fail("reason".to_string()),
|
||||
CheckOutcome::NotRun("reason".to_string()),
|
||||
] {
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
let back: CheckOutcome = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(outcome, back);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F6: a real distinguishing assertion, not `len() > 0` ----
|
||||
|
||||
/// A bare JSON string `"NotRun"` does not match the tuple-variant shape
|
||||
/// `NotRun(String)` at all (that shape serializes as
|
||||
/// `{"NotRun": "..."}`), so this is a **structural** deserialize
|
||||
/// failure — distinct from the empty-reason rejection below, which
|
||||
/// targets a `NotRun` that *does* carry a payload, just an empty one.
|
||||
/// Asserts on serde's actual reported type mismatch (a unit-shaped
|
||||
/// value where a payload-carrying variant was required), which is what
|
||||
/// actually distinguishes this rejection from every other kind of
|
||||
/// deserialize failure this file tests — not on "some error happened"
|
||||
/// (measured: `err.to_string()` is `"invalid type: unit variant,
|
||||
/// expected newtype variant"`, which names neither `NotRun` nor `Fail`
|
||||
/// by name, so asserting on the variant name would itself have been
|
||||
/// wrong).
|
||||
#[test]
|
||||
fn a_bare_string_not_run_with_no_payload_fails_to_deserialize() {
|
||||
let bad = serde_json::json!("NotRun");
|
||||
let err = serde_json::from_value::<CheckOutcome>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("unit variant"), "{err}");
|
||||
assert!(err.contains("newtype variant"), "{err}");
|
||||
}
|
||||
|
||||
// ---- F3: an empty or whitespace-only reason is refused ----
|
||||
|
||||
#[test]
|
||||
fn the_fail_constructor_rejects_an_empty_reason() {
|
||||
let err = CheckOutcome::fail("").unwrap_err();
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fail_constructor_rejects_a_whitespace_only_reason() {
|
||||
let err = CheckOutcome::fail(" \t ").unwrap_err();
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fail_constructor_accepts_a_real_reason() {
|
||||
let outcome = CheckOutcome::fail("host-substituted the Hebrew segment").unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
CheckOutcome::Fail("host-substituted the Hebrew segment".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_not_run_constructor_rejects_an_empty_reason() {
|
||||
let err = CheckOutcome::not_run("").unwrap_err();
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_not_run_constructor_rejects_a_whitespace_only_reason() {
|
||||
let err = CheckOutcome::not_run("\n").unwrap_err();
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_not_run_constructor_accepts_a_real_reason() {
|
||||
let outcome = CheckOutcome::not_run("no Arabic-capable face installed").unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
CheckOutcome::NotRun("no Arabic-capable face installed".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Guards the deserialize path the same way the constructors guard
|
||||
/// direct construction: a `Fail` with an empty string payload must be
|
||||
/// refused on the way in from JSON, not merely by a constructor a
|
||||
/// candidate could route around by deserializing instead.
|
||||
#[test]
|
||||
fn deserializing_an_empty_reason_fail_is_refused() {
|
||||
let bad = serde_json::json!({"Fail": ""});
|
||||
let err = serde_json::from_value::<CheckOutcome>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializing_a_whitespace_only_reason_not_run_is_refused() {
|
||||
let bad = serde_json::json!({"NotRun": " "});
|
||||
let err = serde_json::from_value::<CheckOutcome>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializing_a_real_reason_still_works() {
|
||||
let good = serde_json::json!({"Fail": "a real reason"});
|
||||
let outcome: CheckOutcome = serde_json::from_value(good).unwrap();
|
||||
assert_eq!(outcome, CheckOutcome::Fail("a real reason".to_string()));
|
||||
}
|
||||
|
||||
/// An unknown variant name must still be refused — `CheckOutcomeWire`'s
|
||||
/// own shape carries forward through the custom `Deserialize` impl
|
||||
/// rather than being silently lost when `CheckOutcome` stopped deriving
|
||||
/// it directly. Measured: `err.to_string()` is `"unknown variant
|
||||
/// \`Passed\`, expected one of \`Pass\`, \`Fail\`, \`NotRun\`"`, so the
|
||||
/// specific bad name is named in the message.
|
||||
#[test]
|
||||
fn an_unknown_variant_name_is_refused() {
|
||||
let bad = serde_json::json!({"Passed": null});
|
||||
let err = serde_json::from_value::<CheckOutcome>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("unknown variant"), "{err}");
|
||||
assert!(err.contains("Passed"), "{err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
//! The shared report shape both candidates emit ([`CandidateReport`]), plus
|
||||
//! serializable mirrors of `round2-diff`'s pass/fail types.
|
||||
//!
|
||||
//! `round2-diff` is a reviewed, frozen packet — its own `Cargo.toml` doc
|
||||
//! comment states it is "deliberately zero dependencies", and this crate
|
||||
//! does not modify it to add a `serde` derive it does not otherwise need.
|
||||
//! [`DiffReportRecord`] and [`RegionMassRecord`] are lossless mirrors, with
|
||||
//! an infallible `From` conversion, of `round2_diff::DiffReport` and
|
||||
//! `round2_diff::RegionMass` — the same pattern `round2-reference`'s
|
||||
//! `RegionRecord` uses for `round2_diff::GlyphRegion`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use round2_diff::{DiffReport, RegionMass};
|
||||
use round2_textkit::hittest::DevicePoint;
|
||||
use round2_textkit::types::SpikeCaretAffinity;
|
||||
|
||||
use crate::outcome::CheckOutcome;
|
||||
|
||||
/// Serializable mirror of `round2_diff::RegionMass`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RegionMassRecord {
|
||||
pub label: String,
|
||||
pub reference_mass: f64,
|
||||
pub candidate_mass: f64,
|
||||
pub relative_delta: f64,
|
||||
pub pass: bool,
|
||||
}
|
||||
|
||||
impl From<&RegionMass> for RegionMassRecord {
|
||||
fn from(r: &RegionMass) -> Self {
|
||||
RegionMassRecord {
|
||||
label: r.label.clone(),
|
||||
reference_mass: r.reference_mass,
|
||||
candidate_mass: r.candidate_mass,
|
||||
relative_delta: r.relative_delta,
|
||||
pass: r.pass,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable mirror of `round2_diff::DiffReport` — see this module's doc
|
||||
/// comment for why this crate mirrors rather than modifies `round2-diff`.
|
||||
/// `pass` is [`DiffReport::pass`]'s own computed verdict, stored rather than
|
||||
/// re-derived, so a report read back from JSON does not need the four
|
||||
/// D-rule fields recomputed by hand to know its own outcome.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DiffReportRecord {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub band_pixel_count: u64,
|
||||
pub d1_pixels_outside_band_differing: u64,
|
||||
pub d1_pass: bool,
|
||||
pub reference_ink_mass: f64,
|
||||
pub candidate_ink_mass: f64,
|
||||
pub d2_relative_delta: f64,
|
||||
pub d2_pass: bool,
|
||||
pub reference_centroid: Option<(f64, f64)>,
|
||||
pub candidate_centroid: Option<(f64, f64)>,
|
||||
pub d3_delta: Option<(f64, f64)>,
|
||||
pub d3_pass: Option<bool>,
|
||||
pub in_band_max_abs_delta_luma: u8,
|
||||
pub in_band_count_delta_gt_report_threshold: u64,
|
||||
pub d4_regions: Vec<RegionMassRecord>,
|
||||
pub d4_pass: bool,
|
||||
pub d4_worst: Option<RegionMassRecord>,
|
||||
/// [`DiffReport::pass`]'s overall verdict: D1, D2, D4 must all hold,
|
||||
/// and D3 must either hold or be inapplicable.
|
||||
pub pass: bool,
|
||||
}
|
||||
|
||||
impl From<&DiffReport> for DiffReportRecord {
|
||||
fn from(r: &DiffReport) -> Self {
|
||||
DiffReportRecord {
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
band_pixel_count: r.band_pixel_count,
|
||||
d1_pixels_outside_band_differing: r.d1_pixels_outside_band_differing,
|
||||
d1_pass: r.d1_pass,
|
||||
reference_ink_mass: r.reference_ink_mass,
|
||||
candidate_ink_mass: r.candidate_ink_mass,
|
||||
d2_relative_delta: r.d2_relative_delta,
|
||||
d2_pass: r.d2_pass,
|
||||
reference_centroid: r.reference_centroid,
|
||||
candidate_centroid: r.candidate_centroid,
|
||||
d3_delta: r.d3_delta,
|
||||
d3_pass: r.d3_pass,
|
||||
in_band_max_abs_delta_luma: r.in_band_max_abs_delta_luma,
|
||||
in_band_count_delta_gt_report_threshold: r.in_band_count_delta_gt_report_threshold,
|
||||
d4_regions: r.d4_regions.iter().map(RegionMassRecord::from).collect(),
|
||||
d4_pass: r.d4_pass,
|
||||
d4_worst: r.d4_worst.as_ref().map(RegionMassRecord::from),
|
||||
pass: r.pass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One hit-test probe's recorded comparison. The device point and expected
|
||||
/// answer come straight from `round2-textkit`'s committed
|
||||
/// `hittest_probes.json` (`round2_textkit::hittest::HitTestProbe`);
|
||||
/// resolving *which* byte offset and affinity a candidate's renderer
|
||||
/// actually returns for that point is the candidate's own job — check 4's
|
||||
/// entire subject — so this type only carries the recorded outcome of that
|
||||
/// resolution, never performs it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HitTestProbeResult {
|
||||
pub fixture_id: String,
|
||||
pub point: DevicePoint,
|
||||
pub expected_source_offset: u32,
|
||||
pub expected_affinity: SpikeCaretAffinity,
|
||||
pub actual_source_offset: u32,
|
||||
pub actual_affinity: SpikeCaretAffinity,
|
||||
pub pass: bool,
|
||||
}
|
||||
|
||||
/// One fixture's observed accessibility evidence — what the candidate's own
|
||||
/// tree (or its absence) actually looked like, compared against
|
||||
/// `round2-textkit`'s precommitted
|
||||
/// `round2_textkit::a11y::SpikeAccessibilityExpectation`. Building the tree
|
||||
/// is the candidate's job (recipe §8.4: "nothing here says *how* a
|
||||
/// candidate builds the tree, on which thread, or through which crate");
|
||||
/// this type only carries what was observed.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct A11yEvidence {
|
||||
pub fixture_id: String,
|
||||
/// The platform row (recipe §8.2 table key, e.g. `"accesskit-0.24"`)
|
||||
/// this evidence was collected against — a candidate satisfies check 5
|
||||
/// by matching one row, the platform it actually exposes a tree on.
|
||||
pub platform: String,
|
||||
/// `None` when the run is absent from the tree entirely (the
|
||||
/// `absent-from-tree` prohibited outcome) — a distinct state from an
|
||||
/// empty-but-present name (`name-empty`), which is `Some("")`.
|
||||
pub observed_name: Option<String>,
|
||||
pub observed_name_bytes_hex: Option<String>,
|
||||
pub observed_role: Option<String>,
|
||||
/// One of `round2_textkit::a11y::PROHIBITED_OUTCOMES`, or `None` if no
|
||||
/// prohibited outcome applies.
|
||||
pub prohibited_outcome: Option<String>,
|
||||
pub pass: bool,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Positive evidence that the platform accessibility bus itself was
|
||||
/// unreachable — the *only* thing that can make
|
||||
/// [`CandidateReport::check5_accessibility`] `NotRun` admissible on the
|
||||
/// round's own platform (AT-SPI2, on this machine); see
|
||||
/// `crate::scoring::ROUND0_READBACK_EVIDENCE` for why "we did not build a
|
||||
/// bridge" is not, by itself, an environmental cause here.
|
||||
///
|
||||
/// A **typed** field rather than folding this into `CheckOutcome::NotRun`'s
|
||||
/// free-text reason on purpose: a free-text reason is something a candidate
|
||||
/// can write anything into ("bus unreachable" typed by hand proves
|
||||
/// nothing), while this type asks for the specific thing that would make
|
||||
/// the claim checkable — what was attempted, and what was actually
|
||||
/// observed.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BusUnreachableEvidence {
|
||||
/// How the candidate attempted to reach the platform accessibility bus
|
||||
/// before concluding it was unreachable (e.g. "connected to the AT-SPI2
|
||||
/// session bus via `atspi::Bus::connect`").
|
||||
pub probe_description: String,
|
||||
/// What was actually observed — the failure itself, not a restatement
|
||||
/// of "unreachable" (e.g. the connection error message).
|
||||
pub probe_output: String,
|
||||
}
|
||||
|
||||
/// One dependency added to the candidate's own crate(s) over the Round 1
|
||||
/// baseline. `reason` is a one-line justification a reader can check
|
||||
/// against what the candidate actually needed to build.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DependencyDelta {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// One platform accessibility adapter's status.
|
||||
///
|
||||
/// `NotBuilt` is a distinct variant from a failing status **on purpose** —
|
||||
/// the user's ruling is that an adapter the candidate chose not to build is
|
||||
/// **scope, not a hidden failure**. A string convention (e.g. a `notes`
|
||||
/// field reading `"not built"`) could be typo'd, omitted, or silently
|
||||
/// absorbed into a `PASS`; making it a variant the compiler enforces means
|
||||
/// a report can never accidentally claim a platform is covered by leaving
|
||||
/// its status ambiguous.
|
||||
///
|
||||
/// **This variant covers *other* platforms only** (Windows UIA, macOS AX,
|
||||
/// ...) — it must never be used to excuse an unbuilt bridge on the round's
|
||||
/// own platform (AT-SPI2, on this machine); see
|
||||
/// [`crate::scoring::ROUND0_READBACK_EVIDENCE`] and
|
||||
/// [`CandidateReport::check5_bus_unreachable_evidence`] for the field that
|
||||
/// actually governs whether check 5 is allowed to be `NotRun`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AdapterStatus {
|
||||
/// The candidate built and exercised an adapter for this platform.
|
||||
Implemented { platform: String, notes: String },
|
||||
/// The candidate did not build an adapter for this platform.
|
||||
/// **Scope not covered — not a failure.**
|
||||
NotBuilt { platform: String, reason: String },
|
||||
}
|
||||
|
||||
/// A shared part of the candidate's own integration work, common to both C1
|
||||
/// and C2 so their per-part LOC tables can be read **side by side** — the
|
||||
/// one thing the user's ruling on cost tables asks of this record.
|
||||
///
|
||||
/// Replaces an earlier free-text `part: String` design: free text let each
|
||||
/// candidate invent its own vocabulary, which produced two tables that
|
||||
/// could not be compared directly. `Other(String)` is the escape hatch for
|
||||
/// a genuinely candidate-specific seam that none of the five shared rows
|
||||
/// describes (e.g. egui's immediate-mode re-layout-per-frame glue, or
|
||||
/// vello's scene-graph diffing) — the divergence between the two
|
||||
/// candidates is still expressible, just visibly, instead of silently
|
||||
/// fragmenting every row.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ReportPart {
|
||||
TextRendering,
|
||||
HitTestResolution,
|
||||
AccessibilityTreeConstruction,
|
||||
AccessibilityIntegrationWiring,
|
||||
FixtureAndReportPlumbing,
|
||||
/// A seam that is genuinely candidate-specific — not one of the five
|
||||
/// shared rows above.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// LOC for one part of the candidate's own integration work.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LocByPart {
|
||||
pub part: ReportPart,
|
||||
pub lines: u64,
|
||||
}
|
||||
|
||||
/// Observed cost facts, reported at the same granularity by both
|
||||
/// candidates — never a subjective score, only what was actually added,
|
||||
/// built, or left as scope.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CostRecord {
|
||||
/// The Round 1 baseline commit this delta is measured against.
|
||||
pub baseline_commit: String,
|
||||
pub dependencies_added: Vec<DependencyDelta>,
|
||||
/// One entry per platform row in `round2_textkit::a11y::ACCEPTED_ROLE_TABLE`
|
||||
/// — every platform gets a status, `Implemented` or `NotBuilt`, never an
|
||||
/// absent entry (an absent entry is indistinguishable from "forgot to
|
||||
/// report", which is exactly what `NotBuilt` exists to make explicit).
|
||||
pub adapters: Vec<AdapterStatus>,
|
||||
/// Free-text bullets describing integration/wiring the candidate wrote
|
||||
/// itself — glue code, not vendored or generated.
|
||||
pub integration_wiring: Vec<String>,
|
||||
pub loc_by_part: Vec<LocByPart>,
|
||||
}
|
||||
|
||||
/// The shape both Round 2 text candidates emit.
|
||||
///
|
||||
/// `check1`..`check5` are the five checks [`crate::scoring::criterion_cell`]
|
||||
/// reduces to the criterion cell. `supplementary_f_d_bidi` is deliberately
|
||||
/// **not** one of them — see that function's doc comment for why it is
|
||||
/// structurally incapable of reaching the cell.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CandidateReport {
|
||||
pub candidate_id: String,
|
||||
|
||||
pub check1_faithful_consumption: CheckOutcome,
|
||||
pub check2_fallback: CheckOutcome,
|
||||
/// Must be `CheckOutcome::NotRun(_)` by the standing ruling
|
||||
/// (`ROUND2_TEXT_RECIPE.md` §1.2) — enforced in
|
||||
/// `crate::scoring::criterion_cell` (by panic, not silent acceptance),
|
||||
/// not at construction time here, so a report can still be built and
|
||||
/// inspected before that function ever runs.
|
||||
pub check3_bidi: CheckOutcome,
|
||||
pub check4_hit_testing: CheckOutcome,
|
||||
pub check5_accessibility: CheckOutcome,
|
||||
/// Present only when `check5_accessibility` is `NotRun` **and** that
|
||||
/// `NotRun` is claimed to be caused by the platform accessibility bus
|
||||
/// itself being unreachable — the only cause
|
||||
/// `crate::scoring::criterion_cell`/`crate::scoring::is_eligible`
|
||||
/// accept for a check-5 `NotRun` on this round's own platform. `None`
|
||||
/// whenever `check5_accessibility` is `Pass` or `Fail`.
|
||||
pub check5_bus_unreachable_evidence: Option<BusUnreachableEvidence>,
|
||||
|
||||
/// F-D's supplementary Hebrew/Latin bidi evidence (recipe §1.2) — a
|
||||
/// separate field, never merged into the five above and never read by
|
||||
/// `crate::scoring::criterion_cell`.
|
||||
pub supplementary_f_d_bidi: CheckOutcome,
|
||||
|
||||
/// Keyed by fixture id (`F-A`..`F-E`).
|
||||
pub per_fixture_diffs: BTreeMap<String, DiffReportRecord>,
|
||||
pub hittest_probe_results: Vec<HitTestProbeResult>,
|
||||
pub a11y_evidence: Vec<A11yEvidence>,
|
||||
pub cost: CostRecord,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use round2_diff::GlyphRegion;
|
||||
|
||||
fn solid(width: u32, height: u32, rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; (width as usize) * (height as usize) * 4];
|
||||
for px in buf.chunks_mut(4) {
|
||||
px[0] = rgb[0];
|
||||
px[1] = rgb[1];
|
||||
px[2] = rgb[2];
|
||||
px[3] = 255;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_report_record_mirrors_every_field_and_the_computed_verdict() {
|
||||
let reference = solid(8, 8, [255, 255, 255]);
|
||||
let candidate = reference.clone();
|
||||
let region = GlyphRegion {
|
||||
label: "x".to_string(),
|
||||
x0: 2,
|
||||
y0: 2,
|
||||
x1: 6,
|
||||
y1: 6,
|
||||
};
|
||||
let report = round2_diff::diff(&reference, &candidate, 8, 8, &[region]).unwrap();
|
||||
let record = DiffReportRecord::from(&report);
|
||||
assert_eq!(record.width, report.width);
|
||||
assert_eq!(record.height, report.height);
|
||||
assert_eq!(record.d1_pass, report.d1_pass);
|
||||
assert_eq!(record.d2_pass, report.d2_pass);
|
||||
assert_eq!(record.d3_pass, report.d3_pass);
|
||||
assert_eq!(record.d4_pass, report.d4_pass);
|
||||
assert_eq!(record.pass, report.pass());
|
||||
assert_eq!(record.d4_regions.len(), report.d4_regions.len());
|
||||
}
|
||||
|
||||
fn empty_cost() -> CostRecord {
|
||||
CostRecord {
|
||||
baseline_commit: "abc1234".to_string(),
|
||||
dependencies_added: Vec::new(),
|
||||
adapters: vec![AdapterStatus::NotBuilt {
|
||||
platform: "windows-uia".to_string(),
|
||||
reason: "no Windows CI runner for this spike".to_string(),
|
||||
}],
|
||||
integration_wiring: Vec::new(),
|
||||
loc_by_part: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_candidate_report() -> CandidateReport {
|
||||
CandidateReport {
|
||||
candidate_id: "C-TEST".to_string(),
|
||||
check1_faithful_consumption: CheckOutcome::Pass,
|
||||
check2_fallback: CheckOutcome::Pass,
|
||||
check3_bidi: CheckOutcome::NotRun("x".to_string()),
|
||||
check4_hit_testing: CheckOutcome::Pass,
|
||||
check5_accessibility: CheckOutcome::Pass,
|
||||
check5_bus_unreachable_evidence: None,
|
||||
supplementary_f_d_bidi: CheckOutcome::Pass,
|
||||
per_fixture_diffs: BTreeMap::new(),
|
||||
hittest_probe_results: Vec::new(),
|
||||
a11y_evidence: Vec::new(),
|
||||
cost: empty_cost(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_report_round_trips_through_json() {
|
||||
let report = base_candidate_report();
|
||||
let json = serde_json::to_string_pretty(&report).unwrap();
|
||||
let reloaded: CandidateReport = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(reloaded.candidate_id, "C-TEST");
|
||||
assert!(matches!(
|
||||
reloaded.cost.adapters[0],
|
||||
AdapterStatus::NotBuilt { .. }
|
||||
));
|
||||
assert!(reloaded.check5_bus_unreachable_evidence.is_none());
|
||||
}
|
||||
|
||||
/// `check5_bus_unreachable_evidence` must round-trip when present, not
|
||||
/// just when `None` — the field the review named is exactly the one a
|
||||
/// lossy round trip would silently drop.
|
||||
#[test]
|
||||
fn bus_unreachable_evidence_round_trips_through_json() {
|
||||
let mut report = base_candidate_report();
|
||||
report.check5_accessibility = CheckOutcome::not_run("bus unreachable").unwrap();
|
||||
report.check5_bus_unreachable_evidence = Some(BusUnreachableEvidence {
|
||||
probe_description: "connected to the AT-SPI2 session bus".to_string(),
|
||||
probe_output: "org.freedesktop.DBus.Error.ServiceUnknown".to_string(),
|
||||
});
|
||||
let json = serde_json::to_string_pretty(&report).unwrap();
|
||||
let reloaded: CandidateReport = serde_json::from_str(&json).unwrap();
|
||||
let evidence = reloaded
|
||||
.check5_bus_unreachable_evidence
|
||||
.expect("evidence must survive the round trip");
|
||||
assert_eq!(
|
||||
evidence.probe_output,
|
||||
"org.freedesktop.DBus.Error.ServiceUnknown"
|
||||
);
|
||||
}
|
||||
|
||||
/// `NotBuilt` must not be interchangeable with `Implemented` — the
|
||||
/// compiler-enforced distinction the doc comment claims.
|
||||
#[test]
|
||||
fn not_built_adapter_is_a_distinct_variant_from_implemented() {
|
||||
let a = AdapterStatus::NotBuilt {
|
||||
platform: "macos-nsaccessibility".to_string(),
|
||||
reason: "no macOS runner".to_string(),
|
||||
};
|
||||
assert!(matches!(a, AdapterStatus::NotBuilt { .. }));
|
||||
assert!(!matches!(a, AdapterStatus::Implemented { .. }));
|
||||
}
|
||||
|
||||
/// An unknown field on the wire must be refused, not ignored — the same
|
||||
/// discipline every deserializable type in this workspace uses.
|
||||
#[test]
|
||||
fn an_unknown_field_on_cost_record_is_refused() {
|
||||
let mut v = serde_json::to_value(empty_cost()).unwrap();
|
||||
v.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("smuggled_field".into(), serde_json::json!(1));
|
||||
let err = serde_json::from_value::<CostRecord>(v)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("smuggled_field"), "{err}");
|
||||
}
|
||||
|
||||
// ---- F4: the five shared ReportPart rows compare directly ----
|
||||
|
||||
/// The whole point of replacing free-text `part: String` with a fixed
|
||||
/// enum: two candidates' `LocByPart` rows for the same shared part are
|
||||
/// now directly comparable (`==`), which a free-text label (e.g. "text
|
||||
/// rendering" vs. "rendering text") could never guarantee.
|
||||
#[test]
|
||||
fn the_same_shared_part_from_two_candidates_compares_equal() {
|
||||
let c1_row = LocByPart {
|
||||
part: ReportPart::HitTestResolution,
|
||||
lines: 340,
|
||||
};
|
||||
let c2_row = LocByPart {
|
||||
part: ReportPart::HitTestResolution,
|
||||
lines: 210,
|
||||
};
|
||||
assert_eq!(c1_row.part, c2_row.part);
|
||||
assert_ne!(
|
||||
c1_row.lines, c2_row.lines,
|
||||
"the LOC counts may legitimately differ"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Other` stays the escape hatch: two candidate-specific seams with
|
||||
/// different labels remain distinguishable, unlike the five fixed rows.
|
||||
#[test]
|
||||
fn other_parts_with_different_labels_are_not_conflated() {
|
||||
let egui_seam = ReportPart::Other("immediate-mode re-layout per frame".to_string());
|
||||
let vello_seam = ReportPart::Other("scene-graph diffing".to_string());
|
||||
assert_ne!(egui_seam, vello_seam);
|
||||
}
|
||||
|
||||
/// All five shared rows round-trip, and `Other` carries its label
|
||||
/// through — a lossy `Serialize`/`Deserialize` impl on the enum would
|
||||
/// silently collapse rows that must stay comparable.
|
||||
#[test]
|
||||
fn every_report_part_round_trips_through_json() {
|
||||
let parts = [
|
||||
ReportPart::TextRendering,
|
||||
ReportPart::HitTestResolution,
|
||||
ReportPart::AccessibilityTreeConstruction,
|
||||
ReportPart::AccessibilityIntegrationWiring,
|
||||
ReportPart::FixtureAndReportPlumbing,
|
||||
ReportPart::Other("candidate-specific seam".to_string()),
|
||||
];
|
||||
for part in parts {
|
||||
let json = serde_json::to_string(&part).unwrap();
|
||||
let back: ReportPart = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(part, back);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
//! Turns a [`CandidateReport`]'s five check outcomes into the Round 2
|
||||
//! criterion cell, and reports eligibility separately
|
||||
//! (`ROUND2_TEXT_RECIPE.md` §1.2).
|
||||
|
||||
use crate::outcome::CheckOutcome;
|
||||
use crate::report::CandidateReport;
|
||||
|
||||
/// The round's own platform — `round2_textkit::a11y::ACCEPTED_ROLE_TABLE`'s
|
||||
/// `"at-spi2"` row — restated here so [`ROUND0_READBACK_EVIDENCE`]'s doc
|
||||
/// comment and [`require_check5_not_run_is_admissible`]'s panic can name it
|
||||
/// precisely.
|
||||
pub const ROUND_PLATFORM: &str = "at-spi2";
|
||||
|
||||
/// Round 0's own readback evidence, quoted verbatim in the panic
|
||||
/// [`criterion_cell`]/[`is_eligible`] raise for a check-5 `NotRun` that
|
||||
/// carries no [`crate::report::BusUnreachableEvidence`].
|
||||
///
|
||||
/// `round0-evidence/c1-egui-readback.txt` and
|
||||
/// `round0-evidence/c2-vello-readback.txt` both record `READBACK: PASS` — a
|
||||
/// live, out-of-process AT-SPI2 tree walk that succeeded for **both**
|
||||
/// candidates on this machine. So on [`ROUND_PLATFORM`], "we did not build
|
||||
/// an accessibility bridge" is not an environmental cause: the bus is
|
||||
/// reachable, and check 5 is the round's own platform, not declared
|
||||
/// out-of-scope adapter coverage. `AdapterStatus::NotBuilt` still covers
|
||||
/// *other* platforms (Windows UIA, macOS AX, ...) as declared scope; it
|
||||
/// must not be used to excuse the platform the round actually runs on.
|
||||
pub const ROUND0_READBACK_EVIDENCE: &str = "round0-evidence/c1-egui-readback.txt and \
|
||||
round0-evidence/c2-vello-readback.txt both record READBACK: PASS — a live, out-of-process \
|
||||
AT-SPI2 tree walk succeeded for both candidates on this machine, so the platform \
|
||||
accessibility bus is reachable here and an unbuilt accessibility bridge is not \
|
||||
environmental NOT RUN on this platform. AdapterStatus::NotBuilt covers OTHER platforms \
|
||||
(Windows UIA, macOS AX, ...) as declared scope; it does not, by itself, excuse the \
|
||||
platform the round actually runs on.";
|
||||
|
||||
/// Panics if `report.check5_accessibility` is `NotRun` without
|
||||
/// `report.check5_bus_unreachable_evidence` present — see
|
||||
/// [`ROUND0_READBACK_EVIDENCE`]. A no-op for `Pass`/`Fail`, and a no-op for
|
||||
/// a `NotRun` that *does* carry evidence. Deliberately does **not** inspect
|
||||
/// `report.cost.adapters` — an `AdapterStatus::NotBuilt` entry for
|
||||
/// [`ROUND_PLATFORM`] must not, by itself, satisfy this check (that is the
|
||||
/// exact loophole the review named).
|
||||
fn require_check5_not_run_is_admissible(report: &CandidateReport) {
|
||||
if report.check5_accessibility.is_not_run() && report.check5_bus_unreachable_evidence.is_none()
|
||||
{
|
||||
panic!(
|
||||
"candidate {:?} reported check5_accessibility = NotRun(_) with no \
|
||||
check5_bus_unreachable_evidence — {ROUND0_READBACK_EVIDENCE}",
|
||||
report.candidate_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Round 2 criterion cell for check 3 (bidi / text-run primitives) is
|
||||
/// structurally identical to [`CheckOutcome`] — a cell is the worst of the
|
||||
/// five checks, which is itself just a `CheckOutcome` — kept as a distinct
|
||||
/// name so a reader is never unsure whether a value in hand is *one check's
|
||||
/// own* outcome or *the criterion cell* five checks reduce to.
|
||||
pub type CellOutcome = CheckOutcome;
|
||||
|
||||
/// The standing ruling [`criterion_cell`] enforces (`ROUND2_TEXT_RECIPE.md`
|
||||
/// §1.2, 2026-07-29): no Arabic-capable face is installed on the round's
|
||||
/// declared machine, and pin 9 makes an absent required face environmental
|
||||
/// `NOT RUN`. Quoted verbatim in the panic [`criterion_cell`] raises for a
|
||||
/// report that disagrees with it.
|
||||
pub const CHECK_3_RULING: &str = "ROUND2_TEXT_RECIPE.md §1.2 (2026-07-29 ruling): check 3 is \
|
||||
NOT RUN for every candidate, on both adapters — no Arabic-capable face is installed, and \
|
||||
pin 9 makes an absent required face environmental NOT RUN. F-D's supplementary Hebrew/Latin \
|
||||
bidi evidence is recorded separately and must never upgrade check 3 to PASS.";
|
||||
|
||||
/// Reduces a [`CandidateReport`]'s five check outcomes to the Round 2
|
||||
/// criterion cell: the **worst of the five**, ordered `Pass` < `NotRun` <
|
||||
/// `Fail` ([`CheckOutcome::severity_rank`]).
|
||||
///
|
||||
/// The supplementary F-D bidi result
|
||||
/// ([`CandidateReport::supplementary_f_d_bidi`]) is a separate field on
|
||||
/// `CandidateReport` and this function never reads it — that is what makes
|
||||
/// it **structurally** incapable of reaching the cell (recipe §1.2: "it
|
||||
/// must not upgrade check 3 to PASS"), rather than merely conventionally
|
||||
/// excluded by a check this function could someday grow to include by
|
||||
/// accident.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `report.check3_bidi` is anything other than `NotRun` — a
|
||||
/// candidate reporting `Pass` or `Fail` for check 3 has violated the
|
||||
/// standing ruling ([`CHECK_3_RULING`]), which this function treats as a
|
||||
/// programming error in how the candidate assembled its report, not a value
|
||||
/// a scoring rule is allowed to interpret. (Not every environmental
|
||||
/// deviation deserves a panic; this one does, because pin 9's face-absence
|
||||
/// fact does not vary between the two candidates or between runs — a
|
||||
/// non-`NotRun` value here can only mean the report was built wrong.)
|
||||
pub fn criterion_cell(report: &CandidateReport) -> CellOutcome {
|
||||
if !report.check3_bidi.is_not_run() {
|
||||
panic!(
|
||||
"candidate {:?} reported check 3 as {:?}, not NotRun(_) — {CHECK_3_RULING}",
|
||||
report.candidate_id, report.check3_bidi
|
||||
);
|
||||
}
|
||||
require_check5_not_run_is_admissible(report);
|
||||
|
||||
let checks = [
|
||||
&report.check1_faithful_consumption,
|
||||
&report.check2_fallback,
|
||||
&report.check3_bidi,
|
||||
&report.check4_hit_testing,
|
||||
&report.check5_accessibility,
|
||||
];
|
||||
checks
|
||||
.into_iter()
|
||||
.max_by_key(|c| c.severity_rank())
|
||||
.cloned()
|
||||
.expect("`checks` is a fixed non-empty array of five elements")
|
||||
}
|
||||
|
||||
/// The two disqualifying checks (recipe §1.2: "checks 2 and 5 are the
|
||||
/// disqualifying set"). Named so the disqualifying set is a fact a reader
|
||||
/// (and a grep) can find, not a claim buried in a comment beside
|
||||
/// [`is_eligible`].
|
||||
pub const DISQUALIFYING_CHECKS: &str = "check2_fallback, check5_accessibility";
|
||||
|
||||
/// Whether `report` remains a candidate at all — reported **separately**
|
||||
/// from [`criterion_cell`], because the two questions are different: the
|
||||
/// cell is what the criterion 3 table shows, eligibility is whether the
|
||||
/// candidate survives at all.
|
||||
///
|
||||
/// Failing check 2 or check 5 disqualifies. Check 3's `NotRun` (the only
|
||||
/// state it is ever allowed to carry — see [`criterion_cell`]) does **not**
|
||||
/// disqualify, because check 3 is not in the disqualifying set
|
||||
/// ([`DISQUALIFYING_CHECKS`]).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics under the same condition [`criterion_cell`] does for check 5 —
|
||||
/// see [`require_check5_not_run_is_admissible`] / [`ROUND0_READBACK_EVIDENCE`].
|
||||
/// Without this, a candidate that never wired an accessibility bridge could
|
||||
/// report `check5_accessibility = NotRun("we did not build it")`, and
|
||||
/// `is_eligible` would return `true` because `NotRun` is not `Fail` — the
|
||||
/// exact loophole the ruling this function enforces exists to close. This
|
||||
/// function does not merely return `false` for that case, because the
|
||||
/// report itself is malformed (an inadmissible claim), not merely
|
||||
/// disqualifying: a malformed report should not be silently readable as "at
|
||||
/// least eligible."
|
||||
pub fn is_eligible(report: &CandidateReport) -> bool {
|
||||
require_check5_not_run_is_admissible(report);
|
||||
!report.check2_fallback.is_fail() && !report.check5_accessibility.is_fail()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::report::{AdapterStatus, BusUnreachableEvidence, CostRecord};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn base_report() -> CandidateReport {
|
||||
CandidateReport {
|
||||
candidate_id: "C-TEST".to_string(),
|
||||
check1_faithful_consumption: CheckOutcome::Pass,
|
||||
check2_fallback: CheckOutcome::Pass,
|
||||
check3_bidi: CheckOutcome::NotRun(CHECK_3_RULING.to_string()),
|
||||
check4_hit_testing: CheckOutcome::Pass,
|
||||
check5_accessibility: CheckOutcome::Pass,
|
||||
check5_bus_unreachable_evidence: None,
|
||||
supplementary_f_d_bidi: CheckOutcome::Pass,
|
||||
per_fixture_diffs: BTreeMap::new(),
|
||||
hittest_probe_results: Vec::new(),
|
||||
a11y_evidence: Vec::new(),
|
||||
cost: CostRecord {
|
||||
baseline_commit: "0000000".to_string(),
|
||||
dependencies_added: Vec::new(),
|
||||
adapters: Vec::new(),
|
||||
integration_wiring: Vec::new(),
|
||||
loc_by_part: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn some_evidence() -> BusUnreachableEvidence {
|
||||
BusUnreachableEvidence {
|
||||
probe_description: "connected to the AT-SPI2 session bus".to_string(),
|
||||
probe_output: "org.freedesktop.DBus.Error.ServiceUnknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- criterion_cell: the worst-of-five rule ----
|
||||
|
||||
#[test]
|
||||
fn all_pass_except_the_pinned_check_3_yields_a_not_run_cell() {
|
||||
let cell = criterion_cell(&base_report());
|
||||
assert!(matches!(cell, CheckOutcome::NotRun(_)), "{cell:?}");
|
||||
}
|
||||
|
||||
/// Required kill: a `CandidateReport` claiming check 3 `Pass` is
|
||||
/// rejected, naming the §1.2 ruling.
|
||||
#[test]
|
||||
#[should_panic(expected = "§1.2")]
|
||||
fn a_check_3_pass_is_rejected_naming_the_ruling() {
|
||||
let mut report = base_report();
|
||||
report.check3_bidi = CheckOutcome::Pass;
|
||||
let _ = criterion_cell(&report);
|
||||
}
|
||||
|
||||
/// Same requirement, the other disallowed value: a `Fail` for check 3
|
||||
/// is rejected exactly as a `Pass` is — the ruling pins check 3 to
|
||||
/// `NotRun` specifically, not merely "not Pass".
|
||||
#[test]
|
||||
#[should_panic(expected = "§1.2")]
|
||||
fn a_check_3_fail_is_also_rejected_naming_the_ruling() {
|
||||
let mut report = base_report();
|
||||
report.check3_bidi = CheckOutcome::Fail("pretend Arabic shaping worked".to_string());
|
||||
let _ = criterion_cell(&report);
|
||||
}
|
||||
|
||||
/// Required kill: a supplementary F-D `Pass` does not move the cell off
|
||||
/// `NotRun`.
|
||||
#[test]
|
||||
fn a_supplementary_f_d_pass_does_not_move_the_cell_off_not_run() {
|
||||
let mut report = base_report();
|
||||
report.supplementary_f_d_bidi = CheckOutcome::Pass;
|
||||
assert!(matches!(criterion_cell(&report), CheckOutcome::NotRun(_)));
|
||||
}
|
||||
|
||||
/// Required kill: a supplementary F-D `Fail` does not move the cell
|
||||
/// either — in particular it must not turn `NotRun` into `Fail`, which
|
||||
/// is the direction a naive "worst of six" implementation would break.
|
||||
#[test]
|
||||
fn a_supplementary_f_d_fail_does_not_move_the_cell_either() {
|
||||
let mut report = base_report();
|
||||
report.supplementary_f_d_bidi =
|
||||
CheckOutcome::Fail("Hebrew segment drawn in the wrong face".to_string());
|
||||
let cell = criterion_cell(&report);
|
||||
assert!(
|
||||
matches!(cell, CheckOutcome::NotRun(_)),
|
||||
"a FAIL on the supplementary row must not reach the cell at all: got {cell:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A genuine check-2 FAIL must still win the worst-of-five over the
|
||||
/// pinned check-3 NotRun — confirms the ordering is real, not just
|
||||
/// "always NotRun".
|
||||
#[test]
|
||||
fn a_check_2_failure_outranks_the_pinned_not_run_in_the_cell() {
|
||||
let mut report = base_report();
|
||||
report.check2_fallback =
|
||||
CheckOutcome::Fail("host-substituted the Hebrew segment".to_string());
|
||||
let cell = criterion_cell(&report);
|
||||
assert!(matches!(cell, CheckOutcome::Fail(_)), "{cell:?}");
|
||||
}
|
||||
|
||||
// ---- is_eligible: the disqualifying set is {check2, check5} only ----
|
||||
|
||||
/// Required kill: a candidate failing check 2 is ineligible.
|
||||
#[test]
|
||||
fn failing_check_2_makes_a_candidate_ineligible() {
|
||||
let mut report = base_report();
|
||||
report.check2_fallback = CheckOutcome::Fail("...".to_string());
|
||||
assert!(!is_eligible(&report));
|
||||
}
|
||||
|
||||
/// Required kill: a candidate failing check 5 is ineligible.
|
||||
#[test]
|
||||
fn failing_check_5_makes_a_candidate_ineligible() {
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::Fail("...".to_string());
|
||||
assert!(!is_eligible(&report));
|
||||
}
|
||||
|
||||
/// Required kill: a candidate whose only non-Pass is check 3 `NotRun`
|
||||
/// is eligible.
|
||||
#[test]
|
||||
fn a_candidate_whose_only_non_pass_is_check_3_not_run_is_eligible() {
|
||||
let report = base_report(); // check3 is NotRun; everything else Pass.
|
||||
assert!(
|
||||
is_eligible(&report),
|
||||
"check 3 is not in the disqualifying set"
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks 1 and 4 are not disqualifying either — only 2 and 5 are. This
|
||||
/// distinguishes "affects the cell" from "affects eligibility": a
|
||||
/// check-1 FAIL sinks the cell to FAIL but must not, by itself, remove
|
||||
/// the candidate from the round.
|
||||
#[test]
|
||||
fn failing_check_1_or_4_sinks_the_cell_but_not_eligibility() {
|
||||
let mut report = base_report();
|
||||
report.check1_faithful_consumption = CheckOutcome::Fail("...".to_string());
|
||||
assert!(
|
||||
is_eligible(&report),
|
||||
"checks 1 and 4 are not in the disqualifying set"
|
||||
);
|
||||
assert!(matches!(criterion_cell(&report), CheckOutcome::Fail(_)));
|
||||
}
|
||||
|
||||
// ---- F1: a check-5 NotRun is admissible only with bus-unreachable evidence ----
|
||||
|
||||
/// Required kill: a check-5 `NotRun` with no unreachable-bus evidence is
|
||||
/// rejected, naming Round 0's readback evidence.
|
||||
#[test]
|
||||
#[should_panic(expected = "READBACK: PASS")]
|
||||
fn a_check_5_not_run_with_no_evidence_is_rejected_by_is_eligible() {
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap();
|
||||
report.check5_bus_unreachable_evidence = None;
|
||||
let _ = is_eligible(&report);
|
||||
}
|
||||
|
||||
/// Same rejection, reached through `criterion_cell` instead of
|
||||
/// `is_eligible` — both are "the scoring path" the review named.
|
||||
#[test]
|
||||
#[should_panic(expected = "READBACK: PASS")]
|
||||
fn a_check_5_not_run_with_no_evidence_is_rejected_by_criterion_cell() {
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap();
|
||||
report.check5_bus_unreachable_evidence = None;
|
||||
let _ = criterion_cell(&report);
|
||||
}
|
||||
|
||||
/// Required kill: a check-5 `NotRun` **with** unreachable-bus evidence
|
||||
/// is accepted, and does not disqualify the candidate (NotRun is not in
|
||||
/// the disqualifying set — see [`DISQUALIFYING_CHECKS`]).
|
||||
#[test]
|
||||
fn a_check_5_not_run_with_evidence_is_accepted_and_does_not_disqualify() {
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::not_run("bus unreachable").unwrap();
|
||||
report.check5_bus_unreachable_evidence = Some(some_evidence());
|
||||
assert!(
|
||||
is_eligible(&report),
|
||||
"a legitimately NotRun check 5 must not disqualify"
|
||||
);
|
||||
let cell = criterion_cell(&report);
|
||||
assert!(matches!(cell, CheckOutcome::NotRun(_)), "{cell:?}");
|
||||
}
|
||||
|
||||
/// Required kill: an `AdapterStatus::NotBuilt` entry for the round's own
|
||||
/// platform does not, by itself, make a check-5 `NotRun` admissible —
|
||||
/// `NotBuilt` covers *other* platforms as declared scope, and must not
|
||||
/// be usable to excuse AT-SPI2, the platform this round actually runs
|
||||
/// on. The admissibility check must ignore `cost.adapters` entirely.
|
||||
#[test]
|
||||
#[should_panic(expected = "READBACK: PASS")]
|
||||
fn a_not_built_adapter_for_the_rounds_own_platform_does_not_grant_admissibility() {
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap();
|
||||
report.check5_bus_unreachable_evidence = None;
|
||||
report.cost.adapters.push(AdapterStatus::NotBuilt {
|
||||
platform: ROUND_PLATFORM.to_string(),
|
||||
reason: "ran out of time".to_string(),
|
||||
});
|
||||
let _ = is_eligible(&report);
|
||||
}
|
||||
|
||||
/// A check-5 `Pass` or `Fail` never triggers the admissibility check at
|
||||
/// all — it exists only to gate `NotRun`, and must not fire on a report
|
||||
/// that never claimed environmental absence.
|
||||
#[test]
|
||||
fn a_check_5_pass_or_fail_never_needs_bus_unreachable_evidence() {
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::Pass;
|
||||
report.check5_bus_unreachable_evidence = None;
|
||||
assert!(is_eligible(&report));
|
||||
let _ = criterion_cell(&report);
|
||||
|
||||
let mut report = base_report();
|
||||
report.check5_accessibility = CheckOutcome::fail("absent-from-tree").unwrap();
|
||||
report.check5_bus_unreachable_evidence = None;
|
||||
assert!(!is_eligible(&report));
|
||||
let _ = criterion_cell(&report);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
//! Enforces the neutrality boundary `src/lib.rs`'s crate doc comment states:
|
||||
//! `round2-candidatekit` must not depend on any rendering, windowing, GPU,
|
||||
//! or platform-accessibility crate — those stay candidate-owned (C1 =
|
||||
//! egui + lyon, C2 = vello). This test reads this crate's own `Cargo.toml`
|
||||
//! **at test time** rather than hard-coding "the current dependency list is
|
||||
//! X" — the point is to catch a *future* dependency add, not merely to
|
||||
//! assert today's file is fine.
|
||||
|
||||
/// Rendering, windowing, GPU, and platform-accessibility crates that must
|
||||
/// never appear in `round2-candidatekit`'s own `[dependencies]`. This list
|
||||
/// is the thing under test — it is deliberately hard-coded, unlike the
|
||||
/// dependency names it is checked against, which are always read fresh from
|
||||
/// the manifest.
|
||||
const DENY_LIST: &[&str] = &[
|
||||
"egui",
|
||||
"eframe",
|
||||
"egui-wgpu",
|
||||
"lyon",
|
||||
"lyon_path",
|
||||
"lyon_tessellation",
|
||||
"vello",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"accesskit",
|
||||
"accesskit_winit",
|
||||
"tiny-skia",
|
||||
"resvg",
|
||||
"usvg",
|
||||
];
|
||||
|
||||
/// If `header` (the contents of a `[...]` line, already trimmed) names a
|
||||
/// dependency **sub-table** — TOML's `[dependencies.name]` form, or the
|
||||
/// same thing nested under a target, `[target.'cfg(...)'.dependencies.name]`
|
||||
/// — returns `name`. `Cargo.toml` lets a single dependency spread across
|
||||
/// its own `[...]` header when it needs more than a version string (e.g.
|
||||
/// `[dependencies.wgpu]\nversion = "0.19"`), and that header names the
|
||||
/// dependency directly rather than introducing a block of `key = value`
|
||||
/// lines the way `[dependencies]` does — a scanner that only recognizes the
|
||||
/// block form misses this shape entirely (confirmed empirically: it
|
||||
/// returned `[]` for a manifest whose only dependency used this form).
|
||||
fn dependency_subtable_name(header: &str) -> Option<String> {
|
||||
let rest = if let Some(r) = header.strip_prefix("dependencies.") {
|
||||
r
|
||||
} else if let Some(idx) = header.find(".dependencies.") {
|
||||
&header[idx + ".dependencies.".len()..]
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// A dependency's own sub-table (e.g. hand-spread build metadata) would
|
||||
// add a further dot, as in `dependencies.foo.metadata`; only the first
|
||||
// segment is the crate name.
|
||||
let name = rest.split('.').next().unwrap_or(rest);
|
||||
Some(name.trim_matches('"').trim_matches('\'').to_string())
|
||||
}
|
||||
|
||||
/// True if `header` opens a **block** of `key = value` dependency lines —
|
||||
/// `[dependencies]` itself, or the same thing nested under a target
|
||||
/// (`[target.'cfg(unix)'.dependencies]`). Deliberately does not match
|
||||
/// `dev-dependencies` or `build-dependencies`: both end in "dependencies"
|
||||
/// but with a hyphen, not a dot, immediately before it, so
|
||||
/// `.ends_with(".dependencies")` is false for them — those tables are out
|
||||
/// of scope for this guard on purpose (see
|
||||
/// `the_line_scanner_finds_dependencies_and_ignores_other_sections`).
|
||||
fn opens_dependency_block(header: &str) -> bool {
|
||||
header == "dependencies" || header.ends_with(".dependencies")
|
||||
}
|
||||
|
||||
/// Extracts dependency names from a `Cargo.toml`, covering both shapes
|
||||
/// Cargo accepts: the block form (`[dependencies]` followed by `key =
|
||||
/// value` lines) and the sub-table form (`[dependencies.name]`), each
|
||||
/// optionally nested under `[target.'cfg(...)'. ...]`. Deliberately not a
|
||||
/// TOML parser — pulling one in as a dependency of a crate whose whole
|
||||
/// point is a short, auditable dependency list would be self-defeating —
|
||||
/// but a plain line scan that recognizes both header shapes, not just the
|
||||
/// block one.
|
||||
fn dependency_names(manifest: &str) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
let mut in_dependency_block = false;
|
||||
for raw_line in manifest.lines() {
|
||||
let line = raw_line.trim();
|
||||
if let Some(header) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
let header = header.trim();
|
||||
if let Some(name) = dependency_subtable_name(header) {
|
||||
names.push(name);
|
||||
in_dependency_block = false;
|
||||
continue;
|
||||
}
|
||||
in_dependency_block = opens_dependency_block(header);
|
||||
continue;
|
||||
}
|
||||
if !in_dependency_block || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, _)) = line.split_once('=') {
|
||||
names.push(key.trim().trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn manifest_path() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_do_not_include_a_denied_rendering_windowing_or_a11y_crate() {
|
||||
let manifest = std::fs::read_to_string(manifest_path())
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", manifest_path().display()));
|
||||
let names = dependency_names(&manifest);
|
||||
assert!(
|
||||
!names.is_empty(),
|
||||
"the line scanner found zero dependencies in {} — that means this test is vacuous, not \
|
||||
that the crate has no dependencies (it depends on round2-textkit, round2-diff, serde, \
|
||||
serde_json); check the scanner, not the crate",
|
||||
manifest_path().display()
|
||||
);
|
||||
let violations: Vec<&String> = names
|
||||
.iter()
|
||||
.filter(|n| DENY_LIST.contains(&n.as_str()))
|
||||
.collect();
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"round2-candidatekit/Cargo.toml [dependencies] names denied crate(s) {violations:?} — \
|
||||
this crate is candidate-neutral apparatus only (rendering, hit-test resolution, and \
|
||||
accessibility integration are candidate-owned; see src/lib.rs's crate doc comment for \
|
||||
the ruling this enforces). Denied list: {DENY_LIST:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanity check on the scanner itself, against a synthetic manifest
|
||||
/// fragment: if this fails, the test above could be silently vacuous no
|
||||
/// matter what `[dependencies]` actually contains. Also confirms
|
||||
/// `[dev-dependencies]` is not scanned — a denied crate under dev-only use
|
||||
/// (impossible here, since this crate declares none, but stated as a
|
||||
/// property of the scanner) must not trip the production-dependency check.
|
||||
#[test]
|
||||
fn the_line_scanner_finds_dependencies_and_ignores_other_sections() {
|
||||
let synthetic = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \
|
||||
\"1\"\nwgpu = \"0.19\"\n\n[dev-dependencies]\nwgpu = \"0.19\"\n";
|
||||
let names = dependency_names(synthetic);
|
||||
assert_eq!(names, vec!["serde".to_string(), "wgpu".to_string()]);
|
||||
}
|
||||
|
||||
/// Confirms the scanner (and by extension the test above) actually flags a
|
||||
/// denied name when one is present — otherwise `violations.is_empty()`
|
||||
/// could be vacuously true because the scanner finds nothing, not because
|
||||
/// the manifest is clean.
|
||||
#[test]
|
||||
fn a_synthetic_manifest_with_a_denied_dependency_is_flagged() {
|
||||
let synthetic = "[dependencies]\nserde = \"1\"\ntiny-skia = \"0.11\"\n";
|
||||
let names = dependency_names(synthetic);
|
||||
let violations: Vec<&String> = names
|
||||
.iter()
|
||||
.filter(|n| DENY_LIST.contains(&n.as_str()))
|
||||
.collect();
|
||||
assert_eq!(violations, vec![&"tiny-skia".to_string()]);
|
||||
}
|
||||
|
||||
// ---- F2: the dotted sub-table form, confirmed empirically to be missed ----
|
||||
//
|
||||
// Feeding the original scanner
|
||||
// `"[dependencies]\nserde = \"1\"\n\n[dependencies.wgpu]\nversion = \"0.19\"\n"`
|
||||
// returned `["serde"]` — `wgpu` never appeared, because the scanner only
|
||||
// recognized `[dependencies]` as a block header and had no notion of a
|
||||
// dependency named directly by its own `[...]` header. Each test below
|
||||
// would fail if `dependency_subtable_name`'s handling were removed (i.e.
|
||||
// if `dependency_names` fell back to the old block-only logic).
|
||||
|
||||
/// The bare sub-table form: `[dependencies.wgpu]`.
|
||||
#[test]
|
||||
fn the_scanner_detects_a_dependency_named_via_a_dotted_subtable_header() {
|
||||
let synthetic = "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n\n\
|
||||
[dependencies.wgpu]\nversion = \"0.19\"\n";
|
||||
let names = dependency_names(synthetic);
|
||||
assert!(
|
||||
names.contains(&"wgpu".to_string()),
|
||||
"sub-table form missed: {names:?}"
|
||||
);
|
||||
let violations: Vec<&String> = names
|
||||
.iter()
|
||||
.filter(|n| DENY_LIST.contains(&n.as_str()))
|
||||
.collect();
|
||||
assert_eq!(violations, vec![&"wgpu".to_string()]);
|
||||
}
|
||||
|
||||
/// The block form nested under a target: `[target.'cfg(unix)'.dependencies]`.
|
||||
#[test]
|
||||
fn the_scanner_detects_a_dependency_block_under_a_target_cfg_table() {
|
||||
let synthetic =
|
||||
"[dependencies]\nserde = \"1\"\n\n[target.'cfg(unix)'.dependencies]\nwgpu = \"0.19\"\n";
|
||||
let names = dependency_names(synthetic);
|
||||
assert!(
|
||||
names.contains(&"wgpu".to_string()),
|
||||
"target-cfg block form missed: {names:?}"
|
||||
);
|
||||
let violations: Vec<&String> = names
|
||||
.iter()
|
||||
.filter(|n| DENY_LIST.contains(&n.as_str()))
|
||||
.collect();
|
||||
assert_eq!(violations, vec![&"wgpu".to_string()]);
|
||||
}
|
||||
|
||||
/// Both forms combined: the sub-table form nested under a target,
|
||||
/// `[target.'cfg(windows)'.dependencies.tiny-skia]`.
|
||||
#[test]
|
||||
fn the_scanner_detects_a_dotted_subtable_header_under_a_target_cfg_table() {
|
||||
let synthetic = "[target.'cfg(windows)'.dependencies.tiny-skia]\nversion = \"0.11\"\n";
|
||||
let names = dependency_names(synthetic);
|
||||
assert!(
|
||||
names.contains(&"tiny-skia".to_string()),
|
||||
"target-cfg sub-table form missed: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dependency's own further sub-table (e.g. a spread-out `package`
|
||||
/// rename) must still resolve to the crate name, the first dotted segment
|
||||
/// after `dependencies.`, not the whole trailing path.
|
||||
#[test]
|
||||
fn a_deeper_dotted_path_still_resolves_to_the_leading_crate_name() {
|
||||
let synthetic = "[dependencies.serde.metadata]\nfoo = 1\n";
|
||||
let names = dependency_names(synthetic);
|
||||
assert_eq!(names, vec!["serde".to_string()]);
|
||||
}
|
||||
Loading…
Reference in New Issue