Merge editor-t4-packet2b: the T4 spike's Round 2 text packet
Five commits, all confined to spikes/editor-toolkit/. Built in a worktree so the shared crates/ tree could stay mid-edit for the genesis track without turning the spike gate red under review. The pin-13 apparatus (694d135) and its three schema amendments were each committed and user-reviewed before either candidate existed, so neither candidate could shape what it would be measured against.e2979dflands both candidates from one commit for the same reason.
This commit is contained in:
commit
f33673deef
|
|
@ -659,6 +659,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"epiphany-layout-ir",
|
||||
|
|
@ -667,18 +668,31 @@ dependencies = [
|
|||
"lyon_tessellation",
|
||||
"pollster",
|
||||
"round1-harness",
|
||||
"round2-candidatekit",
|
||||
"round2-diff",
|
||||
"round2-textkit",
|
||||
"serde_json",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "c2-vello"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_winit 0.33.2",
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"epiphany-layout-ir",
|
||||
"pollster",
|
||||
"round1-harness",
|
||||
"round2-candidatekit",
|
||||
"round2-diff",
|
||||
"round2-textkit",
|
||||
"serde_json",
|
||||
"ttf-parser",
|
||||
"vello",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3807,6 +3821,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
|
|
@ -25,3 +25,29 @@ lyon_tessellation = "1.0"
|
|||
pollster = "0.4"
|
||||
bytemuck = "1"
|
||||
anyhow = "1"
|
||||
|
||||
# --- Packet 2B-C1 additions (Round 2, text) ---
|
||||
#
|
||||
# round2-candidatekit / round2-diff / round2-textkit: the candidate-neutral
|
||||
# apparatus (fixture loading, the bounded visual differential, the report
|
||||
# shape and scoring rule) Round 2 requires every candidate to consume rather
|
||||
# than re-derive. See src/bin/round2_text.rs.
|
||||
round2-candidatekit = { path = "../../round2-candidatekit" }
|
||||
round2-diff = { path = "../../round2-diff" }
|
||||
round2-textkit = { path = "../../round2-textkit" }
|
||||
# Glyph outline extraction from the two declared host faces (TeX Gyre
|
||||
# Pagella, Liberation Serif) is candidate-owned work (recipe: "Outline
|
||||
# extraction from the face and conversion to a lyon path IS yours to
|
||||
# write"). Pinned to the exact version round2-textkit shapes fixtures
|
||||
# against, so a `ttf-parser` behavioural difference cannot be mistaken for a
|
||||
# rendering bug in this candidate's own code.
|
||||
ttf-parser = "=0.25.1"
|
||||
# Writing round2_candidatekit::CandidateReport to disk.
|
||||
serde_json = "1"
|
||||
# eframe/winit windowed route for check 5 (accessibility): Round 1's binary
|
||||
# is headless (offscreen wgpu only, no winit/eframe at all), and check 5
|
||||
# requires a real window on the live AT-SPI2 bus (recipe: "your Round 1
|
||||
# binary is headless, so this is a separate mode"). See
|
||||
# src/bin/round2_a11y.rs, the same first-party AccessKit route
|
||||
# `probe-egui`'s Round 0 binary used.
|
||||
eframe = "0.35"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"fixture_id": "F-A",
|
||||
"verdict": "PASS",
|
||||
"reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte",
|
||||
"observed_role": "label",
|
||||
"observed_name": "Allegro affettuoso \u2014 al fine",
|
||||
"observed_name_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65",
|
||||
"prohibited_outcome": null,
|
||||
"walked_tree": [
|
||||
"desktop / application:'c1_round2_a11y'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:''",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-A'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Allegro affettuoso \u2014 al fine'"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"fixture_id": "F-B",
|
||||
"verdict": "PASS",
|
||||
"reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte",
|
||||
"observed_role": "label",
|
||||
"observed_name": "Coro \u05d0\u05d1\u05d2",
|
||||
"observed_name_hex": "436f726f20d790d791d792",
|
||||
"prohibited_outcome": null,
|
||||
"walked_tree": [
|
||||
"desktop / application:'c1_round2_a11y'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:''",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-B'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Coro \u05d0\u05d1\u05d2'"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"fixture_id": "F-C",
|
||||
"verdict": "PASS",
|
||||
"reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte",
|
||||
"observed_role": "label",
|
||||
"observed_name": "Coro \u0627",
|
||||
"observed_name_hex": "436f726f20d8a7",
|
||||
"prohibited_outcome": null,
|
||||
"walked_tree": [
|
||||
"desktop / application:'c1_round2_a11y'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:''",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-C'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Coro \u0627'"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"fixture_id": "F-D",
|
||||
"verdict": "PASS",
|
||||
"reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte",
|
||||
"observed_role": "label",
|
||||
"observed_name": "Allegro \u05d0\u05d1\u05d2 con brio",
|
||||
"observed_name_hex": "416c6c6567726f20d790d791d79220636f6e206272696f",
|
||||
"prohibited_outcome": null,
|
||||
"walked_tree": [
|
||||
"desktop / application:'c1_round2_a11y'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:''",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-D'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Allegro \u05d0\u05d1\u05d2 con brio'"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"fixture_id": "F-E",
|
||||
"verdict": "PASS",
|
||||
"reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte",
|
||||
"observed_role": "label",
|
||||
"observed_name": "Cafe\u0301 \u2014 resume\u0301",
|
||||
"observed_name_hex": "43616665cc8120e2809420726573756d65cc81",
|
||||
"prohibited_outcome": null,
|
||||
"walked_tree": [
|
||||
"desktop / application:'c1_round2_a11y'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:''",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-E'",
|
||||
"desktop / application:'c1_round2_a11y' / frame:'' / label:'Cafe\u0301 \u2014 resume\u0301'"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,62 @@
|
|||
//! `ReportPart::AccessibilityIntegrationWiring`, half one — "getting that
|
||||
//! tree to the platform: adapter lifecycle, event-loop plumbing, window and
|
||||
//! bridge setup" (the F3 fix's own definition of this row). `a11y_subprocess.rs`
|
||||
//! is the other half (the subprocess orchestration of the verifier).
|
||||
//!
|
||||
//! This module owns the `eframe::App` impl, the window options, and the
|
||||
//! `eframe::run_native` call — the windowed route check 5 requires ("a real
|
||||
//! window on the AT-SPI bus", the contract's own words) that Round 1's
|
||||
//! headless binary does not have. It contains **no** semantic node-building
|
||||
//! logic of its own (that is `a11y_node.rs`, which this module calls into)
|
||||
//! and **no** visual rendering (dropped from an earlier revision of this
|
||||
//! packet — see `a11y_node.rs`'s doc comment for why).
|
||||
|
||||
use round2_textkit::types::SpikeResolvedText;
|
||||
|
||||
pub struct A11yApp {
|
||||
pub fixture_id: String,
|
||||
pub resolved: SpikeResolvedText,
|
||||
}
|
||||
|
||||
impl eframe::App for A11yApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
ui.label(format!("Round 2 check 5 — fixture {}", self.fixture_id));
|
||||
ui.separator();
|
||||
|
||||
let rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(900.0, 140.0));
|
||||
crate::a11y_node::build_text_run_node(ui, &self.fixture_id, rect, &self.resolved.text);
|
||||
|
||||
// Keep repainting: AT-SPI clients query live state, and there is no
|
||||
// other event source driving redraws in this minimal app.
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the window and runs the event loop until the process is killed
|
||||
/// (`a11y_subprocess.rs` is the one that kills it, once `verify.py` has read
|
||||
/// the tree). The `eframe::run_native` app-id string
|
||||
/// (`"EpiphanyRound2C1"`) is **not** what AT-SPI names the application —
|
||||
/// AT-SPI's own application name tracks the process/binary name (measured
|
||||
/// against `round0-evidence/c1-egui-readback.txt`'s precedent and
|
||||
/// re-confirmed for this packet's own binary name); `a11y_subprocess.rs`'s
|
||||
/// `A11Y_APP_NAME` constant is what actually has to match.
|
||||
pub fn run(fixture_id: String, resolved: SpikeResolvedText) -> eframe::Result {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([960.0, 260.0])
|
||||
.with_title(format!("EpiphanyRound2C1 {fixture_id}")),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"EpiphanyRound2C1",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Ok(Box::new(A11yApp {
|
||||
fixture_id,
|
||||
resolved,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
//! `ReportPart::AccessibilityTreeConstruction`, **semantic content only** —
|
||||
//! the F3 fix's own definition of this row: "building the accessible
|
||||
//! node(s): role, name, relationships, derived from the resolved text."
|
||||
//!
|
||||
//! Deliberately carries **no** window/event-loop/lifecycle code (that is
|
||||
//! `a11y_app.rs`, counted under `AccessibilityIntegrationWiring`) and **no**
|
||||
//! visual rendering at all — an earlier revision of this packet painted the
|
||||
//! fixture's glyphs in the same file that built the AccessKit node, which is
|
||||
//! exactly the kind of file-level mixing the F3 finding named as the defect:
|
||||
//! a 190-line file that was mostly window setup, font loading, and
|
||||
//! rendering, reported as if it were 190 lines of semantic tree
|
||||
//! construction. This module is `AccessibilityTreeConstruction`, full stop;
|
||||
//! it does not draw anything, and check 5 does not require it to (the
|
||||
//! visual glyph mesh was cosmetic — "for visual confirmation only. Not read
|
||||
//! by verify.py" — dropped here rather than kept and mis-attributed).
|
||||
//!
|
||||
//! **The accessible name is never derived from egui's own text layout.**
|
||||
//! The node is built directly via `egui::Context::accesskit_node_builder` on
|
||||
//! an `Id` that carries no text layout of its own
|
||||
//! (`ui.interact(rect, id, Sense::hover())`), so the bytes reaching AT-SPI
|
||||
//! are exactly the fixture's source string, untouched by galley
|
||||
//! construction, wrapping, or any Unicode normalization egui's text stack
|
||||
//! might otherwise apply — which is exactly what F-E (NFD) and F-C (an
|
||||
//! uncovered codepoint that still must appear in the name) test.
|
||||
|
||||
use egui::accesskit::{Node, Rect as AkRect, Role};
|
||||
|
||||
/// The AccessKit role this candidate exposes the run under — `Label`, whose
|
||||
/// accessible name is read from `Node::value` (per `accesskit`'s own doc
|
||||
/// comment on `Node::set_label`: "the text content of a node with the
|
||||
/// `Role::Label` role should be provided via `Node::value`, not this
|
||||
/// property"), and which `accesskit_atspi_common` maps to AT-SPI role
|
||||
/// `"label"` — one of the accepted at-spi2 tokens
|
||||
/// (`round2_textkit::a11y::ACCEPTED_ROLE_TABLE`).
|
||||
pub const NODE_ROLE: Role = Role::Label;
|
||||
|
||||
/// Builds one AccessKit node carrying `source_text` byte-for-byte as its
|
||||
/// accessible name, at `rect`, allocated under `ui`'s current accesskit
|
||||
/// parent (`ui.interact` registers the `Id` as an accesskit child of the
|
||||
/// enclosing `Ui` — see `egui::Ui::interact`'s own implementation).
|
||||
///
|
||||
/// The `Id` is stable per fixture (`("epiphany_round2_text_run",
|
||||
/// fixture_id)`), so repeated calls across frames update the same node
|
||||
/// rather than accumulating duplicates.
|
||||
pub fn build_text_run_node(
|
||||
ui: &mut egui::Ui,
|
||||
fixture_id: &str,
|
||||
rect: egui::Rect,
|
||||
source_text: &str,
|
||||
) {
|
||||
let id = egui::Id::new(("epiphany_round2_text_run", fixture_id));
|
||||
let _response = ui.interact(rect, id, egui::Sense::hover());
|
||||
let name = source_text.to_string();
|
||||
ui.ctx().accesskit_node_builder(id, |node: &mut Node| {
|
||||
node.set_role(NODE_ROLE);
|
||||
node.set_value(name.clone());
|
||||
node.set_bounds(AkRect {
|
||||
x0: rect.min.x as f64,
|
||||
y0: rect.min.y as f64,
|
||||
x1: rect.max.x as f64,
|
||||
y1: rect.max.y as f64,
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,58 @@
|
|||
//! `ReportPart::FixtureAndReportPlumbing` — CLI parsing and fixture loading
|
||||
//! for the check-5 windowed probe. The window/event-loop/adapter-lifecycle
|
||||
//! code is `c1_egui_lyon::a11y_app` (`AccessibilityIntegrationWiring`); the
|
||||
//! accessible node itself is `c1_egui_lyon::a11y_node`
|
||||
//! (`AccessibilityTreeConstruction`). This file is deliberately thin: it
|
||||
//! reads `--fixture`, loads that one fixture's resolved text from the
|
||||
//! frozen `fixtures.json`, and hands off.
|
||||
//!
|
||||
//! Round 1's binary (and `c1_round2_text.rs`) are headless: offscreen wgpu
|
||||
//! only, no window, nothing an AT-SPI client could ever see. Check 5
|
||||
//! requires "a real window on the AT-SPI bus" (the contract's own words),
|
||||
//! so this is a second, separate windowed mode — the same first-party
|
||||
//! AccessKit route `probe-egui`'s Round 0 binary demonstrated a readback
|
||||
//! for (see `round0-evidence/c1-egui-readback.txt`).
|
||||
//!
|
||||
//! **This binary carries no visual rendering.** An earlier revision of this
|
||||
//! packet painted the fixture's glyphs here too, "for visual confirmation
|
||||
//! only" — which is exactly the kind of non-semantic content the F3 finding
|
||||
//! named as wrongly mixed into this binary's `ReportPart` attribution.
|
||||
//! Scoring is not this binary's job either way: `a11y-verifier/verify.py`,
|
||||
//! run out-of-process against the live AT-SPI2 bus by `c1_round2_text.rs`
|
||||
//! (`c1_egui_lyon::a11y_subprocess`), is the actual readback and verdict.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn spike_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn parse_fixture_arg() -> String {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
if args[i] == "--fixture" && i + 1 < args.len() {
|
||||
return args[i + 1].clone();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
eprintln!("usage: c1_round2_a11y --fixture <F-A|F-B|F-C|F-D|F-E>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let fixture_id = parse_fixture_arg();
|
||||
let root = spike_root();
|
||||
|
||||
let fixtures_path = root.join("round2-textkit/fixtures.json");
|
||||
let fixtures = round2_textkit::output::load_fixtures(&fixtures_path)
|
||||
.unwrap_or_else(|e| panic!("failed to load {}: {e}", fixtures_path.display()));
|
||||
let record = fixtures
|
||||
.fixtures
|
||||
.iter()
|
||||
.find(|f| f.id == fixture_id)
|
||||
.unwrap_or_else(|| panic!("no fixture {fixture_id:?} in fixtures.json"));
|
||||
let resolved = record.resolved.clone();
|
||||
|
||||
c1_egui_lyon::a11y_app::run(fixture_id, resolved)
|
||||
}
|
||||
|
|
@ -0,0 +1,746 @@
|
|||
//! Packet 2B-C1 — Round 2 (text), candidate **C1 (egui + lyon)**.
|
||||
//!
|
||||
//! Drives checks 1 (faithful consumption), 2 (fallback, forced), 4 (hit
|
||||
//! testing), and 5 (accessibility, via subprocess orchestration of
|
||||
//! `bin/c1_round2_a11y.rs` + `a11y-verifier/verify.py`,
|
||||
//! `c1_egui_lyon::a11y_subprocess`) against the frozen Round 2 text
|
||||
//! apparatus (`round2-candidatekit`, `round2-textkit`, `round2-diff`), and
|
||||
//! writes a `round2_candidatekit::CandidateReport` to `round2_report.json`
|
||||
//! in this crate's own directory.
|
||||
//!
|
||||
//! **Separate from `src/main.rs`**, the Round 1 binary, which this packet
|
||||
//! does not touch. This binary renders the resolved text data offscreen —
|
||||
//! it never calls any egui text-layout API, font-fallback API, or
|
||||
//! `rustybuzz`; glyph ids and positions come straight from
|
||||
//! `SpikeResolvedText` (pin 8's fixture data), and outline extraction /
|
||||
//! lyon-path conversion is this candidate's own work
|
||||
//! (`c1_egui_lyon::glyph_outline`, `c1_egui_lyon::render_target`).
|
||||
//!
|
||||
//! `ReportPart::FixtureAndReportPlumbing` (F3): this file itself is
|
||||
//! apparatus loading, check 1/2/4 scoring, and report assembly — the actual
|
||||
//! rendering pipeline lives in `c1_egui_lyon::render_target`
|
||||
//! (`TextRendering`) and the check-5 subprocess orchestration lives in
|
||||
//! `c1_egui_lyon::a11y_subprocess`. **Reattribution (user ruling):**
|
||||
//! `a11y_subprocess.rs` is now `FixtureAndReportPlumbing`, not
|
||||
//! `AccessibilityIntegrationWiring` — it is the verifier-subprocess harness
|
||||
//! (spawning, decoding `verify.py`'s output, freshness/publish handling),
|
||||
//! common to both candidates and not part of either stack's own
|
||||
//! accessibility integration. Only `c1_egui_lyon::a11y_app`
|
||||
//! (`AccessibilityIntegrationWiring`) is this candidate's own
|
||||
//! adapter/window/event-loop wiring; this file calls into both.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use c1_egui_lyon::hit_test;
|
||||
use c1_egui_lyon::render_target::{self, GpuCtx};
|
||||
|
||||
use round2_candidatekit::{
|
||||
scoring, AdapterStatus, CandidateReport, CostRecord, DependencyDelta, DiffReportRecord,
|
||||
HitTestProbeResult, IntegrationOwnership, LocByPart, ReportPart,
|
||||
};
|
||||
use round2_diff::GlyphRegion;
|
||||
use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace};
|
||||
use round2_textkit::hittest::DevicePoint;
|
||||
|
||||
const BASELINE_COMMIT: &str = "c20bc93";
|
||||
const CANDIDATE_ID: &str = "C1 egui 0.35 + lyon 1.0 (egui_wgpu::Renderer)";
|
||||
|
||||
fn spike_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let root = spike_root();
|
||||
println!("== Packet 2B-C1: Round 2 text, candidate C1 (egui + lyon) ==");
|
||||
println!("spike root: {}", root.display());
|
||||
|
||||
let inputs = round2_candidatekit::load_all(&root)
|
||||
.map_err(|e| anyhow!("failed to load candidate-neutral apparatus: {e}"))?;
|
||||
println!(
|
||||
"loaded {} fixtures, {} hit-test probe tables, {} reference rasters",
|
||||
inputs.fixtures.fixtures.len(),
|
||||
inputs.hittest_probes.fixtures.len(),
|
||||
inputs.reference.len()
|
||||
);
|
||||
|
||||
// Resolve the two declared faces. Both are present on this machine
|
||||
// (measured; see the recipe §1 hashes) — a missing face here would be
|
||||
// pin 14's environmental NOT RUN, but since rendering, hit testing, and
|
||||
// accessibility all key off the SAME resolved `fixtures.json` (which
|
||||
// itself required both faces to generate), a missing face at this
|
||||
// point would make every check NOT RUN, not just one, so this is
|
||||
// treated as a fatal precondition rather than folded into any one
|
||||
// check's outcome.
|
||||
let resolved_chain = resolve_declared_chain();
|
||||
let mut loaded_faces: Vec<LoadedFace> = Vec::new();
|
||||
for r in resolved_chain {
|
||||
match r {
|
||||
FaceResolution::Loaded(lf) => loaded_faces.push(lf),
|
||||
FaceResolution::Missing { path } => {
|
||||
return Err(anyhow!(
|
||||
"NOT RUN: declared face missing at {} — environment absence (pin 14), not a \
|
||||
candidate failure; every Round 2 check requires both declared faces",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ttf_faces: Vec<ttf_parser::Face> = loaded_faces
|
||||
.iter()
|
||||
.map(|lf| {
|
||||
ttf_parser::Face::parse(&lf.bytes, lf.identity.face_index)
|
||||
.expect("face bytes already validated by resolve_declared_chain")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut gpu: GpuCtx = render_target::build_gpu()?;
|
||||
println!(
|
||||
"GPU adapter: {} ({})",
|
||||
gpu.adapter_name, gpu.adapter_device_type
|
||||
);
|
||||
|
||||
// ---- Checks 1 & 2: render + diff every fixture ----
|
||||
let mut per_fixture_diffs: BTreeMap<String, DiffReportRecord> = BTreeMap::new();
|
||||
let mut unresolved_by_fixture: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let mut check1_failures: Vec<String> = Vec::new();
|
||||
|
||||
for f in &inputs.fixtures.fixtures {
|
||||
let rt = &f.resolved;
|
||||
let draw = render_target::draw_fixture(&mut gpu, rt, &ttf_faces)
|
||||
.with_context(|| format!("{}: render failed", f.id))?;
|
||||
unresolved_by_fixture.insert(f.id.clone(), draw.unresolved_segments.clone());
|
||||
|
||||
let reference = &inputs.reference[&f.id];
|
||||
let regions: Vec<GlyphRegion> = reference.regions.clone();
|
||||
let diff = round2_diff::diff(
|
||||
&reference.reference_rgba,
|
||||
&draw.rgba,
|
||||
render_target::WIDTH,
|
||||
render_target::HEIGHT,
|
||||
®ions,
|
||||
)
|
||||
.map_err(|e| anyhow!("{}: diff failed: {e}", f.id))?;
|
||||
|
||||
println!(
|
||||
"{}: D1={} D2={:.4}% D3={:?} D4_worst={:?} pass={}",
|
||||
f.id,
|
||||
diff.d1_pixels_outside_band_differing,
|
||||
diff.d2_relative_delta * 100.0,
|
||||
diff.d3_delta,
|
||||
diff.d4_worst
|
||||
.as_ref()
|
||||
.map(|w| (w.label.clone(), w.relative_delta)),
|
||||
diff.pass()
|
||||
);
|
||||
if !diff.pass() {
|
||||
check1_failures.push(format!(
|
||||
"{}: d1_pass={} d2_pass={} d3_pass={:?} d4_pass={}",
|
||||
f.id, diff.d1_pass, diff.d2_pass, diff.d3_pass, diff.d4_pass
|
||||
));
|
||||
}
|
||||
per_fixture_diffs.insert(f.id.clone(), DiffReportRecord::from(&diff));
|
||||
}
|
||||
|
||||
let check1_faithful_consumption = if check1_failures.is_empty() {
|
||||
round2_candidatekit::CheckOutcome::Pass
|
||||
} else {
|
||||
round2_candidatekit::CheckOutcome::fail(format!(
|
||||
"bounded visual differential failed for: {}",
|
||||
check1_failures.join("; ")
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// ---- Check 2: fallback, forced ----
|
||||
// F-C's U+0627 must resolve to face:None (reported explicitly, never
|
||||
// substituted) and F-B/F-C's renders must still match the reference
|
||||
// (proving the *rest* of the declared chain — including the traversal
|
||||
// to face 1 for Hebrew — was followed faithfully, not host-substituted).
|
||||
let fc_unresolved = unresolved_by_fixture
|
||||
.get("F-C")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let fb_pass = per_fixture_diffs
|
||||
.get("F-B")
|
||||
.map(|d| d.pass)
|
||||
.unwrap_or(false);
|
||||
let fc_pass = per_fixture_diffs
|
||||
.get("F-C")
|
||||
.map(|d| d.pass)
|
||||
.unwrap_or(false);
|
||||
println!("F-C unresolved segments (check 2 evidence): {fc_unresolved:?}");
|
||||
|
||||
let check2_fallback = if fc_unresolved.is_empty() {
|
||||
round2_candidatekit::CheckOutcome::fail(
|
||||
"F-C produced no unresolved (face: None) segment at all — expected U+0627 to be \
|
||||
explicitly reported as uncovered by the declared chain",
|
||||
)
|
||||
.unwrap()
|
||||
} else if !fb_pass || !fc_pass {
|
||||
round2_candidatekit::CheckOutcome::fail(format!(
|
||||
"F-B pass={fb_pass}, F-C pass={fc_pass} — the declared fallback chain was not \
|
||||
rendered faithfully"
|
||||
))
|
||||
.unwrap()
|
||||
} else {
|
||||
round2_candidatekit::CheckOutcome::Pass
|
||||
};
|
||||
|
||||
// ---- Check 4: hit testing ----
|
||||
let mut hittest_probe_results = Vec::new();
|
||||
let mut check4_fail_count = 0usize;
|
||||
for ft in &inputs.hittest_probes.fixtures {
|
||||
let rt = &inputs
|
||||
.fixtures
|
||||
.fixtures
|
||||
.iter()
|
||||
.find(|f| f.id == ft.fixture_id)
|
||||
.unwrap()
|
||||
.resolved;
|
||||
for p in &ft.probes {
|
||||
let point = DevicePoint {
|
||||
x: p.point.x,
|
||||
y: p.point.y,
|
||||
};
|
||||
let answer = hit_test::resolve(rt, point);
|
||||
let pass = answer.source_offset == p.expected_source_offset
|
||||
&& answer.affinity == p.expected_affinity;
|
||||
if !pass {
|
||||
check4_fail_count += 1;
|
||||
}
|
||||
hittest_probe_results.push(HitTestProbeResult {
|
||||
fixture_id: ft.fixture_id.clone(),
|
||||
point: p.point,
|
||||
expected_source_offset: p.expected_source_offset,
|
||||
expected_affinity: p.expected_affinity,
|
||||
actual_source_offset: answer.source_offset,
|
||||
actual_affinity: answer.affinity,
|
||||
pass,
|
||||
});
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"check 4: {}/{} probes passed",
|
||||
hittest_probe_results.len() - check4_fail_count,
|
||||
hittest_probe_results.len()
|
||||
);
|
||||
let check4_hit_testing = if check4_fail_count == 0 {
|
||||
round2_candidatekit::CheckOutcome::Pass
|
||||
} else {
|
||||
round2_candidatekit::CheckOutcome::fail(format!(
|
||||
"{check4_fail_count}/{} hit-test probes disagreed with the committed expected answer",
|
||||
hittest_probe_results.len()
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// ---- Supplementary F-D bidi row (never reaches check 3's cell) ----
|
||||
let fd_pass = per_fixture_diffs
|
||||
.get("F-D")
|
||||
.map(|d| d.pass)
|
||||
.unwrap_or(false);
|
||||
let fd_probe_count = hittest_probe_results
|
||||
.iter()
|
||||
.filter(|r| r.fixture_id == "F-D")
|
||||
.count();
|
||||
let fd_probe_fail = hittest_probe_results
|
||||
.iter()
|
||||
.filter(|r| r.fixture_id == "F-D" && !r.pass)
|
||||
.count();
|
||||
let supplementary_f_d_bidi = if fd_pass && fd_probe_fail == 0 {
|
||||
round2_candidatekit::CheckOutcome::Pass
|
||||
} else {
|
||||
round2_candidatekit::CheckOutcome::fail(format!(
|
||||
"F-D diff pass={fd_pass}, hit-test probes {fd_probe_fail}/{fd_probe_count} failed"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// ---- Check 5: accessibility, out-of-process (F1/F2 fixes live in
|
||||
// c1_egui_lyon::a11y_subprocess) ----
|
||||
let exe_dir = std::env::current_exe()?
|
||||
.parent()
|
||||
.expect("executable has a parent directory")
|
||||
.to_path_buf();
|
||||
let a11y_bin = exe_dir.join("c1_round2_a11y");
|
||||
if !a11y_bin.exists() {
|
||||
return Err(anyhow!(
|
||||
"{} does not exist — build it first (cargo build -p c1-egui-lyon --bin \
|
||||
c1_round2_a11y)",
|
||||
a11y_bin.display()
|
||||
));
|
||||
}
|
||||
let evidence_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("round2_a11y_evidence");
|
||||
let fixture_ids: Vec<String> = inputs
|
||||
.fixtures
|
||||
.fixtures
|
||||
.iter()
|
||||
.map(|f| f.id.clone())
|
||||
.collect();
|
||||
let check5 =
|
||||
c1_egui_lyon::a11y_subprocess::run_all(&root, &a11y_bin, &evidence_dir, &fixture_ids)?;
|
||||
|
||||
// ---- Cost record ----
|
||||
let cost = CostRecord {
|
||||
baseline_commit: BASELINE_COMMIT.to_string(),
|
||||
dependencies_added: vec![
|
||||
DependencyDelta {
|
||||
name: "round2-candidatekit".to_string(),
|
||||
version: "0.1.0 (path)".to_string(),
|
||||
reason: "candidate-neutral fixture/oracle loading and the shared report shape / \
|
||||
scoring rule Round 2 requires every candidate to consume rather than \
|
||||
re-derive."
|
||||
.to_string(),
|
||||
},
|
||||
DependencyDelta {
|
||||
name: "round2-diff".to_string(),
|
||||
version: "0.1.0 (path)".to_string(),
|
||||
reason: "the precommitted bounded visual differential (D1-D4) checks 1/2 score \
|
||||
against."
|
||||
.to_string(),
|
||||
},
|
||||
DependencyDelta {
|
||||
name: "round2-textkit".to_string(),
|
||||
version: "0.1.0 (path)".to_string(),
|
||||
reason: "SpikeResolvedText fixtures, the staff->device transform \
|
||||
(hittest::to_device), the hit-test probe table, and face resolution."
|
||||
.to_string(),
|
||||
},
|
||||
DependencyDelta {
|
||||
name: "ttf-parser".to_string(),
|
||||
version: "=0.25.1".to_string(),
|
||||
reason: "candidate-owned glyph outline extraction from the two declared host \
|
||||
faces (not from Bravura's typed PathCommand data, which Round 1 used) — \
|
||||
pinned to the exact version round2-textkit shapes fixtures against."
|
||||
.to_string(),
|
||||
},
|
||||
DependencyDelta {
|
||||
name: "serde_json".to_string(),
|
||||
version: "1".to_string(),
|
||||
reason: "serializing CandidateReport to round2_report.json, and parsing \
|
||||
verify.py's --json output."
|
||||
.to_string(),
|
||||
},
|
||||
DependencyDelta {
|
||||
name: "eframe".to_string(),
|
||||
version: "0.35".to_string(),
|
||||
reason: "check 5 needs a real window on the live AT-SPI2 bus; Round 1's binary \
|
||||
is headless (offscreen wgpu only, no winit/eframe at all). Same first-party \
|
||||
AccessKit route probe-egui's Round 0 binary used."
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
adapters: vec![
|
||||
AdapterStatus::Implemented {
|
||||
platform: "at-spi2".to_string(),
|
||||
notes: "the round's own platform on this Linux/Wayland machine — verified via \
|
||||
a11y-verifier/verify.py's live, out-of-process AT-SPI2 readback for all five \
|
||||
fixtures (round2_a11y_evidence/*.json)."
|
||||
.to_string(),
|
||||
integration_ownership: IntegrationOwnership::inherited(
|
||||
"eframe 0.35 -> egui-winit -> accesskit_winit -> accesskit_unix \
|
||||
(the AT-SPI2 adapter and its lifecycle ship with eframe; this candidate \
|
||||
wrote none of that plumbing)",
|
||||
)
|
||||
.expect("provider is a non-empty literal"),
|
||||
},
|
||||
AdapterStatus::Implemented {
|
||||
platform: "accesskit-0.24".to_string(),
|
||||
notes: "reached and exercised — every check-5 readback below travelled this \
|
||||
path. What this candidate does write on top of the inherited integration is \
|
||||
the accessible node for its own canvas-painted run — counted under \
|
||||
ReportPart::AccessibilityTreeConstruction (c1_egui_lyon::a11y_node), not \
|
||||
here."
|
||||
.to_string(),
|
||||
integration_ownership: IntegrationOwnership::inherited(
|
||||
"eframe 0.35 (bundled AccessKit integration; accesskit was already \
|
||||
in this crate's Round 1 dependency graph at c20bc93 via egui 0.35)",
|
||||
)
|
||||
.expect("provider is a non-empty literal"),
|
||||
},
|
||||
AdapterStatus::NotBuilt {
|
||||
platform: "aria".to_string(),
|
||||
reason: "no web/ARIA target exists for this candidate — egui/eframe here is a \
|
||||
native desktop app, not a web build."
|
||||
.to_string(),
|
||||
},
|
||||
AdapterStatus::NotBuilt {
|
||||
platform: "macos-nsaccessibility".to_string(),
|
||||
reason: "no macOS runner available in this environment.".to_string(),
|
||||
},
|
||||
AdapterStatus::NotBuilt {
|
||||
platform: "windows-uia".to_string(),
|
||||
reason: "no Windows runner available in this environment.".to_string(),
|
||||
},
|
||||
],
|
||||
integration_wiring: vec![
|
||||
"glyph_outline.rs: ttf_parser::OutlineBuilder callbacks converted directly to a \
|
||||
lyon_path::Path in device space (no PathCommand/SVG intermediate), tessellated \
|
||||
with lyon's NonZero fill rule as one compound path per glyph so bounded holes \
|
||||
(e.g. 'o', 'e') survive."
|
||||
.to_string(),
|
||||
"render_target.rs: the offscreen egui_wgpu render target (device/adapter setup, \
|
||||
MSAA/resolve texture pair, render pass, CPU readback) checks 1/2 draw into."
|
||||
.to_string(),
|
||||
"hit_test.rs: a hand-written floor-search resolver over the resolved text's own \
|
||||
Downstream caret-stop partition, reusing only round2_textkit::hittest::to_device \
|
||||
for the shared staff->device transform — no other apparatus from the probe \
|
||||
generator is called."
|
||||
.to_string(),
|
||||
format!(
|
||||
"check 2 evidence: F-C's U+0627 (byte range recorded per-fixture below) is \
|
||||
explicitly detected via SpikeShapedSegment::face == None in \
|
||||
render_target::draw_fixture(), which asserts its glyph list is empty and \
|
||||
records the span in round2_report.json's console log rather than silently \
|
||||
drawing nothing — this candidate's fixture-level trace is: F-C unresolved \
|
||||
segments = {fc_unresolved:?}"
|
||||
),
|
||||
"a11y_node.rs: a custom AccessKit node (Role::Label, value = the fixture's exact \
|
||||
source string) built directly via egui::Context::accesskit_node_builder on an \
|
||||
Id allocated with ui.interact(..., Sense::hover()) — bypassing egui's Label \
|
||||
widget and its own text-layout/galley construction entirely, so the accessible \
|
||||
name is never touched by anything that could re-shape, wrap, or normalize it."
|
||||
.to_string(),
|
||||
"a11y_app.rs (ReportPart::AccessibilityIntegrationWiring — this candidate's own \
|
||||
integration, and the only file counted under this row): the eframe::App/window/ \
|
||||
event-loop wiring the check-5 windowed probe runs under (no visual glyph \
|
||||
rendering — dropped by the F3 fix, see that file's own doc comment)."
|
||||
.to_string(),
|
||||
"a11y_subprocess.rs (ReportPart::FixtureAndReportPlumbing, reattributed by user \
|
||||
ruling: a verifier-subprocess harness common to both Round 2 candidates, not \
|
||||
part of either stack's own accessibility integration): subprocess orchestration \
|
||||
spawning c1_round2_a11y once per fixture and invoking a11y-verifier/verify.py \
|
||||
out-of-process for the live AT-SPI2 readback (never a same-process self-report). \
|
||||
F1: every invocation writes to a fresh, unique path and requires the exit status \
|
||||
and JSON verdict to agree, erroring hard on disagreement rather than trusting \
|
||||
either. G1: admission of an exit-2 NOT RUN requires both the exact prefix and an \
|
||||
approved environmental-cause marker (an allow-list, not a deny-list). F2: any \
|
||||
FAIL across the fixture set wins over any NotRun in the final aggregate, \
|
||||
regardless of which was observed first. G3: validation (in the system temp \
|
||||
directory) and publishing (one canonical file per fixture, overwriting) are \
|
||||
separate steps, so evidence never accumulates."
|
||||
.to_string(),
|
||||
],
|
||||
loc_by_part: loc_by_part()?,
|
||||
};
|
||||
|
||||
let report = CandidateReport {
|
||||
candidate_id: CANDIDATE_ID.to_string(),
|
||||
check1_faithful_consumption,
|
||||
check2_fallback,
|
||||
check3_bidi: round2_candidatekit::CheckOutcome::not_run(scoring::CHECK_3_RULING).unwrap(),
|
||||
check4_hit_testing,
|
||||
check5_accessibility: check5.check5_accessibility,
|
||||
check5_bus_unreachable_evidence: check5.check5_bus_unreachable_evidence,
|
||||
supplementary_f_d_bidi,
|
||||
per_fixture_diffs,
|
||||
hittest_probe_results,
|
||||
a11y_evidence: check5.a11y_evidence,
|
||||
cost,
|
||||
};
|
||||
|
||||
let cell = scoring::criterion_cell(&report);
|
||||
let eligible = scoring::is_eligible(&report);
|
||||
println!("\n== Round 2 criterion cell: {cell:?} ==");
|
||||
println!("== eligible (disqualifying set passed): {eligible} ==");
|
||||
|
||||
let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("round2_report.json");
|
||||
let json = serde_json::to_string_pretty(&report)?;
|
||||
std::fs::write(&out_path, json)?;
|
||||
println!("wrote {}", out_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `src/main.rs` is Round 1's frozen binary — untouched by this packet, not
|
||||
/// part of Round 2's cost table at all — and is the **only** file under
|
||||
/// `src/` this mapping is allowed to leave unclaimed. Named as a constant
|
||||
/// rather than an inline literal so [`check_mapping_exhaustive_and_disjoint`]
|
||||
/// and its tests refer to the same one string.
|
||||
const FROZEN_ROUND1_FILE: &str = "src/main.rs";
|
||||
|
||||
/// G2: the file-to-`ReportPart` mapping must be **exhaustive**, not merely
|
||||
/// disjoint — every `.rs` file under `src/` (`src/main.rs` excepted, see
|
||||
/// [`FROZEN_ROUND1_FILE`]) must be claimed by **exactly one** part. A file
|
||||
/// silently omitted (as `src/lib.rs` was, before this fix) understates the
|
||||
/// packet total and makes this table disagree with a sibling candidate's
|
||||
/// equivalent table about what it even counts. Returns every problem found
|
||||
/// (never just the first), each naming the specific file.
|
||||
fn check_mapping_exhaustive_and_disjoint(
|
||||
all_files: &[String],
|
||||
mapping: &[(&str, &[&str])],
|
||||
) -> Result<()> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut claimed_by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (part, files) in mapping {
|
||||
for f in *files {
|
||||
claimed_by.entry(f).or_default().push(part);
|
||||
}
|
||||
}
|
||||
|
||||
let mut problems = Vec::new();
|
||||
for f in all_files {
|
||||
match claimed_by.get(f.as_str()) {
|
||||
None => problems.push(format!("{f}: unclaimed by any ReportPart")),
|
||||
Some(parts) if parts.len() > 1 => {
|
||||
problems.push(format!("{f}: claimed by multiple parts: {parts:?}"))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (f, parts) in &claimed_by {
|
||||
if !all_files.iter().any(|a| a == f) {
|
||||
problems.push(format!(
|
||||
"{f}: claimed by {parts:?} but not found under src/ (stale mapping entry?)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !problems.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"ReportPart file mapping is not exhaustive/disjoint over src/:\n {}",
|
||||
problems.join("\n ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every `.rs` file under `dir`'s `src/`, recursively, as `src/...`-relative
|
||||
/// paths — `src/main.rs` excluded (see [`FROZEN_ROUND1_FILE`]).
|
||||
fn list_rs_files_under_src(dir: &Path) -> Result<Vec<String>> {
|
||||
fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, base, out)?;
|
||||
} else if path.extension().map(|e| e == "rs").unwrap_or(false) {
|
||||
let rel = path
|
||||
.strip_prefix(base)
|
||||
.expect("walked path is under base")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(&dir.join("src"), dir, &mut files)?;
|
||||
files.retain(|f| f != FROZEN_ROUND1_FILE);
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// LOC per shared `ReportPart`, from this crate's own new Round 2 files
|
||||
/// (`wc -l` equivalents, computed at run time so the figure never drifts
|
||||
/// from what is actually on disk).
|
||||
///
|
||||
/// **F3: one `ReportPart` maps to a disjoint set of whole files — no file
|
||||
/// contributes to two parts.** **G2: the mapping is also exhaustive** —
|
||||
/// [`check_mapping_exhaustive_and_disjoint`] fails loudly, naming the file,
|
||||
/// if anything under `src/` (besides `src/main.rs`) is left unclaimed or
|
||||
/// claimed twice, so a mapping that silently omits a file (as `src/lib.rs`
|
||||
/// was) can no longer happen unnoticed. The mapping is printed (not only
|
||||
/// asserted) so it can be checked against the module layout directly.
|
||||
fn loc_by_part() -> Result<Vec<LocByPart>> {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let count = |rel: &str| -> Result<u64> {
|
||||
Ok(std::fs::read_to_string(dir.join(rel))?.lines().count() as u64)
|
||||
};
|
||||
|
||||
// Reattribution (user ruling): AccessibilityIntegrationWiring holds only
|
||||
// the product-side path — adapter lifecycle, event loop, window/bridge
|
||||
// setup, tree publication. The verifier-subprocess harness
|
||||
// (a11y_subprocess.rs: spawning, result decoding/reduction, freshness
|
||||
// and canonical-publish handling, their mutation tests) is common to
|
||||
// both Round 2 candidates and not part of either stack's own
|
||||
// accessibility integration, so it counts as FixtureAndReportPlumbing —
|
||||
// never `Other`, which the ruling reserves for genuinely
|
||||
// candidate-specific seams, and this harness is not one.
|
||||
let mapping: [(&str, &[&str]); 5] = [
|
||||
(
|
||||
"TextRendering",
|
||||
&["src/glyph_outline.rs", "src/render_target.rs"],
|
||||
),
|
||||
("HitTestResolution", &["src/hit_test.rs"]),
|
||||
("AccessibilityTreeConstruction", &["src/a11y_node.rs"]),
|
||||
("AccessibilityIntegrationWiring", &["src/a11y_app.rs"]),
|
||||
(
|
||||
"FixtureAndReportPlumbing",
|
||||
&[
|
||||
"src/lib.rs",
|
||||
"src/a11y_subprocess.rs",
|
||||
"src/bin/c1_round2_text.rs",
|
||||
"src/bin/c1_round2_a11y.rs",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let all_files = list_rs_files_under_src(&dir)?;
|
||||
check_mapping_exhaustive_and_disjoint(&all_files, &mapping)?;
|
||||
|
||||
println!("\n== ReportPart file mapping (F3 disjoint, G2 exhaustive) ==");
|
||||
let mut rows = Vec::with_capacity(mapping.len());
|
||||
for (label, files) in mapping {
|
||||
let mut total = 0u64;
|
||||
for f in files {
|
||||
let n = count(f)?;
|
||||
println!(" {label:<32} {f:<32} {n:>5} lines");
|
||||
total += n;
|
||||
}
|
||||
rows.push(total);
|
||||
}
|
||||
let packet_total: u64 = rows.iter().sum();
|
||||
println!(" {:<32} {:<32} {packet_total:>5} lines", "TOTAL", "");
|
||||
|
||||
Ok(vec![
|
||||
LocByPart {
|
||||
part: ReportPart::TextRendering,
|
||||
lines: rows[0],
|
||||
},
|
||||
LocByPart {
|
||||
part: ReportPart::HitTestResolution,
|
||||
lines: rows[1],
|
||||
},
|
||||
LocByPart {
|
||||
part: ReportPart::AccessibilityTreeConstruction,
|
||||
lines: rows[2],
|
||||
},
|
||||
LocByPart {
|
||||
part: ReportPart::AccessibilityIntegrationWiring,
|
||||
lines: rows[3],
|
||||
},
|
||||
LocByPart {
|
||||
part: ReportPart::FixtureAndReportPlumbing,
|
||||
lines: rows[4],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loc_mapping_tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_mapping() -> Vec<(&'static str, &'static [&'static str])> {
|
||||
vec![("A", &["src/a.rs"]), ("B", &["src/b.rs", "src/c.rs"])]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exhaustive_disjoint_mapping_passes() {
|
||||
let files = vec![
|
||||
"src/a.rs".to_string(),
|
||||
"src/b.rs".to_string(),
|
||||
"src/c.rs".to_string(),
|
||||
];
|
||||
check_mapping_exhaustive_and_disjoint(&files, &sample_mapping()).unwrap();
|
||||
}
|
||||
|
||||
/// G2 required kill: a file present under `src/` but named in no
|
||||
/// part's file list must be refused, naming the file.
|
||||
#[test]
|
||||
fn an_unclaimed_file_is_refused_by_name() {
|
||||
let files = vec![
|
||||
"src/a.rs".to_string(),
|
||||
"src/b.rs".to_string(),
|
||||
"src/c.rs".to_string(),
|
||||
"src/d.rs".to_string(), // not in any part's file list
|
||||
];
|
||||
let err = check_mapping_exhaustive_and_disjoint(&files, &sample_mapping()).unwrap_err();
|
||||
assert!(err.to_string().contains("src/d.rs: unclaimed"), "{err}");
|
||||
}
|
||||
|
||||
/// G2 required kill: a file named in two parts' file lists must be
|
||||
/// refused, naming the file and both parts — the disjointness half of
|
||||
/// the rule, still enforced now that exhaustiveness is checked too.
|
||||
#[test]
|
||||
fn a_doubly_claimed_file_is_refused_by_name() {
|
||||
let mapping = vec![
|
||||
("A", &["src/a.rs"][..]),
|
||||
("B", &["src/a.rs", "src/c.rs"][..]),
|
||||
];
|
||||
let files = vec!["src/a.rs".to_string(), "src/c.rs".to_string()];
|
||||
let err = check_mapping_exhaustive_and_disjoint(&files, &mapping).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("src/a.rs: claimed by multiple"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The real, current mapping (as built in `loc_by_part`) must itself
|
||||
/// pass against the real, current file tree — this is the regression
|
||||
/// lock for G2 on the actual packet, not just the synthetic cases
|
||||
/// above.
|
||||
#[test]
|
||||
fn the_real_mapping_is_exhaustive_and_disjoint_over_the_real_tree() {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let mapping: [(&str, &[&str]); 5] = [
|
||||
(
|
||||
"TextRendering",
|
||||
&["src/glyph_outline.rs", "src/render_target.rs"],
|
||||
),
|
||||
("HitTestResolution", &["src/hit_test.rs"]),
|
||||
("AccessibilityTreeConstruction", &["src/a11y_node.rs"]),
|
||||
("AccessibilityIntegrationWiring", &["src/a11y_app.rs"]),
|
||||
(
|
||||
"FixtureAndReportPlumbing",
|
||||
&[
|
||||
"src/lib.rs",
|
||||
"src/a11y_subprocess.rs",
|
||||
"src/bin/c1_round2_text.rs",
|
||||
"src/bin/c1_round2_a11y.rs",
|
||||
],
|
||||
),
|
||||
];
|
||||
let all_files = list_rs_files_under_src(&dir).unwrap();
|
||||
check_mapping_exhaustive_and_disjoint(&all_files, &mapping).unwrap();
|
||||
}
|
||||
|
||||
/// Reattribution regression lock (user ruling): `a11y_subprocess.rs`
|
||||
/// must be claimed by `FixtureAndReportPlumbing`, never
|
||||
/// `AccessibilityIntegrationWiring` and never folded into `Other` — the
|
||||
/// ruling explicitly reserves `Other` for candidate-specific seams, and
|
||||
/// the verifier-subprocess harness is shared with C2, not one.
|
||||
#[test]
|
||||
fn a11y_subprocess_is_plumbing_not_integration_wiring_or_other() {
|
||||
let mapping: [(&str, &[&str]); 5] = [
|
||||
(
|
||||
"TextRendering",
|
||||
&["src/glyph_outline.rs", "src/render_target.rs"],
|
||||
),
|
||||
("HitTestResolution", &["src/hit_test.rs"]),
|
||||
("AccessibilityTreeConstruction", &["src/a11y_node.rs"]),
|
||||
("AccessibilityIntegrationWiring", &["src/a11y_app.rs"]),
|
||||
(
|
||||
"FixtureAndReportPlumbing",
|
||||
&[
|
||||
"src/lib.rs",
|
||||
"src/a11y_subprocess.rs",
|
||||
"src/bin/c1_round2_text.rs",
|
||||
"src/bin/c1_round2_a11y.rs",
|
||||
],
|
||||
),
|
||||
];
|
||||
let (label, _) = mapping
|
||||
.iter()
|
||||
.find(|(_, files)| files.contains(&"src/a11y_subprocess.rs"))
|
||||
.expect("src/a11y_subprocess.rs must be claimed by some part");
|
||||
assert_eq!(*label, "FixtureAndReportPlumbing", "{label}");
|
||||
assert_ne!(*label, "AccessibilityIntegrationWiring");
|
||||
}
|
||||
|
||||
/// `list_rs_files_under_src` must exclude `src/main.rs` (Round 1's
|
||||
/// frozen binary) but include everything else, e.g. `src/lib.rs` — the
|
||||
/// exact file G2 found omitted from the mapping.
|
||||
#[test]
|
||||
fn main_rs_is_excluded_but_lib_rs_is_present() {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let files = list_rs_files_under_src(&dir).unwrap();
|
||||
assert!(!files.contains(&"src/main.rs".to_string()), "{files:?}");
|
||||
assert!(files.contains(&"src/lib.rs".to_string()), "{files:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
//! Candidate-owned glyph outline extraction + tessellation, used by
|
||||
//! `render_target.rs` (the offscreen checks 1/2 render pipeline
|
||||
//! `bin/c1_round2_text.rs` drives). Counted under `ReportPart::TextRendering`
|
||||
//! together with `render_target.rs` — the F3 cost-schema amendment's own
|
||||
//! definition of that row ("outline extraction, path building,
|
||||
//! tessellation/rasterization, the offscreen render target"). The check-5
|
||||
//! windowed probe (`bin/c1_round2_a11y.rs`) does not use this module: it
|
||||
//! carries no visual rendering at all, only the accessible node, so that its
|
||||
//! own LOC is not a mix of `AccessibilityTreeConstruction` and rendering
|
||||
//! code the F3 finding named as the defect in the pre-fix version of this
|
||||
//! packet.
|
||||
//!
|
||||
//! Round 1's `main.rs` converted `epiphany_layout_ir::PathCommand` (Bravura's
|
||||
//! own typed outline data, already staff-space `MoveTo`/`LineTo`/`CurveTo`)
|
||||
//! into a lyon path. Round 2's glyphs come from host font faces instead,
|
||||
//! addressed by font-internal glyph id (`SpikePositionedGlyph::glyph_id`) —
|
||||
//! there is no `PathCommand` for them anywhere in this recipe's data. This
|
||||
//! module is therefore new candidate work, not a reuse of Round 1's
|
||||
//! `build_path`: it walks `ttf_parser::Face::outline_glyph`'s own
|
||||
//! `OutlineBuilder` callbacks straight into a `lyon_path::Path`, exactly the
|
||||
//! extraction-and-conversion step the packet names as "yours to write" and
|
||||
//! "part of what the cost table measures."
|
||||
//!
|
||||
//! `round2-svgref` (the frozen, candidate-neutral reference emitter) walks
|
||||
//! the same `ttf_parser::OutlineBuilder` callbacks to build an SVG path
|
||||
//! string. This module does the analogous walk for a *lyon* path instead —
|
||||
//! independently implemented, not called into, since the reference emitter
|
||||
//! is off-limits apparatus (`round2-svgref` is not depended on here) and the
|
||||
//! whole point of this module is that the candidate does its own outline
|
||||
//! walk.
|
||||
|
||||
use egui::epaint::{Mesh, Vertex};
|
||||
use egui::{Color32, Pos2, TextureId};
|
||||
use lyon_path::math::point as lyon_point;
|
||||
use lyon_path::Path as LyonPath;
|
||||
use lyon_tessellation::{
|
||||
BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers,
|
||||
};
|
||||
|
||||
/// The ink colour every glyph is painted, opaque, matching the reference
|
||||
/// emitter's `fill="#000000"` and Round 1's own `INK`.
|
||||
pub const INK: Color32 = Color32::BLACK;
|
||||
|
||||
/// Collects one glyph outline straight into a `lyon_path::Path`, converting
|
||||
/// font units to device pixels and flipping y (font space is y-up; device
|
||||
/// space, like Round 1's and the reference emitter's, is y-down) in the same
|
||||
/// step — no intermediate `PathCommand` or SVG-string representation.
|
||||
///
|
||||
/// `device_origin` is the glyph's own device-space pen position — the output
|
||||
/// of `round2_textkit::hittest::to_device` on the glyph's `offset`, per the
|
||||
/// packet's non-negotiable rendering convention. `scale` is device px per
|
||||
/// font unit (`em_px / units_per_em`).
|
||||
struct GlyphPathSink {
|
||||
builder: lyon_path::path::Builder,
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
scale: f64,
|
||||
open: bool,
|
||||
any: bool,
|
||||
}
|
||||
|
||||
impl GlyphPathSink {
|
||||
fn map(&self, x: f32, y: f32) -> lyon_path::math::Point {
|
||||
lyon_point(
|
||||
(self.ox + x as f64 * self.scale) as f32,
|
||||
(self.oy - y as f64 * self.scale) as f32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ttf_parser::OutlineBuilder for GlyphPathSink {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
if self.open {
|
||||
self.builder.end(true);
|
||||
}
|
||||
let p = self.map(x, y);
|
||||
self.builder.begin(p);
|
||||
self.open = true;
|
||||
self.any = true;
|
||||
}
|
||||
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
let p = self.map(x, y);
|
||||
self.builder.line_to(p);
|
||||
}
|
||||
|
||||
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
|
||||
let c1 = self.map(x1, y1);
|
||||
let p = self.map(x, y);
|
||||
self.builder.quadratic_bezier_to(c1, p);
|
||||
}
|
||||
|
||||
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
|
||||
let c1 = self.map(x1, y1);
|
||||
let c2 = self.map(x2, y2);
|
||||
let p = self.map(x, y);
|
||||
self.builder.cubic_bezier_to(c1, c2, p);
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
if self.open {
|
||||
self.builder.end(true);
|
||||
self.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts glyph `glyph_id`'s **complete** outline (every subpath — a
|
||||
/// glyph's own bounded holes and disjoint components alike, the same
|
||||
/// discipline Round 1's oracle measured Bravura against) from `face` as a
|
||||
/// `lyon_path::Path` in device space, or `None` if the glyph has no outline
|
||||
/// at all (whitespace — `ttf_parser::Face::outline_glyph` itself returns
|
||||
/// `None`, or draws nothing). Never substituted with a placeholder: a glyph
|
||||
/// with no outline draws nothing, exactly as a segment with `face: None`
|
||||
/// draws nothing (recipe: "do not substitute a fallback and do not draw
|
||||
/// `.notdef`").
|
||||
pub fn glyph_outline_to_lyon_path(
|
||||
face: &ttf_parser::Face,
|
||||
glyph_id: u32,
|
||||
device_origin: (f64, f64),
|
||||
em_px: f64,
|
||||
) -> Option<LyonPath> {
|
||||
let upem = face.units_per_em() as f64;
|
||||
if upem <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut sink = GlyphPathSink {
|
||||
builder: LyonPath::builder(),
|
||||
ox: device_origin.0,
|
||||
oy: device_origin.1,
|
||||
scale: em_px / upem,
|
||||
open: false,
|
||||
any: false,
|
||||
};
|
||||
let gid = ttf_parser::GlyphId(glyph_id as u16);
|
||||
face.outline_glyph(gid, &mut sink)?;
|
||||
if sink.open {
|
||||
sink.builder.end(true);
|
||||
}
|
||||
if !sink.any {
|
||||
return None;
|
||||
}
|
||||
Some(sink.builder.build())
|
||||
}
|
||||
|
||||
/// Tessellates one glyph outline and appends its vertices/indices into
|
||||
/// `buffers`, offsetting indices so multiple glyphs can share one
|
||||
/// `VertexBuffers` / one draw call.
|
||||
///
|
||||
/// **Nonzero fill rule** — matching both the reference emitter's own
|
||||
/// `fill-rule="nonzero"` and Round 1's finding that TrueType/CFF outlines
|
||||
/// (like Bravura's) are correctly wound, so nonzero and even-odd agree; the
|
||||
/// **whole glyph outline is tessellated in one `tessellate_path` call**, the
|
||||
/// same "one compound path, not per-subpath" discipline Round 1's `main.rs`
|
||||
/// documents — a glyph with a bounded counter (e.g. `o`, `e`) has its hole
|
||||
/// preserved only because every subpath enters the same fill call.
|
||||
pub fn tessellate_into(
|
||||
path: &LyonPath,
|
||||
buffers: &mut VertexBuffers<[f32; 2], u32>,
|
||||
) -> Result<(), String> {
|
||||
let mut tess = FillTessellator::new();
|
||||
tess.tessellate_path(
|
||||
path,
|
||||
&FillOptions::default().with_fill_rule(FillRule::NonZero),
|
||||
&mut BuffersBuilder::new(buffers, |v: FillVertex| {
|
||||
let p = v.position();
|
||||
[p.x, p.y]
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("lyon tessellation failed: {e:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds one `egui::epaint::Mesh`, bound to `tex`, containing every glyph
|
||||
/// already tessellated into `buffers` — the whole fixture's ink in one mesh,
|
||||
/// paintable in a single draw call.
|
||||
pub fn mesh_from_buffers(buffers: &VertexBuffers<[f32; 2], u32>, tex: TextureId) -> Mesh {
|
||||
let mut mesh = Mesh::with_texture(tex);
|
||||
mesh.vertices = buffers
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|[x, y]| Vertex {
|
||||
pos: Pos2::new(*x, *y),
|
||||
uv: Pos2::ZERO,
|
||||
color: INK,
|
||||
})
|
||||
.collect();
|
||||
mesh.indices = buffers.indices.clone();
|
||||
mesh
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PAGELLA: &str = "/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf";
|
||||
|
||||
fn face_bytes() -> Option<Vec<u8>> {
|
||||
std::fs::read(PAGELLA).ok()
|
||||
}
|
||||
|
||||
/// Mutation-first: an outline that exists must actually tessellate to a
|
||||
/// non-empty mesh with real ink coverage — a sink wired backwards (e.g.
|
||||
/// dropping `close()`) would silently produce zero triangles instead of
|
||||
/// a build error.
|
||||
#[test]
|
||||
fn a_real_glyph_outline_tessellates_to_a_nonempty_mesh() {
|
||||
let Some(bytes) = face_bytes() else {
|
||||
eprintln!("NOT RUN: {PAGELLA} absent — environment absence, not a failure");
|
||||
return;
|
||||
};
|
||||
let face = ttf_parser::Face::parse(&bytes, 0).unwrap();
|
||||
let gid = face.glyph_index('A').unwrap();
|
||||
let path = glyph_outline_to_lyon_path(&face, gid.0 as u32, (0.0, 0.0), 128.0)
|
||||
.expect("'A' must have an outline");
|
||||
let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
|
||||
tessellate_into(&path, &mut buffers).unwrap();
|
||||
assert!(!buffers.vertices.is_empty());
|
||||
assert!(!buffers.indices.is_empty());
|
||||
assert_eq!(
|
||||
buffers.indices.len() % 3,
|
||||
0,
|
||||
"a fill tessellation must produce whole triangles"
|
||||
);
|
||||
}
|
||||
|
||||
/// A whitespace glyph (space) has no outline and must map to `None`, not
|
||||
/// an empty-but-`Some` path — the same "draws nothing, not a degenerate
|
||||
/// mesh" contract `round2-svgref`'s `emit_glyph_paths` documents for its
|
||||
/// own `empty` list.
|
||||
#[test]
|
||||
fn a_whitespace_glyph_has_no_outline() {
|
||||
let Some(bytes) = face_bytes() else {
|
||||
eprintln!("NOT RUN: {PAGELLA} absent");
|
||||
return;
|
||||
};
|
||||
let face = ttf_parser::Face::parse(&bytes, 0).unwrap();
|
||||
let gid = face.glyph_index(' ').unwrap();
|
||||
let path = glyph_outline_to_lyon_path(&face, gid.0 as u32, (0.0, 0.0), 128.0);
|
||||
assert!(path.is_none(), "a space glyph must produce no outline");
|
||||
}
|
||||
|
||||
/// Required kill: a glyph with a bounded hole (`o`) must tessellate with
|
||||
/// its counter preserved — i.e. NOT as a solid blob. Checked the same
|
||||
/// way Round 1's oracle checks it: a point at the glyph's own centre
|
||||
/// (inside the counter) must NOT be covered by any tessellated triangle,
|
||||
/// while a point on the stem must be. This is a coarse geometric check
|
||||
/// (bounding-box centroid, not the oracle's precise point-in-path
|
||||
/// derivation), sufficient to catch the regression this module's own
|
||||
/// doc comment warns about: tessellating per-subpath (which would fill
|
||||
/// the hole solid) instead of as one compound path.
|
||||
#[test]
|
||||
fn a_glyph_with_a_hole_keeps_its_counter_open() {
|
||||
let Some(bytes) = face_bytes() else {
|
||||
eprintln!("NOT RUN: {PAGELLA} absent");
|
||||
return;
|
||||
};
|
||||
let face = ttf_parser::Face::parse(&bytes, 0).unwrap();
|
||||
let gid = face.glyph_index('o').unwrap();
|
||||
let path = glyph_outline_to_lyon_path(&face, gid.0 as u32, (0.0, 0.0), 1000.0)
|
||||
.expect("'o' must have an outline");
|
||||
let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
|
||||
tessellate_into(&path, &mut buffers).unwrap();
|
||||
|
||||
// Bounding box of the tessellated ink.
|
||||
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for [x, y] in &buffers.vertices {
|
||||
minx = minx.min(*x);
|
||||
miny = miny.min(*y);
|
||||
maxx = maxx.max(*x);
|
||||
maxy = maxy.max(*y);
|
||||
}
|
||||
let cx = (minx + maxx) / 2.0;
|
||||
let cy = (miny + maxy) / 2.0;
|
||||
|
||||
let point_in_triangle = |p: (f32, f32), a: (f32, f32), b: (f32, f32), c: (f32, f32)| {
|
||||
let sign = |p1: (f32, f32), p2: (f32, f32), p3: (f32, f32)| {
|
||||
(p1.0 - p3.0) * (p2.1 - p3.1) - (p2.0 - p3.0) * (p1.1 - p3.1)
|
||||
};
|
||||
let d1 = sign(p, a, b);
|
||||
let d2 = sign(p, b, c);
|
||||
let d3 = sign(p, c, a);
|
||||
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
|
||||
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
|
||||
!(has_neg && has_pos)
|
||||
};
|
||||
let covers = |p: (f32, f32)| {
|
||||
buffers.indices.chunks(3).any(|tri| {
|
||||
let a = buffers.vertices[tri[0] as usize];
|
||||
let b = buffers.vertices[tri[1] as usize];
|
||||
let c = buffers.vertices[tri[2] as usize];
|
||||
point_in_triangle(p, (a[0], a[1]), (b[0], b[1]), (c[0], c[1]))
|
||||
})
|
||||
};
|
||||
|
||||
assert!(
|
||||
!covers((cx, cy)),
|
||||
"the centre of 'o' must be an unfilled counter, not solid ink — a per-subpath \
|
||||
tessellation (the regression this module exists to avoid) would fill it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
//! Candidate-owned hit-test resolution: point -> (byte offset, affinity)
|
||||
//! against a `SpikeResolvedText`'s own caret-stop data (check 4).
|
||||
//!
|
||||
//! Loading the *expected* answers (`round2_textkit::hittest::HitTestProbeFile`)
|
||||
//! is neutral apparatus, consumed as-is in `bin/c1_round2_text.rs`. Computing an
|
||||
//! answer from a device point is this module's job, and this module's only
|
||||
//! borrowing from `round2_textkit::hittest` is [`to_device`] — the shared
|
||||
//! staff-space -> device-space transform every render in this packet uses
|
||||
//! (the contract requires reusing it rather than re-implementing the
|
||||
//! transform), not the probe *generator*'s own resolution logic. Resolution
|
||||
//! itself is independently reasoned about below, not copied from that
|
||||
//! module's doc comment.
|
||||
//!
|
||||
//! ## The resolution rule
|
||||
//!
|
||||
//! A run's caret stops (`SpikeCaretStop`, one per grapheme-cluster boundary,
|
||||
//! from the resolved text's own `ClusterMap`) are the only geometry this
|
||||
//! candidate has to test a point against. The `Downstream`-affinity stops are
|
||||
//! exactly the leading edge of each grapheme: sorted by device x they
|
||||
//! partition the line into a sequence of non-overlapping boxes with no gaps.
|
||||
//! So a point maps to the stop that begins the box containing it — the
|
||||
//! largest `Downstream` stop whose device x is at or before the point (a
|
||||
//! "floor" search over a sorted sequence), never a nearest-neighbour vote,
|
||||
//! which would be ambiguous exactly at a box's own midpoint. A point before
|
||||
//! every stop resolves to the first stop (there is no earlier box to belong
|
||||
//! to); a point after every stop resolves to the last.
|
||||
//!
|
||||
//! `Upstream`-affinity stops (the direction-boundary duplicates the resolved
|
||||
//! text carries at a bidi run boundary) are not part of this partition —
|
||||
//! they exist so a *caret*, already known to be at a specific logical
|
||||
//! offset, can pick the geometrically correct side of a direction boundary.
|
||||
//! A point-to-offset query carries no such prior knowledge, so it is
|
||||
//! answered from the `Downstream` partition alone, and this resolver's
|
||||
//! answer always reports `Downstream` affinity.
|
||||
|
||||
use round2_textkit::hittest::{to_device, DevicePoint};
|
||||
use round2_textkit::types::{SpikeCaretAffinity, SpikeResolvedText};
|
||||
|
||||
/// One resolved answer: a UTF-8 byte offset into `SpikeResolvedText::text`
|
||||
/// and the affinity this resolver reports for it — always `Downstream` (see
|
||||
/// the module doc comment).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HitTestAnswer {
|
||||
pub source_offset: u32,
|
||||
pub affinity: SpikeCaretAffinity,
|
||||
}
|
||||
|
||||
/// One `Downstream` caret stop, resolved to device space and kept alongside
|
||||
/// its source offset.
|
||||
struct Stop {
|
||||
source_offset: u32,
|
||||
device_x: f64,
|
||||
}
|
||||
|
||||
/// Builds the device-x-sorted `Downstream` partition this module's
|
||||
/// resolution rule is defined over.
|
||||
fn downstream_partition(rt: &SpikeResolvedText) -> Vec<Stop> {
|
||||
let mut stops: Vec<Stop> = rt
|
||||
.clusters
|
||||
.clusters
|
||||
.iter()
|
||||
.flat_map(|c| c.caret_stops.iter())
|
||||
.filter(|s| s.affinity == SpikeCaretAffinity::Downstream)
|
||||
.map(|s| Stop {
|
||||
source_offset: s.source_offset,
|
||||
device_x: to_device(rt, &s.position).x,
|
||||
})
|
||||
.collect();
|
||||
stops.sort_by(|a, b| {
|
||||
a.device_x
|
||||
.partial_cmp(&b.device_x)
|
||||
.expect("device x is always finite")
|
||||
});
|
||||
stops
|
||||
}
|
||||
|
||||
/// Resolves one device x-coordinate against an already-built, device-x-sorted
|
||||
/// `Downstream` partition — the "floor" search the module doc comment
|
||||
/// describes: the last stop at or before `point_x`, or the first stop if
|
||||
/// `point_x` precedes every stop.
|
||||
fn resolve_against(partition: &[Stop], point_x: f64) -> HitTestAnswer {
|
||||
assert!(
|
||||
!partition.is_empty(),
|
||||
"a resolved text with zero caret stops cannot be hit-tested"
|
||||
);
|
||||
let mut floor = &partition[0];
|
||||
for stop in partition {
|
||||
if stop.device_x <= point_x {
|
||||
floor = stop;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
HitTestAnswer {
|
||||
source_offset: floor.source_offset,
|
||||
affinity: SpikeCaretAffinity::Downstream,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `point` against `rt` from scratch — the entry point
|
||||
/// `bin/c1_round2_text.rs` uses for every probe.
|
||||
///
|
||||
/// Only `point.x` is consulted: every fixture in this recipe lays its run out
|
||||
/// on one fixed baseline (`origin.y` fixed, `align: Start`), so device y does
|
||||
/// not distinguish anything the probe table tests — every probe in
|
||||
/// `hittest_probes.json` shares its fixture's one baseline y already.
|
||||
pub fn resolve(rt: &SpikeResolvedText, point: DevicePoint) -> HitTestAnswer {
|
||||
let partition = downstream_partition(rt);
|
||||
resolve_against(&partition, point.x)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn spike_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// End-to-end: this resolver, run over every one of the 80 committed
|
||||
/// probes across all five fixtures, must agree with every precommitted
|
||||
/// expected answer. This is the check itself, exercised here as a unit
|
||||
/// test rather than only inside the `c1_round2_text` binary, so a
|
||||
/// regression in `resolve` is caught by `cargo test -p c1-egui-lyon`
|
||||
/// alone.
|
||||
#[test]
|
||||
fn resolves_every_committed_probe_correctly() {
|
||||
let fixtures_path = spike_root().join("round2-textkit/fixtures.json");
|
||||
if !fixtures_path.exists() {
|
||||
eprintln!("NOT RUN: fixtures.json absent");
|
||||
return;
|
||||
}
|
||||
let fixtures = round2_textkit::output::load_fixtures(&fixtures_path).unwrap();
|
||||
let probes_path = spike_root().join("round2-textkit/hittest_probes.json");
|
||||
let probe_file =
|
||||
round2_textkit::hittest::load_hittest_probes(&probes_path, &fixtures).unwrap();
|
||||
|
||||
let mut total = 0usize;
|
||||
let mut mismatches = Vec::new();
|
||||
for ft in &probe_file.fixtures {
|
||||
let rt = &fixtures
|
||||
.fixtures
|
||||
.iter()
|
||||
.find(|f| f.id == ft.fixture_id)
|
||||
.unwrap()
|
||||
.resolved;
|
||||
for p in &ft.probes {
|
||||
total += 1;
|
||||
let point = DevicePoint {
|
||||
x: p.point.x,
|
||||
y: p.point.y,
|
||||
};
|
||||
let answer = resolve(rt, point);
|
||||
if answer.source_offset != p.expected_source_offset
|
||||
|| answer.affinity != p.expected_affinity
|
||||
{
|
||||
mismatches.push(format!(
|
||||
"{}: {} -> got (offset {}, {:?}), expected (offset {}, {:?})",
|
||||
ft.fixture_id,
|
||||
p.source_grapheme,
|
||||
answer.source_offset,
|
||||
answer.affinity,
|
||||
p.expected_source_offset,
|
||||
p.expected_affinity
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"{}/{total} probes mismatched:\n{}",
|
||||
mismatches.len(),
|
||||
mismatches.join("\n")
|
||||
);
|
||||
assert_eq!(total, 80, "the recipe measures exactly 80 committed probes");
|
||||
}
|
||||
|
||||
/// Mutation-first (task requirement): a synthetic two-stop run, floor
|
||||
/// resolution at the midpoint must return the FIRST stop, not the
|
||||
/// nearest one — a nearest-neighbour implementation (the bug this
|
||||
/// module's doc comment explicitly rejects) would return the same
|
||||
/// answer on one side and disagree exactly at the midpoint's other
|
||||
/// side, so this test probes both sides of the midpoint, not the tie
|
||||
/// itself.
|
||||
#[test]
|
||||
fn floor_semantics_not_nearest_neighbour() {
|
||||
let partition = vec![
|
||||
Stop {
|
||||
source_offset: 0,
|
||||
device_x: 0.0,
|
||||
},
|
||||
Stop {
|
||||
source_offset: 5,
|
||||
device_x: 100.0,
|
||||
},
|
||||
];
|
||||
// Just past the midpoint (50.0) on the left: nearest-neighbour would
|
||||
// still say "first stop" here too, so this alone doesn't
|
||||
// distinguish the rules -- the distinguishing point is anything in
|
||||
// (0, 100) at all under floor semantics, which always says "first
|
||||
// stop" until x reaches 100. Assert floor holds all the way up to
|
||||
// (but not including) the second stop.
|
||||
assert_eq!(resolve_against(&partition, 0.0).source_offset, 0);
|
||||
assert_eq!(resolve_against(&partition, 49.0).source_offset, 0);
|
||||
assert_eq!(resolve_against(&partition, 50.0).source_offset, 0);
|
||||
assert_eq!(resolve_against(&partition, 99.999).source_offset, 0);
|
||||
assert_eq!(resolve_against(&partition, 100.0).source_offset, 5);
|
||||
assert_eq!(resolve_against(&partition, 500.0).source_offset, 5);
|
||||
// Before the first stop: still resolves to the first stop.
|
||||
assert_eq!(resolve_against(&partition, -50.0).source_offset, 0);
|
||||
}
|
||||
|
||||
/// Required kill: every answer's affinity is `Downstream`, never
|
||||
/// `Upstream` — this resolver has no notion of "the caret's own side" a
|
||||
/// point-only query lacks (module doc comment).
|
||||
#[test]
|
||||
fn every_resolved_answer_is_downstream() {
|
||||
let partition = vec![Stop {
|
||||
source_offset: 0,
|
||||
device_x: 0.0,
|
||||
}];
|
||||
assert_eq!(
|
||||
resolve_against(&partition, 10.0).affinity,
|
||||
SpikeCaretAffinity::Downstream
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
//! Packet 2B-C1: Round 2 (text) shared modules for candidate C1
|
||||
//! (egui + lyon).
|
||||
//!
|
||||
//! `src/main.rs` — the Round 1 binary — is frozen evidence and does not use
|
||||
//! this library; it is untouched by this packet (confirmed by `git diff` in
|
||||
//! the packet's own report). This crate gains a `[lib]` target purely so
|
||||
//! `src/bin/c1_round2_text.rs` and `src/bin/c1_round2_a11y.rs` can share
|
||||
//! candidate-owned logic.
|
||||
//!
|
||||
//! ## The F3 cost-schema mapping: one `ReportPart` per whole file
|
||||
//!
|
||||
//! Every module below maps to exactly one `round2_candidatekit::ReportPart`,
|
||||
//! and no file contributes to two parts — the rule the F3 finding fixed
|
||||
//! this packet to follow, so the per-part LOC comparison against C2 is
|
||||
//! actually comparable rather than an artifact of how one candidate happened
|
||||
//! to split its own files.
|
||||
//!
|
||||
//! | module | `ReportPart` |
|
||||
//! |---|---|
|
||||
//! | [`glyph_outline`] | `TextRendering` |
|
||||
//! | [`render_target`] | `TextRendering` |
|
||||
//! | [`hit_test`] | `HitTestResolution` |
|
||||
//! | [`a11y_node`] | `AccessibilityTreeConstruction` |
|
||||
//! | [`a11y_app`] | `AccessibilityIntegrationWiring` |
|
||||
//! | [`a11y_subprocess`] | `AccessibilityIntegrationWiring` |
|
||||
//!
|
||||
//! `bin/c1_round2_text.rs` and `bin/c1_round2_a11y.rs` themselves are
|
||||
//! `FixtureAndReportPlumbing` — fixture/font loading, diff invocation,
|
||||
//! report assembly, CLI — the two are printed in the run's own output
|
||||
//! (`c1_round2_text`'s `loc_by_part` section) rather than only asserted here.
|
||||
|
||||
pub mod a11y_app;
|
||||
pub mod a11y_node;
|
||||
pub mod a11y_subprocess;
|
||||
pub mod glyph_outline;
|
||||
pub mod hit_test;
|
||||
pub mod render_target;
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
//! `ReportPart::TextRendering`, half two: the offscreen render target this
|
||||
//! candidate draws Round 2 fixtures into for checks 1/2 — `egui_wgpu`
|
||||
//! device/adapter setup, the MSAA/resolve texture pair, the render pass, and
|
||||
//! the CPU readback. `glyph_outline.rs` is the other half (outline
|
||||
//! extraction + tessellation); together they are exactly the F3 cost-schema
|
||||
//! amendment's definition of this row: "outline extraction, path building,
|
||||
//! tessellation/rasterization, the offscreen render target."
|
||||
//!
|
||||
//! Split out of `bin/c1_round2_text.rs` by the F3 fix: that file used to
|
||||
//! carry this render pipeline *and* apparatus loading *and* report assembly
|
||||
//! in one file, which is fine for the packet's own line total but makes the
|
||||
//! per-part comparison against C2 meaningless — a file can only honestly
|
||||
//! contribute to one `ReportPart`. This module is `TextRendering`, full
|
||||
//! stop; `bin/c1_round2_text.rs` now only calls into it.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use egui::epaint::{ClippedPrimitive, Primitive};
|
||||
use egui::{Pos2, Rect, TextureId};
|
||||
use egui_wgpu::wgpu;
|
||||
use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor};
|
||||
use lyon_tessellation::VertexBuffers;
|
||||
|
||||
use crate::glyph_outline::{glyph_outline_to_lyon_path, mesh_from_buffers, tessellate_into};
|
||||
use round2_textkit::types::SpikeResolvedText;
|
||||
|
||||
/// Pin 4's offscreen target (restated as a literal, the discipline every
|
||||
/// loader/emitter in this workspace uses).
|
||||
pub const WIDTH: u32 = 1920;
|
||||
pub const HEIGHT: u32 = 1080;
|
||||
/// Matches Round 1's own C1 configuration (`main.rs`'s `MSAA`) — hardware
|
||||
/// MSAA render-target attachment, GPU-resolved.
|
||||
pub const MSAA: u32 = 8;
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
const GROUND: wgpu::Color = wgpu::Color {
|
||||
r: 1.0,
|
||||
g: 1.0,
|
||||
b: 1.0,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
pub struct GpuCtx {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
renderer: Renderer,
|
||||
white_tex: TextureId,
|
||||
pub adapter_name: String,
|
||||
pub adapter_device_type: String,
|
||||
}
|
||||
|
||||
pub fn build_gpu() -> Result<GpuCtx> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::VULKAN,
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN));
|
||||
if adapters.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"NOT RUN: no Vulkan adapter enumerated — environment absence, not a candidate failure"
|
||||
));
|
||||
}
|
||||
// Prefer the integrated adapter (pin 4/round 4's deciding figure comes
|
||||
// from the integrated adapter) when present; else take whatever
|
||||
// enumerated first. Checks 1/2/4 are pixel/geometry correctness checks,
|
||||
// not timed figures, so the choice is a reporting detail, not a
|
||||
// methodological one — recorded in the printed report either way.
|
||||
let adapter = adapters
|
||||
.iter()
|
||||
.find(|a| a.get_info().device_type == wgpu::DeviceType::IntegratedGpu)
|
||||
.unwrap_or(&adapters[0]);
|
||||
let info = adapter.get_info();
|
||||
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("c1-round2-text"),
|
||||
required_features: wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::default(),
|
||||
trace: wgpu::Trace::Off,
|
||||
experimental_features: wgpu::ExperimentalFeatures::disabled(),
|
||||
}))
|
||||
.context("wgpu device request failed")?;
|
||||
|
||||
let mut renderer = Renderer::new(
|
||||
&device,
|
||||
FORMAT,
|
||||
RendererOptions {
|
||||
msaa_samples: MSAA,
|
||||
depth_stencil_format: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// A 1x1 opaque-white texture registered with the renderer — an
|
||||
// unregistered `TextureId` is silently skipped by egui's own draw loop
|
||||
// (see Round 1's `main.rs` doc comment on `tessellate`), which would
|
||||
// read as "every ink sample is background" rather than a build error.
|
||||
let white = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("c1-round2-white-1x1"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &white,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&[255u8, 255, 255, 255],
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(4),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let white_view = white.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let white_tex =
|
||||
renderer.register_native_texture(&device, &white_view, wgpu::FilterMode::Nearest);
|
||||
|
||||
Ok(GpuCtx {
|
||||
device,
|
||||
queue,
|
||||
renderer,
|
||||
white_tex,
|
||||
adapter_name: info.name.clone(),
|
||||
adapter_device_type: format!("{:?}", info.device_type),
|
||||
})
|
||||
}
|
||||
|
||||
fn readback(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let unpadded = width * 4;
|
||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("c1-round2-readback"),
|
||||
size: (padded as u64) * (height as u64),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("c1-round2-copy"),
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
|
||||
let slice = buffer.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
device.poll(wgpu::PollType::wait_indefinitely())?;
|
||||
rx.recv()
|
||||
.map_err(|e| anyhow!("readback channel closed: {e}"))?
|
||||
.map_err(|e| anyhow!("buffer map failed: {e}"))?;
|
||||
|
||||
let mapped = slice.get_mapped_range();
|
||||
let mut out = Vec::with_capacity((unpadded as usize) * (height as usize));
|
||||
for row in 0..height as usize {
|
||||
let start = row * padded as usize;
|
||||
out.extend_from_slice(&mapped[start..start + unpadded as usize]);
|
||||
}
|
||||
drop(mapped);
|
||||
buffer.unmap();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Everything measured while drawing one fixture: the candidate raster, and
|
||||
/// the check-2 evidence (which segments, if any, resolved to `face: None`
|
||||
/// and therefore drew nothing).
|
||||
pub struct FixtureDraw {
|
||||
pub rgba: Vec<u8>,
|
||||
pub unresolved_segments: Vec<String>,
|
||||
}
|
||||
|
||||
/// Builds the whole fixture's ink as one tessellated mesh directly from
|
||||
/// `rt`'s own segments/glyphs — **never** from any egui text-layout call,
|
||||
/// font-fallback API, or `rustybuzz`. A segment with `face: None` (F-C's
|
||||
/// uncovered Arabic letter) is skipped by construction: its own `glyphs` is
|
||||
/// already empty (W3-F3 / the resolved-text invariants), so there is
|
||||
/// nothing to draw and nothing to substitute — recorded in
|
||||
/// `unresolved_segments` so the report can name it explicitly rather than
|
||||
/// looking identical to a candidate that silently dropped it.
|
||||
pub fn draw_fixture(
|
||||
gpu: &mut GpuCtx,
|
||||
rt: &SpikeResolvedText,
|
||||
ttf_faces: &[ttf_parser::Face],
|
||||
) -> Result<FixtureDraw> {
|
||||
let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
|
||||
let mut unresolved_segments = Vec::new();
|
||||
|
||||
for seg in &rt.segments {
|
||||
let Some(face_idx) = seg.face else {
|
||||
assert!(
|
||||
seg.glyphs.is_empty(),
|
||||
"an unresolved segment (face: None) must carry no glyphs — this candidate never \
|
||||
substitutes a fallback glyph for one"
|
||||
);
|
||||
let text = rt
|
||||
.text
|
||||
.get(seg.source.start as usize..seg.source.end as usize)
|
||||
.unwrap_or("<non-UTF8-boundary>");
|
||||
unresolved_segments.push(format!(
|
||||
"source {}..{} ({text:?}): face resolved to None (no declared face covers this \
|
||||
span) — {} glyphs drawn, no substitution",
|
||||
seg.source.start,
|
||||
seg.source.end,
|
||||
seg.glyphs.len()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let face = ttf_faces.get(face_idx as usize).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"segment declares face {face_idx}, but only {} faces were loaded",
|
||||
ttf_faces.len()
|
||||
)
|
||||
})?;
|
||||
let em_px = seg.size.0 * round2_textkit::DEVICE_SCALE;
|
||||
for g in &seg.glyphs {
|
||||
let device = round2_textkit::hittest::to_device(rt, &g.offset);
|
||||
if let Some(path) =
|
||||
glyph_outline_to_lyon_path(face, g.glyph_id, (device.x, device.y), em_px)
|
||||
{
|
||||
tessellate_into(&path, &mut buffers)
|
||||
.map_err(|e| anyhow!("tessellation failed: {e}"))?;
|
||||
}
|
||||
// `None`: a whitespace glyph with no outline — draws nothing,
|
||||
// exactly as the reference emitter's own `empty` list records.
|
||||
}
|
||||
}
|
||||
|
||||
let mesh = mesh_from_buffers(&buffers, gpu.white_tex);
|
||||
|
||||
let msaa_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("c1-round2-msaa"),
|
||||
size: wgpu::Extent3d {
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: MSAA,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let resolve_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("c1-round2-resolve"),
|
||||
size: wgpu::Extent3d {
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let jobs = vec![ClippedPrimitive {
|
||||
clip_rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(WIDTH as f32, HEIGHT as f32)),
|
||||
primitive: Primitive::Mesh(mesh),
|
||||
}];
|
||||
let screen = ScreenDescriptor {
|
||||
size_in_pixels: [WIDTH, HEIGHT],
|
||||
pixels_per_point: 1.0,
|
||||
};
|
||||
|
||||
let mut encoder = gpu
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("c1-round2-encode"),
|
||||
});
|
||||
let extra = gpu
|
||||
.renderer
|
||||
.update_buffers(&gpu.device, &gpu.queue, &mut encoder, &jobs, &screen);
|
||||
{
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("c1-round2-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &msaa_view,
|
||||
resolve_target: Some(&resolve_view),
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(GROUND),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
let mut pass = pass.forget_lifetime();
|
||||
gpu.renderer.render(&mut pass, &jobs, &screen);
|
||||
}
|
||||
gpu.queue
|
||||
.submit(extra.into_iter().chain([encoder.finish()]));
|
||||
|
||||
let rgba = readback(&gpu.device, &gpu.queue, &resolve_tex, WIDTH, HEIGHT)?;
|
||||
Ok(FixtureDraw {
|
||||
rgba,
|
||||
unresolved_segments,
|
||||
})
|
||||
}
|
||||
|
|
@ -21,3 +21,27 @@ vello = "0.9"
|
|||
pollster = "0.4"
|
||||
bytemuck = "1"
|
||||
anyhow = "1"
|
||||
|
||||
# --- Packet 2B-C2: Round 2 text (spec/CONTRACT_EDITOR_T4_SPIKE.md Round 2;
|
||||
# ROUND2_TEXT_RECIPE.md). Every dependency below is new over the Round 1
|
||||
# baseline (c20bc93) and is recorded, with its reason, in
|
||||
# CandidateReport::cost::dependencies_added by src/bin/round2_text.rs.
|
||||
round2-candidatekit = { path = "../../round2-candidatekit" }
|
||||
round2-diff = { path = "../../round2-diff" }
|
||||
round2-textkit = { path = "../../round2-textkit" }
|
||||
# Outline extraction from the resolved face, converted to a kurbo BezPath, is
|
||||
# the candidate-owned part of check 1 (task instructions) — round2-svgref's
|
||||
# emitter builds SVG path strings for the *reference*, not kurbo geometry for
|
||||
# a candidate, so it is deliberately not depended on here. Pinned to the same
|
||||
# version round2-textkit/round2-svgref use so "this face's cmap/outline table
|
||||
# says X" means the same thing everywhere in this packet.
|
||||
ttf-parser = "=0.25.1"
|
||||
serde_json = "1"
|
||||
# Check 5 (accessibility, disqualifying): vello ships no accessibility layer
|
||||
# of its own (contract, candidate set), so this is the same manual
|
||||
# accesskit_winit route probe-vello's Round 0 readback already proved —
|
||||
# reused here behind a real window rather than assumed. Versions match
|
||||
# probe-vello's own pins exactly.
|
||||
winit = "0.30"
|
||||
accesskit = "0.24"
|
||||
accesskit_winit = "0.33"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,780 @@
|
|||
//! Check 5, `ReportPart::FixtureAndReportPlumbing`: the shared spike/report
|
||||
//! harness around `a11y-verifier/verify.py` — verifier subprocesses, result
|
||||
//! decoding and reduction, bus-unreachable evidence, and temporary/canonical
|
||||
//! evidence-file handling.
|
||||
//!
|
||||
//! **Ruling (H1): this is harness, not product.** `a11y-verifier/verify.py`
|
||||
//! exists once, is shared by both Round 2 candidates, and is not part of
|
||||
//! either candidate's own accessibility stack — running it, trusting its
|
||||
//! output only when the exit status and the JSON output actually agree, and
|
||||
//! reducing five per-fixture outcomes to one round verdict is exactly the
|
||||
//! kind of harness plumbing `ReportPart::FixtureAndReportPlumbing` is for,
|
||||
//! not `AccessibilityIntegrationWiring`. An earlier revision of this packet
|
||||
//! put all of this in the same file as the winit/`accesskit_winit` adapter
|
||||
//! lifecycle (`a11y_wiring.rs`) — that file is now product-only; this one is
|
||||
//! everything the ruling names as harness.
|
||||
//!
|
||||
//! ## F1/F2 — the two review findings this file's shape enforces
|
||||
//!
|
||||
//! **F1 (freshness).** Every fixture's `--json` output path is unique to
|
||||
//! *this run* ([`run_nonce`], mixing pid + a timestamp) and is deleted
|
||||
//! immediately before its `verify.py` invocation is spawned
|
||||
//! ([`run_all_fixtures`]), so a read can never see anything this run did not
|
||||
//! itself write. On top of that, [`interpret_verify_output`] cross-checks
|
||||
//! the exit status against the JSON's own `verdict` field and its
|
||||
//! `fixture_id` field, and refuses (hard error, never silently trusts
|
||||
//! either) if they disagree.
|
||||
//!
|
||||
//! **F2 (ordering).** [`reduce_outcomes`] is a pure function over *every*
|
||||
//! fixture's outcome, decided only after all five have been attempted — a
|
||||
//! disqualifying `FAIL` found on any fixture always wins over an
|
||||
//! environmental `BusUnreachable` found on another, in **either** order,
|
||||
//! because [`run_all_fixtures`]'s loop never short-circuits on the first
|
||||
//! `BusUnreachable`.
|
||||
//!
|
||||
//! ## H2 — the exit-2 conjunction, and why it is two separate conditions
|
||||
//!
|
||||
//! Exit 2 is only accepted as [`FixtureOutcome::BusUnreachable`] when
|
||||
//! **both**, independently:
|
||||
//!
|
||||
//! - `inv.stdout` **begins with** [`CHECK5_NOT_RUN_PREFIX`] — the exact
|
||||
//! prefix `a11y-verifier/verify.py` prints for check 5's NOT RUN case,
|
||||
//! never for a usage error (which prints under a distinct `usage error`
|
||||
//! sentence instead — see that file's own `run_check5`);
|
||||
//! - `inv.stdout` contains one of [`CHECK5_ENVIRONMENTAL_MARKERS`] —
|
||||
//! transcribed verbatim from `verify.py`'s own source, not guessed, with
|
||||
//! the exact call site named against each one.
|
||||
//!
|
||||
//! Both conditions are required, tested **separately** (each of the two
|
||||
//! `interpret_exit_2_*_alone_is_a_hard_error` tests below holds the other
|
||||
//! condition satisfied while breaking just the one it names), because a
|
||||
//! conjunction whose two halves are only ever exercised together is not
|
||||
//! actually verified — either half could be silently dropped and every
|
||||
//! previously-passing test would keep passing.
|
||||
//!
|
||||
//! ## J2 — the worker thread and the readiness handoff moved here too
|
||||
//!
|
||||
//! [`run_a11y_round`] now owns spawning the worker thread that runs
|
||||
//! [`run_all_fixtures`] and delivering its result — the *coordination*,
|
||||
//! not the window. It drives `a11y_wiring::run_window`'s generic,
|
||||
//! verifier-agnostic surface (`FinishHandle<Result<A11yRoundResult>>`): the
|
||||
//! callback `run_window` invokes the instant the tree is actually live does
|
||||
//! nothing but spawn a thread and return immediately, so the event loop
|
||||
//! (product-side, `a11y_wiring.rs`) is never blocked by this file's work.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
|
||||
use round2_candidatekit::{A11yEvidence, BusUnreachableEvidence};
|
||||
|
||||
use crate::a11y_wiring::run_window;
|
||||
|
||||
/// Must match the binary target name (`c2_round2_text.rs` -> Cargo target
|
||||
/// `c2_round2_text`): `accesskit_unix` (the AT-SPI2 bridge `accesskit_winit`
|
||||
/// uses on Linux) derives the AT-SPI *application* name from
|
||||
/// `std::env::current_exe()`'s file name (`accesskit_unix::context::app_name`,
|
||||
/// verified in this workspace's lockfile at
|
||||
/// `accesskit_unix-0.22.1/src/context.rs:36`), and
|
||||
/// `a11y-verifier/verify.py`'s `--app-name` is a substring match against
|
||||
/// that. **F4**: renamed from `round2_text` to `c2_round2_text` because both
|
||||
/// candidate packages produced a binary named `round2_text`, colliding in
|
||||
/// the shared `target/release/` output directory.
|
||||
pub const APP_NAME: &str = "c2_round2_text";
|
||||
|
||||
/// The exact five fixture ids, recipe §2 order — restated (not read back out
|
||||
/// of `round2-textkit`), the same discipline every crate in this packet
|
||||
/// uses so a copy built independently still agrees on the roster.
|
||||
pub const FIXTURE_ORDER: [&str; 5] = ["F-A", "F-B", "F-C", "F-D", "F-E"];
|
||||
|
||||
/// **H2**: the exact prefix `a11y-verifier/verify.py` prints for every
|
||||
/// check-5 `NOT RUN` case — transcribed verbatim from that file (never
|
||||
/// modified here, and never guessed): every `print(f"CHECK5: NOT RUN —
|
||||
/// ...")` call, plus the one call site in `main()` that prints under a
|
||||
/// dynamic `{label}` that is `"CHECK5"` in check-5 mode
|
||||
/// (`a11y-verifier/verify.py:1092`), all begin with exactly this text.
|
||||
/// `verify.py`'s usage-error branches (a bad `--expectations`, a digest
|
||||
/// mismatch, an unknown `--fixture`, ...) print under a distinct `"CHECK5:
|
||||
/// usage error — ..."` sentence instead and therefore never match this
|
||||
/// prefix.
|
||||
const CHECK5_NOT_RUN_PREFIX: &str = "CHECK5: NOT RUN";
|
||||
|
||||
/// **H2**: every environmental-cause marker `a11y-verifier/verify.py`
|
||||
/// actually prints after [`CHECK5_NOT_RUN_PREFIX`], transcribed verbatim
|
||||
/// from that file's source (never guessed), one entry per call site:
|
||||
///
|
||||
/// - `run_check5`, `Atspi.init()` raising: `verify.py:961`
|
||||
/// (`f"CHECK5: NOT RUN — Atspi.init() failed: {exc}"`)
|
||||
/// - `run_check5`, `Atspi.get_desktop(0)` raising: `verify.py:973`
|
||||
/// (`f"CHECK5: NOT RUN — Atspi.get_desktop(0) failed: {exc}"`)
|
||||
/// - `run_check5`, `Atspi.get_desktop(0)` returning `None`: `verify.py:976`
|
||||
/// (`"CHECK5: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI \
|
||||
/// registry?)"`)
|
||||
/// - `run_check5`, `desktop.get_child_count()` raising: `verify.py:984`
|
||||
/// (`f"CHECK5: NOT RUN — desktop.get_child_count() failed: {exc}"`)
|
||||
/// - `main`, the `gi.repository.Atspi` import itself failing — reached
|
||||
/// *before* `run_check5` even starts: `verify.py:1092`
|
||||
/// (`f"{label}: NOT RUN — could not import gi.repository.Atspi: {exc}"`,
|
||||
/// `label == "CHECK5"` in check-5 mode)
|
||||
///
|
||||
/// Matched as a substring of `inv.stdout` *after* [`CHECK5_NOT_RUN_PREFIX`]
|
||||
/// has already been confirmed present — the two checks are independent
|
||||
/// (H2), so this list is consulted regardless of what precedes it in the
|
||||
/// calling code, but the marker text itself never appears in any of
|
||||
/// `verify.py`'s usage-error prints (`verify.py:934`, `:944`, `:953`), which
|
||||
/// is what makes it a safe positive signal once the prefix is also
|
||||
/// satisfied.
|
||||
const CHECK5_ENVIRONMENTAL_MARKERS: &[&str] = &[
|
||||
"Atspi.init() failed",
|
||||
"Atspi.get_desktop(0) failed",
|
||||
"Atspi.get_desktop(0) returned None",
|
||||
"desktop.get_child_count() failed",
|
||||
"could not import gi.repository.Atspi",
|
||||
];
|
||||
|
||||
/// The outcome of one full a11y round: either every fixture that could be
|
||||
/// scored was, or the platform accessibility bus was found unreachable for
|
||||
/// at least one fixture **and no fixture failed** — see [`reduce_outcomes`]
|
||||
/// for why those two conditions must both hold (F2). `BusUnreachable` still
|
||||
/// carries whatever fixtures *did* get scored before/around the bus issue
|
||||
/// (`partial_scored`), so the report is not forced to discard real evidence
|
||||
/// just because the round overall reads `NotRun`.
|
||||
pub enum A11yRoundResult {
|
||||
Scored(Vec<A11yEvidence>),
|
||||
BusUnreachable {
|
||||
evidence: BusUnreachableEvidence,
|
||||
partial_scored: Vec<A11yEvidence>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One fixture's outcome, before the round-level F2 reduction.
|
||||
#[derive(Debug)]
|
||||
enum FixtureOutcome {
|
||||
Scored(A11yEvidence),
|
||||
BusUnreachable(BusUnreachableEvidence),
|
||||
}
|
||||
|
||||
/// One `verify.py` invocation's raw result, in a form
|
||||
/// [`interpret_verify_output`] can be exercised against without spawning a
|
||||
/// subprocess (F1/H2's mutation tests).
|
||||
struct VerifyInvocation {
|
||||
fixture_id: String,
|
||||
exit_code: Option<i32>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
/// The fresh, run-unique path this invocation was told to write its
|
||||
/// `--json` output to — already deleted (if anything occupied it)
|
||||
/// immediately before the subprocess was spawned. See this module's F1
|
||||
/// doc section.
|
||||
json_path: PathBuf,
|
||||
}
|
||||
|
||||
fn evidence_from_json(fixture_id: &str, v: &serde_json::Value) -> Result<A11yEvidence> {
|
||||
let verdict = v
|
||||
.get("verdict")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| anyhow!("{fixture_id}: verify.py's json output is missing 'verdict'"))?;
|
||||
Ok(A11yEvidence {
|
||||
fixture_id: fixture_id.to_string(),
|
||||
platform: "at-spi2".to_string(),
|
||||
observed_name: v
|
||||
.get("observed_name")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string),
|
||||
observed_name_bytes_hex: v
|
||||
.get("observed_name_hex")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string),
|
||||
observed_role: v
|
||||
.get("observed_role")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string),
|
||||
prohibited_outcome: v
|
||||
.get("prohibited_outcome")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string),
|
||||
pass: verdict == "PASS",
|
||||
notes: v
|
||||
.get("reason")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Interprets one already-completed `verify.py` invocation (F1, H2). Never
|
||||
/// trusts a file's mere presence or a bare exit code alone:
|
||||
///
|
||||
/// - exit 0/1: reads `inv.json_path`, and requires **both** its `verdict`
|
||||
/// field to agree with what the exit code implies (`0` -> `"PASS"`, `1`
|
||||
/// -> `"FAIL"`) **and** its `fixture_id` field to equal `inv.fixture_id`.
|
||||
/// Either disagreement is a hard error.
|
||||
/// - exit 2: refuses to treat it as [`FixtureOutcome::BusUnreachable`] unless
|
||||
/// **all three**, independently: `inv.json_path` is absent (a fresh scored
|
||||
/// output existing alongside an exit-2 status is a contradiction, not
|
||||
/// evidence); `inv.stdout` begins with [`CHECK5_NOT_RUN_PREFIX`]; and
|
||||
/// `inv.stdout` contains one of [`CHECK5_ENVIRONMENTAL_MARKERS`] (H2).
|
||||
/// Every other exit-2 shape (a usage error, a digest mismatch, ...) is a
|
||||
/// hard error, never silently promoted to environmental absence.
|
||||
fn interpret_verify_output(inv: &VerifyInvocation) -> Result<FixtureOutcome> {
|
||||
match inv.exit_code {
|
||||
Some(0) | Some(1) => {
|
||||
let code = inv.exit_code.expect("matched Some above");
|
||||
let expected_verdict = if code == 0 { "PASS" } else { "FAIL" };
|
||||
let text = std::fs::read_to_string(&inv.json_path).map_err(|e| {
|
||||
anyhow!(
|
||||
"{}: verify.py exited {code} but its fresh --json output at {} could not be \
|
||||
read: {e}\nstdout:\n{}",
|
||||
inv.fixture_id,
|
||||
inv.json_path.display(),
|
||||
inv.stdout
|
||||
)
|
||||
})?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
|
||||
anyhow!(
|
||||
"{}: failed to parse verify.py's json output: {e}",
|
||||
inv.fixture_id
|
||||
)
|
||||
})?;
|
||||
let json_fixture_id =
|
||||
v.get("fixture_id")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"{}: verify.py's json output is missing 'fixture_id'",
|
||||
inv.fixture_id
|
||||
)
|
||||
})?;
|
||||
if json_fixture_id != inv.fixture_id {
|
||||
bail!(
|
||||
"{}: verify.py's --json output at {} names fixture_id {json_fixture_id:?}, \
|
||||
not the fixture this invocation asked for — refusing to attribute someone \
|
||||
else's verdict",
|
||||
inv.fixture_id,
|
||||
inv.json_path.display()
|
||||
);
|
||||
}
|
||||
let verdict = v.get("verdict").and_then(|x| x.as_str()).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"{}: verify.py's json output is missing 'verdict'",
|
||||
inv.fixture_id
|
||||
)
|
||||
})?;
|
||||
if verdict != expected_verdict {
|
||||
bail!(
|
||||
"{}: verify.py exited {code} (implying {expected_verdict:?}) but its own \
|
||||
json output reports verdict {verdict:?} — exit status and json output \
|
||||
disagree, refusing to trust either",
|
||||
inv.fixture_id
|
||||
);
|
||||
}
|
||||
Ok(FixtureOutcome::Scored(evidence_from_json(
|
||||
&inv.fixture_id,
|
||||
&v,
|
||||
)?))
|
||||
}
|
||||
Some(2) => {
|
||||
if inv.json_path.exists() {
|
||||
bail!(
|
||||
"{}: verify.py exited 2 (usage/NOT RUN) but a fresh --json output exists at \
|
||||
{} anyway — an exit-2 run must never have written scored output, so this is \
|
||||
a contradiction rather than evidence of anything",
|
||||
inv.fixture_id,
|
||||
inv.json_path.display()
|
||||
);
|
||||
}
|
||||
// H2: the two halves of the conjunction, computed and checked
|
||||
// independently -- see this module's doc comment for why they
|
||||
// must never be collapsed into one combined test of "looks
|
||||
// environmental".
|
||||
let has_prefix = inv.stdout.starts_with(CHECK5_NOT_RUN_PREFIX);
|
||||
let has_marker = CHECK5_ENVIRONMENTAL_MARKERS
|
||||
.iter()
|
||||
.any(|marker| inv.stdout.contains(marker));
|
||||
if has_prefix && has_marker {
|
||||
return Ok(FixtureOutcome::BusUnreachable(BusUnreachableEvidence {
|
||||
probe_description: format!(
|
||||
"python3 a11y-verifier/verify.py --fixture {} --app-name {APP_NAME} \
|
||||
(AT-SPI2 client via gi.repository.Atspi)",
|
||||
inv.fixture_id
|
||||
),
|
||||
probe_output: inv.stdout.clone(),
|
||||
}));
|
||||
}
|
||||
bail!(
|
||||
"{}: verify.py exited 2 but stdout does not satisfy both required conditions \
|
||||
(has_prefix={has_prefix}, has_marker={has_marker}) — treating as a hard usage \
|
||||
error, not environmental NOT RUN: {}\n{}",
|
||||
inv.fixture_id,
|
||||
inv.stdout,
|
||||
inv.stderr
|
||||
);
|
||||
}
|
||||
other => bail!(
|
||||
"{}: verify.py exited with unexpected status {other:?}\nstdout:\n{}\nstderr:\n{}",
|
||||
inv.fixture_id,
|
||||
inv.stdout,
|
||||
inv.stderr
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduces every fixture's [`FixtureOutcome`] (collected in whatever order
|
||||
/// they were attempted) to the round's overall result (F2).
|
||||
///
|
||||
/// A disqualifying `FAIL` found on **any** fixture always wins over a
|
||||
/// `BusUnreachable` found on another — an environmental `NotRun` is only
|
||||
/// admissible when **nothing failed**. The caller never short-circuits on
|
||||
/// the first `BusUnreachable` (see [`run_all_fixtures`]), so both orderings
|
||||
/// — a fail observed before a bus issue, or after one — reach this function
|
||||
/// with the same two facts and therefore produce the same verdict.
|
||||
fn reduce_outcomes(outcomes: Vec<FixtureOutcome>) -> A11yRoundResult {
|
||||
let mut scored = Vec::new();
|
||||
let mut bus_unreachable: Option<BusUnreachableEvidence> = None;
|
||||
for o in outcomes {
|
||||
match o {
|
||||
FixtureOutcome::Scored(ev) => scored.push(ev),
|
||||
FixtureOutcome::BusUnreachable(ev) => {
|
||||
if bus_unreachable.is_none() {
|
||||
bus_unreachable = Some(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let any_fail = scored.iter().any(|e| !e.pass);
|
||||
match bus_unreachable {
|
||||
Some(evidence) if !any_fail => A11yRoundResult::BusUnreachable {
|
||||
evidence,
|
||||
partial_scored: scored,
|
||||
},
|
||||
_ => A11yRoundResult::Scored(scored),
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-process, per-call identifier mixed into every `--json` output path
|
||||
/// this run creates (F1) — process id plus a nanosecond timestamp, cheap and
|
||||
/// dependency-free. Not a cryptographic uniqueness guarantee by itself
|
||||
/// (that is what the pre-spawn deletion in [`run_all_fixtures`] and the
|
||||
/// exit-status/verdict/fixture-id cross-checks in
|
||||
/// [`interpret_verify_output`] are for); it is the first line of defense,
|
||||
/// making an accidental collision with another run's leftover file
|
||||
/// vanishingly unlikely rather than structural.
|
||||
fn run_nonce() -> String {
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("{pid}-{nanos}")
|
||||
}
|
||||
|
||||
fn fresh_json_path(run_nonce: &str, fixture_id: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("c2-round2-a11y-{run_nonce}-{fixture_id}.json"))
|
||||
}
|
||||
|
||||
/// Runs `a11y-verifier/verify.py` once per fixture, against the one live
|
||||
/// window `a11y_wiring.rs` builds, out-of-process — the committed verifier
|
||||
/// is the only thing that ever classifies a tree (task instructions:
|
||||
/// "Scoring is not yours to decide. Run the committed verifier
|
||||
/// out-of-process.").
|
||||
///
|
||||
/// **Never short-circuits (F2):** every fixture in [`FIXTURE_ORDER`] is
|
||||
/// attempted regardless of what earlier fixtures returned, and the round's
|
||||
/// overall verdict is decided once, by [`reduce_outcomes`], only after all
|
||||
/// five outcomes are in hand.
|
||||
fn run_all_fixtures(spike_root: &Path, digest: &str) -> Result<A11yRoundResult> {
|
||||
let verify_py = spike_root.join("a11y-verifier/verify.py");
|
||||
let expectations = spike_root.join("round2-a11y-oracle/a11y_expectations.json");
|
||||
let nonce = run_nonce();
|
||||
let mut outcomes = Vec::with_capacity(FIXTURE_ORDER.len());
|
||||
|
||||
for fixture_id in FIXTURE_ORDER {
|
||||
let json_path = fresh_json_path(&nonce, fixture_id);
|
||||
// F1: never read a file this run did not write. Deleting whatever
|
||||
// (if anything) already occupies this path, immediately before
|
||||
// spawning, makes that a filesystem-level guarantee rather than
|
||||
// something inferred from the exit code alone.
|
||||
let _ = std::fs::remove_file(&json_path);
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg(&verify_py)
|
||||
.arg("--expectations")
|
||||
.arg(&expectations)
|
||||
.arg("--fixture")
|
||||
.arg(fixture_id)
|
||||
.arg("--app-name")
|
||||
.arg(APP_NAME)
|
||||
.arg("--expect-source-digest")
|
||||
.arg(digest)
|
||||
.arg("--json")
|
||||
.arg(&json_path)
|
||||
.arg("--timeout")
|
||||
.arg("10")
|
||||
.current_dir(spike_root)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("failed to spawn verify.py for {fixture_id}: {e}"))?;
|
||||
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: fixture_id.to_string(),
|
||||
exit_code: output.status.code(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
json_path,
|
||||
};
|
||||
// A hard interpretation error (exit status/json disagreement, a
|
||||
// fixture-id mismatch, an unrecognized exit code, ...) is a harness
|
||||
// defect, not a legitimate outcome to defer judgement on — it
|
||||
// aborts the whole round immediately, unlike a `FAIL` or a
|
||||
// `BusUnreachable`, both of which are trustworthy typed outcomes
|
||||
// `reduce_outcomes` is free to weigh against each other.
|
||||
outcomes.push(interpret_verify_output(&inv)?);
|
||||
}
|
||||
|
||||
Ok(reduce_outcomes(outcomes))
|
||||
}
|
||||
|
||||
/// Opens the one probe window (`a11y_wiring::run_window`, product-side) and
|
||||
/// scores all five fixtures against it (`run_all_fixtures`, this file),
|
||||
/// then closes the window.
|
||||
///
|
||||
/// **J2**: this function, not `a11y_wiring.rs`, owns the coordination — the
|
||||
/// worker thread, the fact that it runs `run_all_fixtures`, and delivering
|
||||
/// the result. It drives `run_window`'s generic surface with `T =
|
||||
/// Result<A11yRoundResult>`: the callback handed to `on_tree_published`
|
||||
/// does nothing but spawn a thread and return immediately (never blocking
|
||||
/// the event loop), and that thread's only two jobs are calling
|
||||
/// `run_all_fixtures` and calling [`crate::a11y_wiring::FinishHandle::finish`]
|
||||
/// with what it got.
|
||||
///
|
||||
/// `fixture_texts` must be in [`FIXTURE_ORDER`]'s order (F-A..F-E) — the
|
||||
/// caller (`c2_round2_text.rs`) builds it directly from the loaded
|
||||
/// `SpikeResolvedText::text` fields, never from a literal restated here, so
|
||||
/// a fixture whose source string changed is exercised as it actually is.
|
||||
pub fn run_a11y_round(
|
||||
spike_root: &Path,
|
||||
digest: &str,
|
||||
fixture_texts: [String; 5],
|
||||
) -> Result<A11yRoundResult> {
|
||||
let spike_root = spike_root.to_path_buf();
|
||||
let digest = digest.to_string();
|
||||
run_window(fixture_texts, move |handle| {
|
||||
std::thread::spawn(move || {
|
||||
let result = run_all_fixtures(&spike_root, &digest);
|
||||
handle.finish(result);
|
||||
});
|
||||
})?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch_json_path(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"c2-round2-a11y-subprocess-test-{name}-{}.json",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, body: &serde_json::Value) {
|
||||
std::fs::write(path, serde_json::to_string_pretty(body).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn passing_body(fixture_id: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"fixture_id": fixture_id,
|
||||
"verdict": "PASS",
|
||||
"reason": "a node with an accepted role carries the accessible name byte-for-byte",
|
||||
"observed_role": "paragraph",
|
||||
"observed_name": "whatever",
|
||||
"observed_name_hex": "77686174657665",
|
||||
"prohibited_outcome": null,
|
||||
"walked_tree": []
|
||||
})
|
||||
}
|
||||
|
||||
// ---- F1: freshness / exit-status-vs-json agreement ----
|
||||
|
||||
/// Required kill (F1): a **stale** file at this run's json path claims
|
||||
/// `PASS`, but this invocation's exit code says `FAIL` (1) — the stale
|
||||
/// file must never be picked up as this fixture's evidence.
|
||||
#[test]
|
||||
fn interpret_rejects_a_stale_file_whose_verdict_disagrees_with_the_exit_code() {
|
||||
let path = scratch_json_path("stale-verdict");
|
||||
write_json(&path, &passing_body("F-A"));
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(1),
|
||||
stdout: "CHECK5: FAIL\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path.clone(),
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
assert!(err.to_string().contains("disagree"), "{err}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Required kill (F1): a fresh file that names a **different** fixture
|
||||
/// id must never be attributed to this one, even if exit code and
|
||||
/// verdict otherwise agree.
|
||||
#[test]
|
||||
fn interpret_rejects_a_json_whose_fixture_id_does_not_match() {
|
||||
let path = scratch_json_path("wrong-fixture-id");
|
||||
write_json(&path, &passing_body("F-B"));
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(0),
|
||||
stdout: "CHECK5: PASS\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path.clone(),
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
assert!(err.to_string().contains("not the fixture"), "{err}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_accepts_a_fresh_agreeing_pass() {
|
||||
let path = scratch_json_path("agreeing-pass");
|
||||
write_json(&path, &passing_body("F-A"));
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(0),
|
||||
stdout: "CHECK5: PASS\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path.clone(),
|
||||
};
|
||||
let outcome = interpret_verify_output(&inv).unwrap();
|
||||
assert!(matches!(outcome, FixtureOutcome::Scored(e) if e.pass));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Required kill (F1): exit 2 with generic usage-error stdout (no
|
||||
/// prefix, no marker) and **no** fresh output present must be a hard
|
||||
/// error, never silently promoted to bus-unreachable merely because
|
||||
/// there is nothing to read.
|
||||
#[test]
|
||||
fn interpret_exit_2_without_prefix_or_marker_and_no_fresh_output_is_a_hard_error() {
|
||||
let path = scratch_json_path("exit2-no-markers");
|
||||
let _ = std::fs::remove_file(&path); // guarantee absence
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(2),
|
||||
stdout: "CHECK5: usage error — bad --fixture value\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path,
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
assert!(err.to_string().contains("usage error"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_exit_2_with_prefix_and_marker_and_no_fresh_output_is_bus_unreachable() {
|
||||
let path = scratch_json_path("exit2-with-markers");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(2),
|
||||
stdout: "CHECK5: NOT RUN — Atspi.init() failed: no bus\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path,
|
||||
};
|
||||
let outcome = interpret_verify_output(&inv).unwrap();
|
||||
assert!(matches!(outcome, FixtureOutcome::BusUnreachable(_)));
|
||||
}
|
||||
|
||||
/// **H2, required kill 1 of 2 (prefix present, marker absent).** stdout
|
||||
/// begins with the exact `CHECK5: NOT RUN` prefix, but names a cause
|
||||
/// this module does not recognise as environmental — must be a hard
|
||||
/// error. If the marker half of the conjunction were ever dropped
|
||||
/// (accept on prefix alone), this stdout would wrongly pass.
|
||||
#[test]
|
||||
fn interpret_exit_2_with_prefix_but_no_recognised_marker_is_a_hard_error() {
|
||||
let path = scratch_json_path("h2-prefix-no-marker");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(2),
|
||||
stdout: "CHECK5: NOT RUN — something we do not recognise\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path,
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("hard usage error"), "{msg}");
|
||||
// The message must report *which half* was unmet: prefix true,
|
||||
// marker false — proof this is the marker-missing case specifically,
|
||||
// not a generic rejection.
|
||||
assert!(msg.contains("has_prefix=true"), "{msg}");
|
||||
assert!(msg.contains("has_marker=false"), "{msg}");
|
||||
}
|
||||
|
||||
/// **H2, required kill 2 of 2 (marker present, prefix absent).** stdout
|
||||
/// contains a recognised environmental marker, but does not begin with
|
||||
/// the required `CHECK5: NOT RUN` prefix — must be a hard error. If the
|
||||
/// prefix half of the conjunction were ever dropped (accept on marker
|
||||
/// alone), this stdout would wrongly pass.
|
||||
#[test]
|
||||
fn interpret_exit_2_with_marker_but_no_prefix_is_a_hard_error() {
|
||||
let path = scratch_json_path("h2-marker-no-prefix");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(2),
|
||||
// A recognised marker string is present, but as a *substring*
|
||||
// of some other sentence, not as the required prefix.
|
||||
stdout: "some unrelated wrapper reported: Atspi.init() failed somewhere downstream\n"
|
||||
.to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path,
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("hard usage error"), "{msg}");
|
||||
// The message must report *which half* was unmet: marker true,
|
||||
// prefix false — proof this is the prefix-missing case specifically,
|
||||
// not a generic rejection.
|
||||
assert!(msg.contains("has_prefix=false"), "{msg}");
|
||||
assert!(msg.contains("has_marker=true"), "{msg}");
|
||||
}
|
||||
|
||||
/// Required kill (F1): exit 2 with the required prefix and marker, but a
|
||||
/// fresh json output **exists anyway** — a contradiction (an exit-2 run
|
||||
/// must never have written scored output), so this must be a hard
|
||||
/// error, not accepted as bus-unreachable evidence.
|
||||
#[test]
|
||||
fn interpret_exit_2_with_prefix_and_marker_but_a_fresh_json_present_is_a_hard_error() {
|
||||
let path = scratch_json_path("exit2-contradiction");
|
||||
write_json(&path, &passing_body("F-A"));
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(2),
|
||||
stdout: "CHECK5: NOT RUN — Atspi.init() failed: no bus\n".to_string(),
|
||||
stderr: String::new(),
|
||||
json_path: path.clone(),
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
assert!(err.to_string().contains("contradiction"), "{err}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_missing_json_on_exit_0_is_a_hard_error() {
|
||||
let path = scratch_json_path("missing-on-exit0");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let inv = VerifyInvocation {
|
||||
fixture_id: "F-A".to_string(),
|
||||
exit_code: Some(0),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
json_path: path,
|
||||
};
|
||||
let err = interpret_verify_output(&inv).unwrap_err();
|
||||
assert!(err.to_string().contains("could not be read"), "{err}");
|
||||
}
|
||||
|
||||
// ---- F2: a FAIL wins over a BusUnreachable found elsewhere, in either order ----
|
||||
|
||||
fn fail_evidence(id: &str) -> A11yEvidence {
|
||||
A11yEvidence {
|
||||
fixture_id: id.to_string(),
|
||||
platform: "at-spi2".to_string(),
|
||||
observed_name: Some("".to_string()),
|
||||
observed_name_bytes_hex: Some("".to_string()),
|
||||
observed_role: None,
|
||||
prohibited_outcome: Some("absent-from-tree".to_string()),
|
||||
pass: false,
|
||||
notes: "no accessible-text-candidate node found".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pass_evidence(id: &str) -> A11yEvidence {
|
||||
A11yEvidence {
|
||||
fixture_id: id.to_string(),
|
||||
platform: "at-spi2".to_string(),
|
||||
observed_name: Some("x".to_string()),
|
||||
observed_name_bytes_hex: Some("78".to_string()),
|
||||
observed_role: Some("paragraph".to_string()),
|
||||
prohibited_outcome: None,
|
||||
pass: true,
|
||||
notes: "byte-for-byte".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn some_bus_evidence() -> BusUnreachableEvidence {
|
||||
BusUnreachableEvidence {
|
||||
probe_description: "test probe".to_string(),
|
||||
probe_output: "CHECK5: NOT RUN — Atspi.init() failed: no bus".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Required kill (F2): a FAIL observed before a bus-unreachable outcome
|
||||
/// still disqualifies — the round must not report NotRun.
|
||||
#[test]
|
||||
fn a_fail_before_a_bus_unreachable_still_wins() {
|
||||
let outcomes = vec![
|
||||
FixtureOutcome::Scored(fail_evidence("F-A")),
|
||||
FixtureOutcome::BusUnreachable(some_bus_evidence()),
|
||||
];
|
||||
let result = reduce_outcomes(outcomes);
|
||||
match result {
|
||||
A11yRoundResult::Scored(evidence) => {
|
||||
assert!(evidence.iter().any(|e| !e.pass), "the FAIL must survive");
|
||||
}
|
||||
A11yRoundResult::BusUnreachable { .. } => {
|
||||
panic!("a FAIL found anywhere must never be erased by a later BusUnreachable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Required kill (F2), the other ordering: a bus-unreachable observed
|
||||
/// **before** a FAIL must reach the exact same verdict as the previous
|
||||
/// test — ordering must never decide a disqualifying check.
|
||||
#[test]
|
||||
fn a_bus_unreachable_before_a_fail_still_loses_to_the_fail() {
|
||||
let outcomes = vec![
|
||||
FixtureOutcome::BusUnreachable(some_bus_evidence()),
|
||||
FixtureOutcome::Scored(fail_evidence("F-C")),
|
||||
];
|
||||
let result = reduce_outcomes(outcomes);
|
||||
match result {
|
||||
A11yRoundResult::Scored(evidence) => {
|
||||
assert!(evidence.iter().any(|e| !e.pass), "the FAIL must survive");
|
||||
}
|
||||
A11yRoundResult::BusUnreachable { .. } => {
|
||||
panic!(
|
||||
"the FAIL must win regardless of whether the BusUnreachable was observed \
|
||||
before or after it"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bus-unreachable with **no** FAIL anywhere is the legitimate
|
||||
/// environmental-absence case — this is the one place `BusUnreachable`
|
||||
/// is allowed to be the verdict.
|
||||
#[test]
|
||||
fn a_bus_unreachable_with_no_fail_anywhere_is_not_run() {
|
||||
let outcomes = vec![
|
||||
FixtureOutcome::Scored(pass_evidence("F-A")),
|
||||
FixtureOutcome::BusUnreachable(some_bus_evidence()),
|
||||
FixtureOutcome::Scored(pass_evidence("F-D")),
|
||||
];
|
||||
let result = reduce_outcomes(outcomes);
|
||||
assert!(matches!(result, A11yRoundResult::BusUnreachable { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_pass_and_no_bus_issue_is_scored() {
|
||||
let outcomes = vec![
|
||||
FixtureOutcome::Scored(pass_evidence("F-A")),
|
||||
FixtureOutcome::Scored(pass_evidence("F-B")),
|
||||
];
|
||||
let result = reduce_outcomes(outcomes);
|
||||
match result {
|
||||
A11yRoundResult::Scored(evidence) => assert_eq!(evidence.len(), 2),
|
||||
A11yRoundResult::BusUnreachable { .. } => panic!("no bus issue was reported"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
//! Check 5, `ReportPart::AccessibilityTreeConstruction`: building the
|
||||
//! accessible node(s) — role, name, relationships — derived from the
|
||||
//! resolved text. **Semantic content only.** Getting this tree to the
|
||||
//! platform (adapter lifecycle, event-loop plumbing, window/bridge setup,
|
||||
//! subprocess orchestration of the verifier) is a *different* part of the
|
||||
//! cost table and lives in `a11y_wiring.rs`, never here — the coordinator's
|
||||
//! common attribution rule requires every `ReportPart` to map to a disjoint
|
||||
//! set of whole files, and this file is exactly and only the semantic half.
|
||||
//!
|
||||
//! **One window, five sibling nodes.** winit does not support tearing an
|
||||
//! `EventLoop` down and building a second one in the same process on every
|
||||
//! platform, so rather than open and close five windows in sequence,
|
||||
//! `build_initial_tree` builds one window's tree carrying one
|
||||
//! `Role::Paragraph` child per fixture (F-A..F-E, name = that fixture's
|
||||
//! exact source string) — `a11y_wiring.rs` scores all five fixtures against
|
||||
//! that single live window, one `a11y-verifier/verify.py` subprocess
|
||||
//! invocation per fixture.
|
||||
|
||||
use accesskit::{Node as AccessNode, NodeId as AccessNodeId, Role, Tree, TreeId, TreeUpdate};
|
||||
|
||||
pub const WINDOW_TITLE: &str = "EpiphanyC2Round2Text";
|
||||
pub const ROOT_ID: AccessNodeId = AccessNodeId(0);
|
||||
pub const FIXTURE_NODE_IDS: [AccessNodeId; 5] = [
|
||||
AccessNodeId(1),
|
||||
AccessNodeId(2),
|
||||
AccessNodeId(3),
|
||||
AccessNodeId(4),
|
||||
AccessNodeId(5),
|
||||
];
|
||||
|
||||
/// One `Role::Paragraph` node whose accessible name is `text` **verbatim** —
|
||||
/// the fixture's exact source string, never a shaped/ligated rendering of
|
||||
/// it. `Role::Paragraph` maps to AT-SPI2 role `"paragraph"`, one of recipe
|
||||
/// §8.2's accepted at-spi2 roles (verified against
|
||||
/// `accesskit_atspi_common-0.19.1`'s `Role::Paragraph => AtspiRole::Paragraph`
|
||||
/// mapping and `atspi-common-0.13.0`'s role-name table `"paragraph"`, both in
|
||||
/// this workspace's lockfile).
|
||||
///
|
||||
/// **Deliberately not `Role::Label`**, despite it also being accepted:
|
||||
/// `accesskit_consumer::Node::label_comes_from_value` special-cases exactly
|
||||
/// `Role::Label` to read the accessible name from the node's *value*
|
||||
/// property rather than its *label* property (`accesskit_consumer-0.36.0`
|
||||
/// `node.rs:735`, in this workspace's lockfile). Measured directly on this
|
||||
/// packet's first run: a `Role::Label` node with `set_label(text)` and no
|
||||
/// `set_value` reached AT-SPI with an accessible name of `""` — precisely
|
||||
/// the `name-empty` prohibited outcome recipe §8.3 pins, and not a
|
||||
/// substitution or a drop, but a role/property mismatch this candidate's own
|
||||
/// choice of role caused. `Role::Paragraph` carries no such special case, so
|
||||
/// `set_label` alone is sufficient.
|
||||
pub fn build_fixture_node(text: &str) -> AccessNode {
|
||||
let mut node = AccessNode::new(Role::Paragraph);
|
||||
node.set_label(text);
|
||||
node
|
||||
}
|
||||
|
||||
pub fn build_root() -> AccessNode {
|
||||
let mut node = AccessNode::new(Role::Window);
|
||||
node.set_children(FIXTURE_NODE_IDS.to_vec());
|
||||
node.set_label(WINDOW_TITLE);
|
||||
node
|
||||
}
|
||||
|
||||
pub fn build_initial_tree(fixture_texts: &[String; 5]) -> TreeUpdate {
|
||||
let mut nodes = vec![(ROOT_ID, build_root())];
|
||||
for (id, text) in FIXTURE_NODE_IDS.iter().zip(fixture_texts.iter()) {
|
||||
nodes.push((*id, build_fixture_node(text)));
|
||||
}
|
||||
TreeUpdate {
|
||||
nodes,
|
||||
tree: Some(Tree::new(ROOT_ID)),
|
||||
tree_id: TreeId::ROOT,
|
||||
focus: FIXTURE_NODE_IDS[0],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_root_carries_all_five_fixture_children_in_order() {
|
||||
let root = build_root();
|
||||
assert_eq!(root.role(), Role::Window);
|
||||
assert_eq!(root.children(), &FIXTURE_NODE_IDS[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixture_node_is_a_paragraph_carrying_the_exact_text_as_its_label() {
|
||||
let node = build_fixture_node("Coro \u{0627}");
|
||||
assert_eq!(node.role(), Role::Paragraph);
|
||||
assert_eq!(node.label(), Some("Coro \u{0627}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_initial_tree_has_six_nodes_and_focuses_the_first_fixture() {
|
||||
let texts: [String; 5] = [
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
"e".to_string(),
|
||||
];
|
||||
let update = build_initial_tree(&texts);
|
||||
assert_eq!(update.nodes.len(), 6);
|
||||
assert_eq!(update.focus, FIXTURE_NODE_IDS[0]);
|
||||
assert_eq!(update.nodes[1].1.label(), Some("a"));
|
||||
assert_eq!(update.nodes[5].1.label(), Some("e"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
//! Check 5, `ReportPart::AccessibilityIntegrationWiring`: getting the tree
|
||||
//! `a11y_tree.rs` builds onto the platform — **product-side only**: adapter
|
||||
//! lifecycle, event loop, window and bridge setup, and tree publication.
|
||||
//!
|
||||
//! **Ruling (H1/J2): nothing that exists only to drive or await the
|
||||
//! verifier lives here.** A real editor shipping this stack keeps exactly
|
||||
//! what this file has — the window, the `accesskit_winit::Adapter`, the
|
||||
//! event loop, publishing the tree `a11y_tree.rs` builds — and none of the
|
||||
//! coordination that spawns `a11y-verifier/verify.py`, waits for it, or
|
||||
//! decides what its output means, because that machinery exists only
|
||||
//! because this spike scores itself out-of-process. That coordination is
|
||||
//! `a11y_subprocess.rs`'s (`ReportPart::FixtureAndReportPlumbing`). An
|
||||
//! earlier revision kept the worker thread, the readiness channel, and the
|
||||
//! verifier's own result type in this file because the event loop "must run
|
||||
//! while the subprocess does" — true, but that is a reason to expose a
|
||||
//! narrow product-side surface the plumbing drives, not a reason to keep
|
||||
//! the coordination itself here.
|
||||
//!
|
||||
//! **The seam, concretely.** [`run_window`] is generic over `T` and knows
|
||||
//! nothing about verifiers, subprocesses, or `A11yRoundResult` — it runs the
|
||||
//! window, calls `on_tree_published` exactly once (synchronously, from the
|
||||
//! winit thread, the instant the tree has actually been pushed to the
|
||||
//! platform), and blocks until *something* calls [`FinishHandle::finish`]
|
||||
//! with a `T`, then closes the window and returns that `T`. What `T` is,
|
||||
//! what `on_tree_published` does with the handle it receives (spawn a
|
||||
//! thread; run a subprocess; anything), and how the result gets computed
|
||||
//! are entirely the caller's concern (`a11y_subprocess::run_a11y_round`,
|
||||
//! the only caller in this packet). This is deliberately reusable for
|
||||
//! reasons that have nothing to do with check 5's verifier — the window
|
||||
//! lifecycle a real product needs is exactly this and no more.
|
||||
//!
|
||||
//! vello ships no accessibility layer of its own, so — as `probe-vello`'s
|
||||
//! Round 0 precedent already established for this candidate — this is a
|
||||
//! **manual `accesskit_winit` wiring**: an accessibility tree built by hand
|
||||
//! (`a11y_tree.rs`) and pushed through `accesskit_winit::Adapter`, driven
|
||||
//! from a real winit `ApplicationHandler`. Unlike `probe-vello`, this mode
|
||||
//! does not also drive a vello render pass: check 5 asks only whether the
|
||||
//! run appears in the live platform tree as its source string, and the
|
||||
//! headless rendering that answers check 1 already lives in `render.rs`.
|
||||
//! Skipping the GPU surface here is a real simplification, named as one
|
||||
//! rather than silently taken — see `c2_round2_text.rs`'s cost record.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent};
|
||||
use anyhow::{anyhow, Result};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::LogicalSize;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
use crate::a11y_tree::{build_initial_tree, WINDOW_TITLE};
|
||||
|
||||
enum AppEvent<T> {
|
||||
AccessKit(AccessKitEvent),
|
||||
/// Delivered by [`FinishHandle::finish`] — the window closes and
|
||||
/// [`run_window`] returns this value. This is the entire coordination
|
||||
/// surface: what produces `T`, and when, is never this file's concern.
|
||||
Finish(T),
|
||||
}
|
||||
|
||||
impl<T> From<AccessKitEvent> for AppEvent<T> {
|
||||
fn from(e: AccessKitEvent) -> Self {
|
||||
AppEvent::AccessKit(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handed to `on_tree_published` exactly once, the instant [`run_window`]'s
|
||||
/// tree has actually been pushed to the platform. Calling
|
||||
/// [`FinishHandle::finish`] (from any thread) is the only way the window
|
||||
/// closes and `run_window` returns — the window otherwise waits
|
||||
/// indefinitely, which is why every caller must eventually call it.
|
||||
pub struct FinishHandle<T: Send + 'static> {
|
||||
proxy: EventLoopProxy<AppEvent<T>>,
|
||||
}
|
||||
|
||||
impl<T: Send + 'static> FinishHandle<T> {
|
||||
pub fn finish(&self, value: T) {
|
||||
let _ = self.proxy.send_event(AppEvent::Finish(value));
|
||||
}
|
||||
}
|
||||
|
||||
struct A11yApp<T: Send + 'static> {
|
||||
proxy: EventLoopProxy<AppEvent<T>>,
|
||||
fixture_texts: [String; 5],
|
||||
window: Option<Arc<Window>>,
|
||||
adapter: Option<Adapter>,
|
||||
on_tree_published: Option<Box<dyn FnOnce(FinishHandle<T>) + Send>>,
|
||||
result: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: Send + 'static> ApplicationHandler<AppEvent<T>> for A11yApp<T> {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attrs = Window::default_attributes()
|
||||
.with_inner_size(LogicalSize::new(480.0, 320.0))
|
||||
.with_title(WINDOW_TITLE);
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(attrs)
|
||||
.expect("failed to create the round 2 a11y probe window"),
|
||||
);
|
||||
// Manual accesskit_winit wiring, exactly probe-vello's Round 0 route
|
||||
// (this file's module doc comment), minus the vello render pass.
|
||||
let adapter = Adapter::with_event_loop_proxy(event_loop, &window, self.proxy.clone());
|
||||
self.window = Some(window);
|
||||
self.adapter = Some(adapter);
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
window_id: WindowId,
|
||||
event: WindowEvent,
|
||||
) {
|
||||
let Some(window) = self.window.clone() else {
|
||||
return;
|
||||
};
|
||||
if window.id() != window_id {
|
||||
return;
|
||||
}
|
||||
if let Some(adapter) = &mut self.adapter {
|
||||
adapter.process_event(&window, &event);
|
||||
}
|
||||
if let WindowEvent::CloseRequested = event {
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent<T>) {
|
||||
match event {
|
||||
AppEvent::AccessKit(ak_event) => {
|
||||
if let AccessKitWindowEvent::InitialTreeRequested = ak_event.window_event {
|
||||
let texts = self.fixture_texts.clone();
|
||||
if let Some(adapter) = &mut self.adapter {
|
||||
// Tree publication: push the tree a11y_tree.rs
|
||||
// built, onto the platform, via the adapter this
|
||||
// file owns the lifecycle of.
|
||||
adapter.update_if_active(move || build_initial_tree(&texts));
|
||||
}
|
||||
// The tree is now actually live. Notify the caller
|
||||
// exactly once, synchronously — this file has no
|
||||
// opinion on what happens next, only that it *can*
|
||||
// happen now. The callback must not block (it runs on
|
||||
// the event loop's own thread); every real caller
|
||||
// spawns a thread and returns immediately.
|
||||
if let Some(cb) = self.on_tree_published.take() {
|
||||
let handle = FinishHandle {
|
||||
proxy: self.proxy.clone(),
|
||||
};
|
||||
cb(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::Finish(value) => {
|
||||
self.result = Some(value);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the one probe window and builds its accessibility tree
|
||||
/// (`a11y_tree::build_initial_tree`); calls `on_tree_published` exactly
|
||||
/// once, the instant that tree is actually live, handing it a
|
||||
/// [`FinishHandle`]; blocks until something calls
|
||||
/// [`FinishHandle::finish`], then closes the window and returns the
|
||||
/// delivered value.
|
||||
///
|
||||
/// This function is the entire product-side surface (H1/J2's ruling): it
|
||||
/// knows nothing about verifiers, subprocesses, or check-5 scoring — `T` is
|
||||
/// whatever the caller needs delivered, and `on_tree_published` is where the
|
||||
/// caller's own coordination (spawning a thread, running a subprocess,
|
||||
/// anything) begins. `a11y_subprocess::run_a11y_round` is the only caller
|
||||
/// in this packet.
|
||||
pub fn run_window<T: Send + 'static>(
|
||||
fixture_texts: [String; 5],
|
||||
on_tree_published: impl FnOnce(FinishHandle<T>) + Send + 'static,
|
||||
) -> Result<T> {
|
||||
let event_loop = EventLoop::<AppEvent<T>>::with_user_event().build()?;
|
||||
let proxy = event_loop.create_proxy();
|
||||
|
||||
let mut app = A11yApp {
|
||||
proxy,
|
||||
fixture_texts,
|
||||
window: None,
|
||||
adapter: None,
|
||||
on_tree_published: Some(Box::new(on_tree_published)),
|
||||
result: None,
|
||||
};
|
||||
event_loop.run_app(&mut app)?;
|
||||
|
||||
app.result.take().ok_or_else(|| {
|
||||
anyhow!("a11y probe window closed with no value delivered via FinishHandle::finish")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
//! Check 4 (hit testing): point -> (byte offset, affinity) resolution.
|
||||
//!
|
||||
//! **This is the candidate-owned part.** `round2-candidatekit` loads the
|
||||
//! committed `hittest_probes.json` (the *expected* answers) but explicitly
|
||||
//! must not resolve one — that is what this check measures
|
||||
//! (`round2_candidatekit::inputs` module doc: "Loading the expected answers
|
||||
//! ... is neutral. Computing them is the candidate's job"). This module does
|
||||
//! not call any of `round2_textkit::hittest`'s probe-*generation* functions
|
||||
//! (`build_probe_table`, `build_all`, ...) — those built the very expected
|
||||
//! values this module is scored against, so calling them here would make the
|
||||
//! check circular. The only thing reused from that module is
|
||||
//! `to_device` — sanctioned by the task instructions as neutral geometry the
|
||||
//! reference also uses.
|
||||
//!
|
||||
//! ## The resolution rule this module implements
|
||||
//!
|
||||
//! `ROUND2_TEXT_RECIPE.md` §7's closing paragraph, restated in
|
||||
//! `round2_textkit::hittest`'s own module doc comment as the semantics the
|
||||
//! *committed probe table* assumes (not as code this module may call): every
|
||||
//! grapheme's own `Downstream` caret stop, resolved to device space and
|
||||
//! sorted by device x, partitions the line into non-overlapping intervals
|
||||
//! with no gaps. A query point resolves to the stop that begins the interval
|
||||
//! containing it — "floor" to the nearest stop at or before the point, never
|
||||
//! a nearest-neighbour vote. Every probe in the committed table expects
|
||||
//! `Downstream` affinity (recipe §7's own stated consequence of this rule),
|
||||
//! so this resolver always returns `Downstream`.
|
||||
|
||||
use round2_textkit::hittest::{to_device, DevicePoint};
|
||||
use round2_textkit::types::{SpikeCaretAffinity, SpikeResolvedText};
|
||||
|
||||
/// One grapheme's own `Downstream` caret stop, in device space.
|
||||
struct Stop {
|
||||
device_x: f64,
|
||||
source_offset: u32,
|
||||
}
|
||||
|
||||
/// Every `Downstream` caret stop in `rt`, sorted ascending by device x. An
|
||||
/// RTL segment's clusters are byte-ascending but device-x-descending, so
|
||||
/// sorting by device x (not source order) is what makes the floor lookup
|
||||
/// below correct on F-B/F-D's Hebrew segments as well as the LTR ones.
|
||||
fn downstream_stops_by_device_x(rt: &SpikeResolvedText) -> Vec<Stop> {
|
||||
let mut stops: Vec<Stop> = rt
|
||||
.clusters
|
||||
.clusters
|
||||
.iter()
|
||||
.flat_map(|c| c.caret_stops.iter())
|
||||
.filter(|s| s.affinity == SpikeCaretAffinity::Downstream)
|
||||
.map(|s| {
|
||||
let d = to_device(rt, &s.position);
|
||||
Stop {
|
||||
device_x: d.x,
|
||||
source_offset: s.source_offset,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
stops.sort_by(|a, b| {
|
||||
a.device_x
|
||||
.partial_cmp(&b.device_x)
|
||||
.expect("device x is always finite")
|
||||
});
|
||||
stops
|
||||
}
|
||||
|
||||
/// Resolves one device point to `(byte offset, affinity)` against `rt`'s own
|
||||
/// resolved caret-stop data — this candidate's own hit-test implementation,
|
||||
/// not a lookup into any precommitted table.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `rt` has no caret stops at all (every fixture in this recipe
|
||||
/// has at least one grapheme, so this never fires on the committed set; a
|
||||
/// degenerate empty-text fixture would need a different contract, not a
|
||||
/// silently invented answer).
|
||||
pub fn resolve_hit(rt: &SpikeResolvedText, point: &DevicePoint) -> (u32, SpikeCaretAffinity) {
|
||||
let stops = downstream_stops_by_device_x(rt);
|
||||
assert!(
|
||||
!stops.is_empty(),
|
||||
"resolve_hit: no Downstream caret stops in this SpikeResolvedText — nothing to resolve against"
|
||||
);
|
||||
|
||||
// Floor: the last stop whose device x is <= the query point's x. Before
|
||||
// the first stop, clamp to the first (recipe §7: a probe placed before
|
||||
// the first caret stop still expects that stop's own offset).
|
||||
let mut chosen = &stops[0];
|
||||
for s in &stops {
|
||||
if s.device_x <= point.x {
|
||||
chosen = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(chosen.source_offset, SpikeCaretAffinity::Downstream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use round2_textkit::identity::{
|
||||
SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent,
|
||||
};
|
||||
use round2_textkit::types::{
|
||||
SpikeBoundingBox, SpikeCaretStop, SpikeCluster, SpikeClusterMap, SpikeGlyphStyle,
|
||||
SpikeLanguageTag, SpikePoint, SpikePositionedGlyph, SpikeProvenance, SpikeScriptTag,
|
||||
SpikeShapedSegment, SpikeStaffSpace, SpikeTextAlign, SpikeTextDirection,
|
||||
SpikeTypedObjectId,
|
||||
};
|
||||
|
||||
fn dummy_identity() -> SpikeTextShapingIdentity {
|
||||
SpikeTextShapingIdentity {
|
||||
faces: Vec::new(),
|
||||
shaper: SpikeShaperId("rustybuzz".to_string()),
|
||||
shaper_version: SemVerRecord {
|
||||
major: 0,
|
||||
minor: 20,
|
||||
patch: 1,
|
||||
},
|
||||
features: Vec::new(),
|
||||
unicode_bidi: SpikeUnicodeComponent {
|
||||
impl_name: "unicode-bidi".to_string(),
|
||||
crate_version: "0.3.18".to_string(),
|
||||
unicode_version: Some("16.0.0".to_string()),
|
||||
},
|
||||
unicode_segmentation: SpikeUnicodeComponent {
|
||||
impl_name: "unicode-segmentation".to_string(),
|
||||
crate_version: "1.13.3".to_string(),
|
||||
unicode_version: Some("17.0.0".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy_provenance() -> SpikeProvenance {
|
||||
SpikeProvenance {
|
||||
source: SpikeTypedObjectId {
|
||||
discriminant: 0,
|
||||
canonical_bytes_hex: "00".repeat(18),
|
||||
},
|
||||
synthesis: None,
|
||||
dependencies: Vec::new(),
|
||||
stable_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Three graphemes at staff-space x = 0.0, 1.0, 2.0 (device x 100, 200,
|
||||
/// 300 relative to origin 0,0) — enough to test floor lookup at an
|
||||
/// interior midpoint, before the first stop, and after the last.
|
||||
fn three_stops() -> SpikeResolvedText {
|
||||
let seg = SpikeShapedSegment {
|
||||
face: Some(0),
|
||||
glyphs: vec![
|
||||
SpikePositionedGlyph {
|
||||
glyph_id: 1,
|
||||
offset: SpikePoint::new(0.0, 0.0),
|
||||
transform: None,
|
||||
},
|
||||
SpikePositionedGlyph {
|
||||
glyph_id: 2,
|
||||
offset: SpikePoint::new(1.0, 0.0),
|
||||
transform: None,
|
||||
},
|
||||
SpikePositionedGlyph {
|
||||
glyph_id: 3,
|
||||
offset: SpikePoint::new(2.0, 0.0),
|
||||
transform: None,
|
||||
},
|
||||
],
|
||||
source: 0..3,
|
||||
direction: SpikeTextDirection::Ltr,
|
||||
script: SpikeScriptTag("Latn".to_string()),
|
||||
language: SpikeLanguageTag(None),
|
||||
size: SpikeStaffSpace(1.28),
|
||||
};
|
||||
let mk = |byte: u32, x: f64| SpikeCluster {
|
||||
source: byte..byte + 1,
|
||||
segment: 0,
|
||||
glyph_indices: vec![byte],
|
||||
resolved: true,
|
||||
grapheme_count: 1,
|
||||
caret_stops: vec![SpikeCaretStop {
|
||||
source_offset: byte,
|
||||
position: SpikePoint::new(x, 0.0),
|
||||
affinity: SpikeCaretAffinity::Downstream,
|
||||
}],
|
||||
};
|
||||
SpikeResolvedText {
|
||||
provenance: dummy_provenance(),
|
||||
text: "abc".to_string(),
|
||||
shaping: dummy_identity(),
|
||||
segments: vec![seg],
|
||||
clusters: SpikeClusterMap {
|
||||
clusters: vec![mk(0, 0.0), mk(1, 1.0), mk(2, 2.0)],
|
||||
},
|
||||
bounds: SpikeBoundingBox {
|
||||
left: 0.0,
|
||||
bottom: 0.0,
|
||||
right: 2.0,
|
||||
top: 1.0,
|
||||
},
|
||||
reserved_box: SpikeBoundingBox {
|
||||
left: 0.0,
|
||||
bottom: 0.0,
|
||||
right: 2.0,
|
||||
top: 1.0,
|
||||
},
|
||||
origin: SpikePoint::new(0.0, 0.0),
|
||||
align: SpikeTextAlign::Start,
|
||||
style: SpikeGlyphStyle { rgba: 0x0000_00ff },
|
||||
layer: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutation-first: an interior point between stop 0 (device x=0) and
|
||||
/// stop 1 (device x=100) must resolve to stop 0's offset, not stop 1's —
|
||||
/// the floor rule, not a nearest-neighbour vote (which would flip the
|
||||
/// answer past the literal midpoint at x=50, not matter here, but would
|
||||
/// give the WRONG answer at e.g. x=90 under a nearest-stop rule, since
|
||||
/// 90 is nearer to 100 than to 0). This point (x=60) is nearer to 0? no —
|
||||
/// 60 is nearer to 100 under Euclidean distance (|60-0|=60 > |60-100|=40
|
||||
/// is false, so pick a point where floor and nearest disagree instead:
|
||||
/// x=90 is nearer to stop 1 (distance 10) than stop 0 (distance 90), so a
|
||||
/// nearest-neighbour implementation would wrongly return offset 1 here,
|
||||
/// while the correct floor rule returns offset 0.
|
||||
#[test]
|
||||
fn floor_not_nearest_neighbour() {
|
||||
let rt = three_stops();
|
||||
let (offset, affinity) = resolve_hit(&rt, &DevicePoint { x: 90.0, y: 0.0 });
|
||||
assert_eq!(
|
||||
offset, 0,
|
||||
"floor rule must pick the stop at-or-before the point, not the nearer one"
|
||||
);
|
||||
assert_eq!(affinity, SpikeCaretAffinity::Downstream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_the_first_stop_clamps_to_it() {
|
||||
let rt = three_stops();
|
||||
let (offset, _) = resolve_hit(&rt, &DevicePoint { x: -50.0, y: 0.0 });
|
||||
assert_eq!(offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_the_last_stop_resolves_to_it() {
|
||||
let rt = three_stops();
|
||||
let (offset, _) = resolve_hit(&rt, &DevicePoint { x: 1000.0, y: 0.0 });
|
||||
assert_eq!(offset, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_on_a_stop_resolves_to_that_stop() {
|
||||
let rt = three_stops();
|
||||
// byte 1 sits at staff x=1.0 -> device x=100.0.
|
||||
let (offset, _) = resolve_hit(&rt, &DevicePoint { x: 100.0, y: 0.0 });
|
||||
assert_eq!(offset, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
//! Check 1 (faithful consumption) + check 2's rendering half: offscreen vello
|
||||
//! rendering of a `SpikeResolvedText`, drawing exactly the resolved
|
||||
//! `(face, glyph_id, offset)` triples — no text layout API, no font
|
||||
//! fallback, no rustybuzz.
|
||||
//!
|
||||
//! **The candidate-owned part named in the task instructions**: outline
|
||||
//! extraction from the face and conversion to a `kurbo::BezPath`. This module
|
||||
//! implements `ttf_parser::OutlineBuilder` directly, the same way
|
||||
//! `round2-svgref` does for its own (SVG-string, reference-only) output — but
|
||||
//! that crate is deliberately not depended on here: its output type is a
|
||||
//! `<path d="...">` string for the reference emitter, not `kurbo` geometry
|
||||
//! for a candidate to feed `vello::Scene::fill`.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use ttf_parser::{Face as TtfFace, GlyphId, OutlineBuilder};
|
||||
use vello::kurbo::{Affine, BezPath, Point as KPoint};
|
||||
use vello::peniko::{color::palette, Color, Fill};
|
||||
use vello::wgpu;
|
||||
use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
|
||||
|
||||
use round2_candidatekit::inputs::{HEIGHT, WIDTH};
|
||||
use round2_textkit::faces::LoadedFace;
|
||||
use round2_textkit::hittest::to_device;
|
||||
use round2_textkit::types::SpikeResolvedText;
|
||||
use round2_textkit::DEVICE_SCALE;
|
||||
|
||||
/// Opaque black ink on an opaque white ground (recipe §3/§10: "verified:
|
||||
/// every reference pixel has alpha 255, ground is #ffffff, ink is #000000"),
|
||||
/// the same convention Round 1 used.
|
||||
const INK: Color = palette::css::BLACK;
|
||||
const GROUND: Color = palette::css::WHITE;
|
||||
|
||||
/// vello's `render_to_texture` requires `Rgba8Unorm` + `STORAGE_BINDING` —
|
||||
/// same literal deviation from pin 4's "sRGB target format" Round 1's C2
|
||||
/// documented and for the same reason; immaterial here since `round2_diff`
|
||||
/// compares luma classifications, not raw channel values under a transfer
|
||||
/// function.
|
||||
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// Same AA config Round 1's C2 used, so this packet's choice is traceable to
|
||||
/// that precedent rather than picked fresh; Round 2's check 1 is a bounded
|
||||
/// visual differential against a reference raster (recipe §10), not a
|
||||
/// pixel-exact comparison, so the AA method does not change the outcome the
|
||||
/// way it would in Round 4's timings.
|
||||
const AA: AaConfig = AaConfig::Msaa8;
|
||||
|
||||
pub struct Gpu {
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
pub renderer: Renderer,
|
||||
pub adapter_name: String,
|
||||
pub adapter_device_type: String,
|
||||
}
|
||||
|
||||
/// Initializes one headless wgpu device + vello renderer, reused across all
|
||||
/// five fixtures. Prefers the integrated adapter (pin 4: "the integrated
|
||||
/// adapter's figures decide"), falling back to the first enumerated Vulkan
|
||||
/// adapter — Round 2's check 1 is a correctness check, not the adapter-class
|
||||
/// comparison pin 4 requires for Round 4's timings, so a single adapter is
|
||||
/// sufficient here and the one chosen is recorded in the report.
|
||||
pub fn init_gpu() -> Result<Gpu> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::VULKAN,
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN));
|
||||
if adapters.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"NOT RUN: no Vulkan adapters enumerated — environment absence, not a candidate defect"
|
||||
));
|
||||
}
|
||||
let adapter = adapters
|
||||
.iter()
|
||||
.find(|a| a.get_info().device_type == wgpu::DeviceType::IntegratedGpu)
|
||||
.unwrap_or(&adapters[0]);
|
||||
let info = adapter.get_info();
|
||||
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("c2-vello-round2-text"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::default(),
|
||||
trace: wgpu::Trace::Off,
|
||||
experimental_features: wgpu::ExperimentalFeatures::disabled(),
|
||||
}))?;
|
||||
|
||||
let renderer = Renderer::new(
|
||||
&device,
|
||||
RendererOptions {
|
||||
use_cpu: false,
|
||||
antialiasing_support: AaSupport::all(),
|
||||
num_init_threads: None,
|
||||
pipeline_cache: None,
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow!("vello Renderer::new failed: {e}"))?;
|
||||
|
||||
Ok(Gpu {
|
||||
device,
|
||||
queue,
|
||||
renderer,
|
||||
adapter_name: info.name.clone(),
|
||||
adapter_device_type: format!("{:?}", info.device_type),
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects one glyph's outline into a device-space `kurbo::BezPath`,
|
||||
/// scaling by `em_px / units_per_em` and flipping y (font space is y-up,
|
||||
/// device space is y-down) — the same rule Round 1's `build_path` used for
|
||||
/// Bravura outlines, applied here to a `ttf_parser` face outline instead of
|
||||
/// a typed `PathCommand` sequence.
|
||||
///
|
||||
/// Returns `None` for a glyph with no outline (whitespace, or — under W3-F3's
|
||||
/// invariant — a glyph id that does not exist in this face at all, which
|
||||
/// never happens here because `seg.face` is only ever `Some` when resolution
|
||||
/// found real coverage).
|
||||
fn build_glyph_bezpath(
|
||||
face: &TtfFace,
|
||||
glyph_id: u16,
|
||||
em_px: f64,
|
||||
origin: KPoint,
|
||||
) -> Option<BezPath> {
|
||||
struct Sink {
|
||||
path: BezPath,
|
||||
scale: f64,
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
open: bool,
|
||||
any: bool,
|
||||
}
|
||||
impl Sink {
|
||||
fn map(&self, x: f32, y: f32) -> KPoint {
|
||||
KPoint::new(
|
||||
self.ox + x as f64 * self.scale,
|
||||
self.oy - y as f64 * self.scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl OutlineBuilder for Sink {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
if self.open {
|
||||
self.path.close_path();
|
||||
}
|
||||
let p = self.map(x, y);
|
||||
self.path.move_to(p);
|
||||
self.open = true;
|
||||
self.any = true;
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.path.line_to(self.map(x, y));
|
||||
}
|
||||
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
|
||||
self.path.quad_to(self.map(x1, y1), self.map(x, y));
|
||||
}
|
||||
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
|
||||
self.path
|
||||
.curve_to(self.map(x1, y1), self.map(x2, y2), self.map(x, y));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.path.close_path();
|
||||
self.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
let upem = face.units_per_em() as f64;
|
||||
if upem <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut sink = Sink {
|
||||
path: BezPath::new(),
|
||||
scale: em_px / upem,
|
||||
ox: origin.x,
|
||||
oy: origin.y,
|
||||
open: false,
|
||||
any: false,
|
||||
};
|
||||
face.outline_glyph(GlyphId(glyph_id), &mut sink)?;
|
||||
if sink.open {
|
||||
sink.path.close_path();
|
||||
}
|
||||
if !sink.any {
|
||||
return None;
|
||||
}
|
||||
Some(sink.path)
|
||||
}
|
||||
|
||||
/// Builds one `vello::Scene` for `rt`, drawing exactly the resolved
|
||||
/// `(face, glyph_id, offset)` triples — **never re-shaping**. A segment whose
|
||||
/// `face` is `None` (F-C's uncovered Arabic letter) has no glyphs by
|
||||
/// construction (W3-F3 / `invariants::assert_unresolved_clusters_are_diagnostic`,
|
||||
/// asserted on every loaded fixture by `FixtureFile::validate`), so the loop
|
||||
/// below draws nothing for it and substitutes nothing — there is no
|
||||
/// "draw `.notdef`" branch to suppress because shaping was never attempted
|
||||
/// against a face that does not cover the codepoint.
|
||||
fn build_scene(rt: &SpikeResolvedText, faces: &[LoadedFace]) -> Result<Scene> {
|
||||
let mut scene = Scene::new();
|
||||
for (seg_idx, seg) in rt.segments.iter().enumerate() {
|
||||
let Some(face_idx) = seg.face else {
|
||||
// Uncovered span: `seg.glyphs` is guaranteed empty here. Nothing
|
||||
// drawn, nothing substituted.
|
||||
continue;
|
||||
};
|
||||
let loaded = faces.get(face_idx as usize).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"segment {seg_idx} resolved to face {face_idx}, but only {} faces are loaded",
|
||||
faces.len()
|
||||
)
|
||||
})?;
|
||||
let face = TtfFace::parse(&loaded.bytes, loaded.identity.face_index)
|
||||
.map_err(|e| anyhow!("face {face_idx} failed to parse: {e}"))?;
|
||||
let em_px = seg.size.0 * DEVICE_SCALE;
|
||||
for g in &seg.glyphs {
|
||||
let device = to_device(rt, &g.offset);
|
||||
let origin = KPoint::new(device.x, device.y);
|
||||
if let Some(path) = build_glyph_bezpath(&face, g.glyph_id as u16, em_px, origin) {
|
||||
// NonZero: the reference emitter (`round2-svgref`) fills with
|
||||
// fill-rule nonzero, and the recipe states this is the rule
|
||||
// to match (§3: "Fill rule nonzero, as the reference emitter
|
||||
// uses").
|
||||
scene.fill(Fill::NonZero, Affine::IDENTITY, INK, None, &path);
|
||||
}
|
||||
// `None`: a whitespace glyph with an empty outline. Not an
|
||||
// error — `round2-svgref` treats this identically (`empty`, not
|
||||
// a failure), and the fixture's own glyph/segment counts already
|
||||
// account for it.
|
||||
}
|
||||
}
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
/// Copies a rendered texture back to host memory as tightly packed RGBA,
|
||||
/// undoing wgpu's 256-byte row-stride padding. Identical in shape to Round
|
||||
/// 1's C2 `readback` (duplicated rather than shared, so `src/main.rs` stays
|
||||
/// byte-identical and untouched by this packet).
|
||||
fn readback(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let unpadded = width * 4;
|
||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("c2-round2-readback"),
|
||||
size: (padded as u64) * (height as u64),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("c2-round2-copy"),
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
|
||||
let slice = buffer.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
device.poll(wgpu::PollType::wait_indefinitely())?;
|
||||
rx.recv()
|
||||
.map_err(|e| anyhow!("readback channel closed: {e}"))?
|
||||
.map_err(|e| anyhow!("buffer map failed: {e}"))?;
|
||||
|
||||
let mapped = slice.get_mapped_range();
|
||||
let mut out = Vec::with_capacity((unpadded as usize) * (height as usize));
|
||||
for row in 0..height as usize {
|
||||
let start = row * padded as usize;
|
||||
out.extend_from_slice(&mapped[start..start + unpadded as usize]);
|
||||
}
|
||||
drop(mapped);
|
||||
buffer.unmap();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Renders `rt` offscreen at pin 4's 1920x1080, opaque white ground, opaque
|
||||
/// black ink, returning tightly packed RGBA8 — the exact shape
|
||||
/// `round2_diff::diff` and `round2-candidatekit`'s loader require.
|
||||
pub fn render_fixture(
|
||||
gpu: &mut Gpu,
|
||||
rt: &SpikeResolvedText,
|
||||
faces: &[LoadedFace],
|
||||
) -> Result<Vec<u8>> {
|
||||
let scene = build_scene(rt, faces)?;
|
||||
|
||||
let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("c2-round2-target"),
|
||||
size: wgpu::Extent3d {
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
gpu.renderer
|
||||
.render_to_texture(
|
||||
&gpu.device,
|
||||
&gpu.queue,
|
||||
&scene,
|
||||
&view,
|
||||
&RenderParams {
|
||||
base_color: GROUND,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
antialiasing_method: AA,
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow!("vello render_to_texture failed: {e}"))?;
|
||||
|
||||
readback(&gpu.device, &gpu.queue, &texture, WIDTH, HEIGHT)
|
||||
}
|
||||
|
|
@ -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,72 @@
|
|||
//! # 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, IntegrationOwnership, 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,864 @@
|
|||
//! 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`.
|
||||
/// Who *owns* the integration behind an [`AdapterStatus::Implemented`] row.
|
||||
///
|
||||
/// Added by the pin-13 schema amendment (2026-07-30), because the first
|
||||
/// pair of real reports proved that `Implemented`/`NotBuilt` alone cannot
|
||||
/// carry what Packet 2B was chartered to record. C1 reached AT-SPI through
|
||||
/// AccessKit **inherited from eframe** and reported that platform as
|
||||
/// `NotBuilt` ("no separate AccessKit-native readback was built"); C2
|
||||
/// reached AT-SPI through AccessKit **it wired by hand** and reported
|
||||
/// `Implemented`. Same underlying fact, opposite rows — and the one that
|
||||
/// said `NotBuilt` was simply false, since the AccessKit path was present
|
||||
/// and exercised.
|
||||
///
|
||||
/// Relabelling C1's row `Implemented` would have fixed the falsehood and
|
||||
/// still lost the distinction, because "inherited or candidate-owned?"
|
||||
/// would have survived only as prose in `notes` — which is exactly how the
|
||||
/// two candidates diverged in the first place. So it is typed, required on
|
||||
/// every `Implemented` row, and therefore impossible to omit.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum IntegrationOwnership {
|
||||
/// The integration came with a dependency the candidate adopted; the
|
||||
/// candidate did not write it. `provider` names what supplied it (e.g.
|
||||
/// `"eframe 0.35 (bundled AccessKit integration)"`), so a reader can
|
||||
/// check the claim against the dependency graph rather than take it.
|
||||
///
|
||||
/// Inheriting an integration is **not** inheriting the semantics drawn
|
||||
/// on top of it: a candidate that inherits a bridge still writes the
|
||||
/// accessible nodes for anything it painted itself, and that work shows
|
||||
/// up in [`ReportPart::AccessibilityTreeConstruction`], not here.
|
||||
Inherited { provider: Provider },
|
||||
/// The candidate wrote the integration itself — the bridge wiring, the
|
||||
/// event plumbing, the adapter lifecycle.
|
||||
CandidateOwned,
|
||||
}
|
||||
|
||||
/// A non-empty provider name, carrying its own invariant.
|
||||
///
|
||||
/// The previous amendment put the check in a constructor and in
|
||||
/// `Deserialize`, and left `Inherited { provider: String }` public — so the
|
||||
/// struct-literal path bypassed both, and the very first caller (C1's
|
||||
/// adapter rows) took it. A checked constructor beside a public field is a
|
||||
/// suggestion; the field is the API.
|
||||
///
|
||||
/// The inner `String` is private and every route in — `new`, `TryFrom`,
|
||||
/// `Deserialize` — runs the same check, so an empty provider cannot be
|
||||
/// constructed *or* serialized, on any path, without editing this module.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Provider(String);
|
||||
|
||||
impl Provider {
|
||||
/// The only way to build a [`Provider`]. Rejects empty and
|
||||
/// whitespace-only names.
|
||||
pub fn new(provider: impl Into<String>) -> Result<Self, String> {
|
||||
let provider = provider.into();
|
||||
if provider.trim().is_empty() {
|
||||
return Err(
|
||||
"Provider::new: provider must not be empty or whitespace-only — an inherited \
|
||||
integration whose provider is unnamed cannot be checked against the dependency \
|
||||
graph, which is the only reason this variant carries a provider at all"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Provider(provider))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Provider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Provider {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Provider::new(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntegrationOwnership {
|
||||
/// Checked constructor: rejects an empty-or-whitespace-only provider.
|
||||
///
|
||||
/// Retained as the ergonomic route, but it is no longer the *only*
|
||||
/// guard — [`Provider`] carries the invariant now, so the struct-literal
|
||||
/// path is closed too.
|
||||
pub fn inherited(provider: impl Into<String>) -> Result<Self, String> {
|
||||
Ok(IntegrationOwnership::Inherited {
|
||||
provider: Provider::new(provider)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialization shadow for [`IntegrationOwnership`], so the JSON path
|
||||
/// runs the same provider check the constructor does. Without it a
|
||||
/// hand-edited or differently-generated report could carry
|
||||
/// `{"Inherited": {"provider": ""}}` straight past a guard that only ever
|
||||
/// existed in Rust — the same hole, and the same fix, as
|
||||
/// [`crate::outcome::CheckOutcome`]'s reason strings.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum IntegrationOwnershipWire {
|
||||
Inherited { provider: Provider },
|
||||
CandidateOwned,
|
||||
}
|
||||
|
||||
impl TryFrom<IntegrationOwnershipWire> for IntegrationOwnership {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(wire: IntegrationOwnershipWire) -> Result<Self, String> {
|
||||
match wire {
|
||||
IntegrationOwnershipWire::Inherited { provider } => {
|
||||
Ok(IntegrationOwnership::Inherited { provider })
|
||||
}
|
||||
IntegrationOwnershipWire::CandidateOwned => Ok(IntegrationOwnership::CandidateOwned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IntegrationOwnership {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let wire = IntegrationOwnershipWire::deserialize(deserializer)?;
|
||||
IntegrationOwnership::try_from(wire).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AdapterStatus {
|
||||
/// The candidate built and exercised an adapter for this platform.
|
||||
///
|
||||
/// `integration_ownership` is **required**: a platform the candidate
|
||||
/// actually exposed a tree on is `Implemented` whether the integration
|
||||
/// was inherited or written, and the difference between those two is
|
||||
/// the measurement, not a footnote.
|
||||
Implemented {
|
||||
platform: String,
|
||||
notes: String,
|
||||
integration_ownership: IntegrationOwnership,
|
||||
},
|
||||
/// The candidate did not build an adapter for this platform.
|
||||
/// **Scope not covered — not a failure.**
|
||||
///
|
||||
/// Reserved **exclusively** for uncovered scope. A platform the
|
||||
/// candidate reached — by any route, inherited or its own — is
|
||||
/// `Implemented`, never this.
|
||||
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 { .. }));
|
||||
}
|
||||
|
||||
// ---- pin-13 schema amendment: integration ownership ----
|
||||
|
||||
fn implemented(platform: &str, own: IntegrationOwnership) -> AdapterStatus {
|
||||
AdapterStatus::Implemented {
|
||||
platform: platform.to_string(),
|
||||
notes: "n".to_string(),
|
||||
integration_ownership: own,
|
||||
}
|
||||
}
|
||||
|
||||
/// The amendment's whole point: two candidates that both reached a
|
||||
/// platform are both `Implemented`, and the inherited-vs-owned
|
||||
/// difference survives as *data* rather than as prose in `notes`. This
|
||||
/// is the comparison the first pair of real reports could not express —
|
||||
/// C1 reached AT-SPI through AccessKit inherited from eframe and
|
||||
/// reported `NotBuilt`; C2 reached it through AccessKit it wired itself
|
||||
/// and reported `Implemented`.
|
||||
#[test]
|
||||
fn two_candidates_on_one_platform_differ_only_in_ownership() {
|
||||
let c1 = implemented(
|
||||
"accesskit-0.24",
|
||||
IntegrationOwnership::Inherited {
|
||||
provider: Provider::new("eframe 0.35").unwrap(),
|
||||
},
|
||||
);
|
||||
let c2 = implemented("accesskit-0.24", IntegrationOwnership::CandidateOwned);
|
||||
for a in [&c1, &c2] {
|
||||
assert!(matches!(a, AdapterStatus::Implemented { .. }));
|
||||
}
|
||||
let own = |a: &AdapterStatus| match a {
|
||||
AdapterStatus::Implemented {
|
||||
integration_ownership,
|
||||
..
|
||||
} => integration_ownership.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_ne!(own(&c1), own(&c2));
|
||||
}
|
||||
|
||||
/// `Inherited` must name its provider, so the claim is checkable
|
||||
/// against the dependency graph instead of taken on trust.
|
||||
#[test]
|
||||
fn inherited_carries_its_provider_and_is_not_conflated_with_owned() {
|
||||
let inherited = IntegrationOwnership::Inherited {
|
||||
provider: Provider::new("eframe 0.35 (bundled AccessKit integration)").unwrap(),
|
||||
};
|
||||
match &inherited {
|
||||
IntegrationOwnership::Inherited { provider } => {
|
||||
assert!(provider.as_str().contains("eframe"), "{provider}")
|
||||
}
|
||||
IntegrationOwnership::CandidateOwned => panic!("wrong variant"),
|
||||
}
|
||||
assert_ne!(inherited, IntegrationOwnership::CandidateOwned);
|
||||
}
|
||||
|
||||
/// Mutation: an `Implemented` row that omits `integration_ownership`
|
||||
/// must fail to deserialize. If this ever passes, the field has become
|
||||
/// optional in practice and the amendment is decorative — a report
|
||||
/// could once again carry the distinction only in prose.
|
||||
#[test]
|
||||
fn an_implemented_row_without_integration_ownership_is_refused() {
|
||||
let bad = serde_json::json!({
|
||||
"Implemented": { "platform": "at-spi2", "notes": "n" }
|
||||
});
|
||||
let err = serde_json::from_value::<AdapterStatus>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("integration_ownership"), "{err}");
|
||||
}
|
||||
|
||||
/// `NotBuilt` is for uncovered scope only, so it takes no ownership
|
||||
/// field — a platform reached by *any* route is `Implemented`.
|
||||
#[test]
|
||||
fn not_built_takes_no_ownership_field() {
|
||||
let bad = serde_json::json!({
|
||||
"NotBuilt": {
|
||||
"platform": "windows-uia",
|
||||
"reason": "no runner",
|
||||
"integration_ownership": "CandidateOwned"
|
||||
}
|
||||
});
|
||||
let err = serde_json::from_value::<AdapterStatus>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("integration_ownership"), "{err}");
|
||||
}
|
||||
|
||||
/// Mutation: the checked constructor refuses an empty provider. If this
|
||||
/// passes, `Inherited`'s "checkable against the dependency graph" claim
|
||||
/// is decorative.
|
||||
#[test]
|
||||
fn the_inherited_constructor_rejects_an_empty_provider() {
|
||||
let err = IntegrationOwnership::inherited("").unwrap_err();
|
||||
assert!(err.contains("provider"), "{err}");
|
||||
assert!(err.contains("dependency graph"), "{err}");
|
||||
}
|
||||
|
||||
/// Whitespace-only is the same hole wearing a space — a provider of
|
||||
/// `" "` renders as `Inherited` in a table and names nothing.
|
||||
#[test]
|
||||
fn the_inherited_constructor_rejects_a_whitespace_only_provider() {
|
||||
assert!(IntegrationOwnership::inherited(" \t \n ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inherited_constructor_accepts_a_real_provider() {
|
||||
let own = IntegrationOwnership::inherited("eframe 0.35").unwrap();
|
||||
assert_eq!(
|
||||
own,
|
||||
IntegrationOwnership::Inherited {
|
||||
provider: Provider::new("eframe 0.35").unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The JSON path must run the same check — a hand-edited report is
|
||||
/// exactly where an unnamed provider would arrive from.
|
||||
#[test]
|
||||
fn deserializing_an_empty_provider_is_refused() {
|
||||
let bad = serde_json::json!({ "Inherited": { "provider": "" } });
|
||||
let err = serde_json::from_value::<IntegrationOwnership>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("provider"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializing_a_whitespace_only_provider_is_refused() {
|
||||
let bad = serde_json::json!({ "Inherited": { "provider": " " } });
|
||||
assert!(serde_json::from_value::<IntegrationOwnership>(bad).is_err());
|
||||
}
|
||||
|
||||
/// An empty provider must be refused when it arrives nested inside a
|
||||
/// whole adapter row, not only when deserialized on its own — that is
|
||||
/// the shape a real report carries it in.
|
||||
#[test]
|
||||
fn an_adapter_row_with_an_empty_provider_is_refused() {
|
||||
let bad = serde_json::json!({
|
||||
"Implemented": {
|
||||
"platform": "at-spi2",
|
||||
"notes": "n",
|
||||
"integration_ownership": { "Inherited": { "provider": "" } }
|
||||
}
|
||||
});
|
||||
let err = serde_json::from_value::<AdapterStatus>(bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("provider"), "{err}");
|
||||
}
|
||||
|
||||
/// Mutation, and the reason this amendment exists: the *struct-literal*
|
||||
/// path must be closed, not just the constructor. `Provider`'s inner
|
||||
/// field is private, so outside this module
|
||||
/// `IntegrationOwnership::Inherited { provider: "".to_string() }` does
|
||||
/// not compile at all — which is the only kind of guard a caller cannot
|
||||
/// forget to call. The previous amendment guarded the constructor and
|
||||
/// `Deserialize` and left the literal open, and the first real caller
|
||||
/// (C1's adapter rows) took exactly that path.
|
||||
///
|
||||
/// This test pins the *serialization* consequence, the half a type
|
||||
/// error cannot express: every `Provider` that exists has been checked,
|
||||
/// so no serialized row can carry an empty provider.
|
||||
#[test]
|
||||
fn no_constructible_provider_serializes_as_empty() {
|
||||
for bad in ["", " ", "\t", "\n \t"] {
|
||||
assert!(
|
||||
Provider::new(bad).is_err(),
|
||||
"Provider::new({bad:?}) must not construct"
|
||||
);
|
||||
}
|
||||
let good = Provider::new("eframe 0.35").unwrap();
|
||||
let json = serde_json::to_string(&good).unwrap();
|
||||
assert_eq!(
|
||||
json, "\"eframe 0.35\"",
|
||||
"provider must serialize transparently"
|
||||
);
|
||||
assert!(!json.trim_matches('"').trim().is_empty());
|
||||
}
|
||||
|
||||
/// The newtype must not smuggle its invariant past the wire either: a
|
||||
/// whole adapter row carrying an empty provider string is refused on
|
||||
/// the way in, and a row that round-trips keeps its provider intact.
|
||||
#[test]
|
||||
fn an_adapter_row_provider_survives_a_round_trip_unchanged() {
|
||||
let row = AdapterStatus::Implemented {
|
||||
platform: "at-spi2".to_string(),
|
||||
notes: "n".to_string(),
|
||||
integration_ownership: IntegrationOwnership::inherited("eframe 0.35").unwrap(),
|
||||
};
|
||||
let json = serde_json::to_string(&row).unwrap();
|
||||
let back: AdapterStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(serde_json::to_string(&back).unwrap(), json);
|
||||
assert!(json.contains("eframe 0.35"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializing_candidate_owned_still_works() {
|
||||
let ok = serde_json::json!("CandidateOwned");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<IntegrationOwnership>(ok).unwrap(),
|
||||
IntegrationOwnership::CandidateOwned
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_rows_round_trip_through_json_both_ways() {
|
||||
for a in [
|
||||
implemented("at-spi2", IntegrationOwnership::CandidateOwned),
|
||||
implemented(
|
||||
"accesskit-0.24",
|
||||
IntegrationOwnership::Inherited {
|
||||
provider: Provider::new("eframe 0.35").unwrap(),
|
||||
},
|
||||
),
|
||||
AdapterStatus::NotBuilt {
|
||||
platform: "windows-uia".to_string(),
|
||||
reason: "no runner".to_string(),
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
let back: AdapterStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&back).unwrap(),
|
||||
json,
|
||||
"round trip changed the row"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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