diff --git a/spikes/editor-toolkit/Cargo.lock b/spikes/editor-toolkit/Cargo.lock index 9421b1b..5220805 100644 --- a/spikes/editor-toolkit/Cargo.lock +++ b/spikes/editor-toolkit/Cargo.lock @@ -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" diff --git a/spikes/editor-toolkit/Cargo.toml b/spikes/editor-toolkit/Cargo.toml index a5a539f..a1d4ed2 100644 --- a/spikes/editor-toolkit/Cargo.toml +++ b/spikes/editor-toolkit/Cargo.toml @@ -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 diff --git a/spikes/editor-toolkit/a11y-verifier/.gitignore b/spikes/editor-toolkit/a11y-verifier/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/spikes/editor-toolkit/a11y-verifier/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/spikes/editor-toolkit/a11y-verifier/test_verify.py b/spikes/editor-toolkit/a11y-verifier/test_verify.py new file mode 100644 index 0000000..cf6d607 --- /dev/null +++ b/spikes/editor-toolkit/a11y-verifier/test_verify.py @@ -0,0 +1,1230 @@ +"""Plain `unittest` coverage for `verify.classify` and +`verify.validate_expectations_file` — check 5's whole scoring logic and its +fail-closed artifact validation, isolated from any live AT-SPI bus. + +`verify.py`'s `gi.repository.Atspi` import happens inside `main()`, not at +module import time, specifically so this file can `import verify` and drive +`classify()` directly with synthetic `ObservedNode` trees — no bus, no +desktop, no display required. Run with: + + python3 -m unittest + +from this directory (`a11y-verifier/`). + +Every test constructs the *wrong* input for the property it names and +asserts the *specific* verdict/outcome that input must produce — never a +bare "FAIL" or "not PASS", because a classifier branch that silently +degrades to the wrong FAIL reason would pass a weaker test and is exactly +the kind of regression this file exists to catch. +""" +import unittest + +from verify import ( + EXPECTED_FIXTURE_IDS, + PLATFORM, + VISUAL_ORDER_TRAP, + ObservedNode, + Verdict, + _is_source_bearing_fragment, + classify, + validate_expectations_file, +) + + +def node(role, name, children=None): + """A synthetic `ObservedNode`, the same shape `walk_for_check5` builds + from a live AT-SPI tree.""" + return ObservedNode(role=role, name=name, children=list(children) if children else []) + + +def expectation( + expected_name, + accepted_roles=("label", "static", "text", "paragraph"), + prohibited_roles=("image", "canvas", "filler", "panel", "unknown"), + alternative_forms=None, + visual_order_name=None, + source_atoms=None, +): + """A synthetic fixture entry with the same shape + `round2-a11y-oracle/a11y_expectations.json` emits.""" + return { + "fixture_id": "F-TEST", + "expected_name": expected_name, + "expected_name_hex": expected_name.encode("utf-8").hex(), + "expected_name_byte_len": len(expected_name.encode("utf-8")), + "accepted_roles": list(accepted_roles), + "prohibited_roles": list(prohibited_roles), + "alternative_forms": alternative_forms or {}, + "visual_order_name": visual_order_name, + # D1: per-segment source atoms (`round2-a11y-oracle`'s + # `source_atoms`). Defaults to `None` (classify's own `.get(...) or + # []` treats that as no atoms), since most tests don't need one. + "source_atoms": source_atoms, + } + + +class ByteExactPass(unittest.TestCase): + def test_single_node_with_accepted_role_and_exact_name_passes(self): + exp = expectation("Allegro affettuoso — al fine") + verdict = classify(exp, [node("text", "Allegro affettuoso — al fine")]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual(verdict.observed_role, "text") + self.assertEqual(verdict.observed_name, exp["expected_name"]) + + def test_a_one_byte_difference_does_not_pass(self): + """Mutation guard: if byte comparison were replaced by e.g. a + case-insensitive or trimmed comparison, this would wrongly PASS.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("text", "allegro")]) + self.assertNotEqual(verdict.verdict, "PASS") + + +class CompositionConcatenationPass(unittest.TestCase): + def test_two_segment_names_concatenated_in_tree_order_pass(self): + exp = expectation("Coro אבג") + # No single node carries the whole name — only the concatenation of + # two text descendants of a common parent, in tree/logical order, + # does. + run = node("frame", "", children=[node("text", "Coro "), node("text", "אבג")]) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual(verdict.observed_name, exp["expected_name"]) + + def test_concatenation_in_the_wrong_order_does_not_pass(self): + """Mutation guard: if concatenation order were unspecified (e.g. a + set instead of an ordered list), swapping the two nodes would still + wrongly PASS.""" + exp = expectation("Coro אבג") + run = node("frame", "", children=[node("text", "אבג"), node("text", "Coro ")]) + verdict = classify(exp, [run]) + self.assertNotEqual(verdict.verdict, "PASS") + + +class ProhibitedOutcomes(unittest.TestCase): + """One test per `round2_textkit::a11y::PROHIBITED_OUTCOMES` name.""" + + def test_absent_from_tree_when_no_candidate_node_is_found(self): + exp = expectation("Allegro") + verdict = classify(exp, []) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_name_empty_is_distinct_from_absent_from_tree(self): + """§8.3: "name-empty ... absence wearing a role." A node is present + (unlike the absent-from-tree case above) but its name is the empty + string — these must classify differently.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("label", "")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-empty") + + def test_name_normalized_matches_the_precommitted_nfc_form(self): + # F-E's case: source is NFD ("Cafe" + combining acute, "Café"), + # a wrong tree exposes the NFC "Café" ("Café", a *different* + # string byte-for-byte even though the two render identically) + # instead. Written with explicit \N escapes rather than the literal + # glyph so the two forms cannot be silently typed as the same string + # by accident — that mistake produced a false PASS here once already. + nfd = "Cafe\N{COMBINING ACUTE ACCENT}" + nfc = "Caf\N{LATIN SMALL LETTER E WITH ACUTE}" + self.assertNotEqual(nfd, nfc, "anchor: the two forms must be different strings") + exp = expectation( + nfd, + alternative_forms={"name-normalized": [nfc]}, + ) + verdict = classify(exp, [node("text", nfc)]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-normalized") + self.assertEqual(verdict.observed_name, nfc) + + def test_name_is_shaped_glyphs_matches_the_precommitted_ligature_form(self): + # F-A's case: the `ff` ligature collapses, so a wrong tree drops a + # letter relative to the source string. + exp = expectation( + "Allegro affettuoso — al fine", + alternative_forms={"name-is-shaped-glyphs": ["Allegro afettuoso — al fne"]}, + ) + verdict = classify(exp, [node("text", "Allegro afettuoso — al fne")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + def test_name_drops_unresolved_codepoints_matches_the_precommitted_form(self): + # F-C's case: U+0627 is covered by no declared face and is dropped. + exp = expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + ) + verdict = classify(exp, [node("static", "Coro ")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-drops-unresolved-codepoints") + + def test_a_correct_name_does_not_spuriously_match_an_alternative_form(self): + """Mutation guard: if alternative-form matching ran before the exact + match, a correct name would risk matching an alternative_forms entry + by accident and wrongly FAIL.""" + exp = expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + ) + verdict = classify(exp, [node("static", "Coro ا")]) + self.assertEqual(verdict.verdict, "PASS") + + +class VisualOrderDiagnosis(unittest.TestCase): + def test_visual_order_concatenation_is_named_specifically(self): + # F-D's designed trap: a tree walking visual runs left to right + # reverses the embedded RTL segment's codepoint order. + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + run = node( + "frame", + "", + children=[node("text", "Allegro "), node("text", "גבא"), node("text", " con brio")], + ) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, VISUAL_ORDER_TRAP) + self.assertEqual(verdict.observed_name, exp["visual_order_name"]) + + def test_visual_order_diagnosis_is_not_reported_as_a_generic_mismatch(self): + """Mutation guard: if the visual-order check were deleted, this + would still FAIL but with `prohibited_outcome=None` (the generic + fallback) instead of the specific diagnosis — asserting the exact + name, not just FAIL, is what catches that.""" + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + run = node( + "frame", + "", + children=[node("text", "Allegro "), node("text", "גבא"), node("text", " con brio")], + ) + verdict = classify(exp, [run]) + self.assertIsNotNone(verdict.prohibited_outcome) + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertNotEqual(verdict.prohibited_outcome, "name-empty") + + +class ProhibitedRole(unittest.TestCase): + def test_a_prohibited_role_fails_even_with_the_exact_name(self): + exp = expectation("Allegro") + verdict = classify(exp, [node("canvas", "Allegro")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + # This is a role-vocabulary failure (§8.2), not one of the five + # name-transformation PROHIBITED_OUTCOMES (§8.3) — it must not be + # reported as one. + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual( + verdict.reason, + "a node's name matches expected_name byte-for-byte, but its role 'canvas' is in the " + "at-spi2 prohibited set (no accepted-role node also carries it)", + ) + + def test_an_accepted_role_with_the_exact_name_is_not_penalized(self): + """Mutation guard: confirms the previous test is actually exercising + the role check, not some other reason that name would fail.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("label", "Allegro")]) + self.assertEqual(verdict.verdict, "PASS") + + def test_an_unlisted_role_carrying_the_exact_name_is_not_absent_from_tree(self): + """A role outside `accepted_roles | prohibited_roles` (e.g. `push + button`) is never a text-*candidate* for the composition/single-node + role checks — `_flatten_candidates`/`_subtree_contributors` exclude + it. But a node under that role can still carry the run's exact + text, and §8.3's absent-from-tree ("the default outcome for a + toolkit that draws to a canvas and stops") does not describe that: + the run *is* in the tree. This must FAIL naming the actual observed + role, not report absent-from-tree — conflating "wrong role" with + "nothing there at all" would hide evidence a real candidate + produced.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("push button", "Allegro")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + self.assertEqual(verdict.observed_name, "Allegro") + + def test_a_truly_empty_tree_is_still_absent_from_tree(self): + """The companion case to the one above, pinned side by side so a + regression that merges the two back together (e.g. by making the + new unlisted-role scan fire unconditionally) is caught: with + nothing in the tree at all, the outcome must still be + `absent-from-tree`.""" + exp = expectation("Allegro") + verdict = classify(exp, []) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_an_unlisted_role_carrying_an_alternative_form_is_also_not_absent_from_tree(self): + """The whole-tree scan must check precommitted alternative forms + too, not only `expected_name` — a candidate that shaped the name + wrong *and* exposed it under an unlisted role has still put the + (wrong) text in the tree, which is a different, more specific, + finding than "nothing is there.""" + exp = expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + ) + verdict = classify(exp, [node("push button", "Coro ")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + self.assertEqual(verdict.observed_name, "Coro ") + + def test_an_unlisted_role_carrying_the_visual_order_form_is_also_not_absent_from_tree(self): + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + verdict = classify(exp, [node("push button", "Allegro גבא con brio")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + + def test_an_unlisted_role_carrying_an_unrelated_name_is_still_absent_from_tree(self): + """Mutation guard: the whole-tree scan must only match a name + against `expected_name`/alternative forms/`visual_order_name` — a + node with an unlisted role and completely unrelated text must not + rescue the verdict away from absent-from-tree either.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("push button", "something unrelated")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class MultipleFormsPerOutcome(unittest.TestCase): + """O2: one outcome can carry more than one precommitted rendering — + `round2-a11y-oracle` gives `name-is-shaped-glyphs` both a + cluster-collapse form and a ligature presentation-form substitution for + F-A. Either one observed must classify the same outcome.""" + + def _f_a_like_expectation(self): + return expectation( + "Allegro affettuoso — al fine", + alternative_forms={ + "name-is-shaped-glyphs": [ + "Allegro afettuoso — al fne", + "Allegro a\N{LATIN SMALL LIGATURE FF}ettuoso — al \N{LATIN SMALL LIGATURE FI}ne", + ] + }, + ) + + def test_the_cluster_collapse_form_classifies(self): + verdict = classify( + self._f_a_like_expectation(), [node("text", "Allegro afettuoso — al fne")] + ) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + def test_the_presentation_form_classifies_the_same_outcome(self): + presentation = ( + "Allegro a\N{LATIN SMALL LIGATURE FF}ettuoso — al \N{LATIN SMALL LIGATURE FI}ne" + ) + verdict = classify(self._f_a_like_expectation(), [node("text", presentation)]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + def test_a_third_string_matches_neither_form(self): + """Mutation guard: confirms the two tests above are matching a + specific listed string, not merely "anything different from + expected_name" — if list matching degenerated to that, this would + wrongly report name-is-shaped-glyphs too.""" + verdict = classify( + self._f_a_like_expectation(), + [node("text", "something else entirely")], + ) + self.assertNotEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + +class SubtreeScopedComposition(unittest.TestCase): + """B1: composition is scored against one run subtree's own descendants, + never the whole application flattened into one list. These are the two + cases the coordinator reproduced against the pre-B1 flat classifier: + + - `[("canvas", "Coro "), ("text", "אבג")]` wrongly PASSed. + - `[("text", "Coro "), ("text", "אבג"), ("label", "MyApp Window")]` + wrongly FAILed, even though `label` is an accepted at-spi2 role that + every real application's window frame carries, elsewhere in the tree. + """ + + def _f_b_like_expectation(self): + return expectation("Coro אבג") + + def test_a_prohibited_role_sibling_in_the_same_run_subtree_fails_with_the_role_named(self): + """The false-PASS case (B1's first reproduction), expressed as a + real tree: `canvas` and `text` are siblings under one run subtree — + together they still spell out expected_name byte-for-byte, but a + `canvas` contributed to it, which must FAIL, naming `canvas`, not + PASS.""" + run = node("frame", "", children=[node("canvas", "Coro "), node("text", "אבג")]) + verdict = classify(self._f_b_like_expectation(), [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + self.assertEqual(verdict.observed_name, "Coro אבג") + self.assertIn("canvas", verdict.reason) + self.assertIn("otherwise-correct composition", verdict.reason) + # Not one of the five PROHIBITED_OUTCOMES names — this is a §8.2 + # role-vocabulary failure inside a composition, not a §8.3 name + # transformation. + self.assertIsNone(verdict.prohibited_outcome) + + def test_an_unrelated_accepted_role_node_elsewhere_does_not_poison_a_correct_composition(self): + """The false-FAIL case (B1's second reproduction), expressed as a + real tree: the run's own two text nodes are correctly grouped under + their own subtree; an unrelated `label` (an *accepted* at-spi2 role + — every real window frame carries one) sits elsewhere in the same + application. The label must not be able to corrupt the run's own, + otherwise-correct, composition into a FAIL.""" + application = node( + "frame", + "", + children=[ + node("group", "", children=[node("text", "Coro "), node("text", "אבג")]), + node("label", "MyApp Window"), + ], + ) + verdict = classify(self._f_b_like_expectation(), [application]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual(verdict.observed_name, "Coro אבג") + + def test_the_legitimate_accepted_role_split_still_passes_when_it_is_the_whole_tree(self): + """Sanity companion to the two reproductions above: a run correctly + split across two accepted-role text nodes, with nothing else in the + tree at all, must still PASS — B1's fix must not have become so + conservative that it stopped recognizing the ordinary case.""" + run = node("paragraph", "", children=[node("text", "Coro "), node("text", "אבג")]) + verdict = classify(self._f_b_like_expectation(), [run]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + + def test_a_prohibited_contributor_is_named_even_when_a_correct_subtree_exists_elsewhere(self): + """The composition scan must not let a PASS found in one subtree + erase evidence of a bad contributor found in *another* — but it must + still prefer reporting the PASS overall, since a candidate that gets + it right anywhere in a legitimate run subtree has satisfied §8.1. + This test pins the reverse: when NO subtree passes cleanly, the + reported reason must name the actual bad contributor, not a generic + mismatch.""" + run = node( + "frame", + "", + children=[node("canvas", "Coro "), node("text", "אבג")], + ) + verdict = classify(self._f_b_like_expectation(), [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + self.assertNotIn("no node or composition matched", verdict.reason) + + def test_a_composition_that_silently_dropped_a_prohibited_contributor_would_be_wrong(self): + """Guards the specific failure mode B1 warns against: a subtree must + not PASS by concatenating only its *accepted*-role contributors and + ignoring a prohibited one. Here, dropping the `canvas` node would + leave just `"אבג"`, which does not equal expected_name either — so + this also confirms the concatenation includes every contributor's + name, not a filtered subset, before the role check ever runs.""" + run = node("frame", "", children=[node("canvas", "Coro "), node("text", "אבג")]) + verdict = classify(self._f_b_like_expectation(), [run]) + # If contributors had been filtered to accepted-only before + # concatenating, the concat would be "אבג" (not expected_name), and + # this subtree would be silently skipped rather than FAILed with a + # named reason — falling through to a *weaker* diagnosis than the + # sharp one B1 requires. + self.assertEqual(verdict.verdict, "FAIL") + self.assertIn("canvas", verdict.reason) + + +class ContainerNamingDoesNotChangeBlame(unittest.TestCase): + """An empty-named structural wrapper (a `frame` around the real + contributors, exposing no name of its own) must never be blamed for a + bad composition. It contributes zero bytes to the concatenation, so it + cannot be what made the composition wrong; blaming it hides the actual + offender — here, a *prohibited*-role `canvas` that carried half the + run, which is the real §8.2 violation the report exists to name. + + Same tree content in all three shapes below; only the container's own + name (or its absence) differs. All three must name `canvas`.""" + + def _f_b_like_expectation(self): + return expectation("Coro אבג") + + def test_canvas_and_text_inside_an_unnamed_frame_blames_canvas(self): + tree = node( + "application", + "p", + children=[node("frame", "", children=[node("canvas", "Coro "), node("text", "אבג")])], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + def test_canvas_and_text_inside_a_named_frame_blames_canvas(self): + tree = node( + "application", + "p", + children=[ + node("frame", "MyApp", children=[node("canvas", "Coro "), node("text", "אבג")]) + ], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + def test_canvas_and_text_as_direct_siblings_blames_canvas(self): + tree = node( + "application", + "p", + children=[node("canvas", "Coro "), node("text", "אבג")], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + def test_an_unlisted_but_not_prohibited_contributor_is_still_named_when_no_prohibited_one_exists( + self, + ): + """The weaker fallback branch stays reachable: with no + prohibited-role contributor at all, an unlisted-role one is still + named (not silently dropped just because it is the weaker case). + Nested under an unnamed wrapper, so this test also isolates the + empty-name exclusion on its own: with no prohibited contributor + present, the "prefer prohibited" rule cannot be what saves this + case from blaming the wrapper — only excluding the empty-named + `frame` from the contributor set can. A mutation that deleted the + empty-name exclusion (but kept the prohibited-preference) would + wrongly blame `frame` here, even though the same mutation happens + to survive the two `blames_canvas` tests above (where a prohibited + `canvas` is also present and the preference rule alone rescues + them).""" + tree = node( + "application", + "p", + children=[ + node( + "frame", "", children=[node("push button", "Coro "), node("text", "אבג")] + ) + ], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "push button") + self.assertIn("neither accepted nor prohibited", verdict.reason) + + def test_a_prohibited_contributor_is_preferred_over_an_unlisted_one_listed_first(self): + """Isolates the "prefer prohibited" rule specifically, with no + empty-named node anywhere in the tree: an unlisted-role + (`push button`) contributor is listed *before* a prohibited-role + (`canvas`) one, both non-empty-named. Naming "the first non-accepted + contributor" (no preference) would wrongly blame `push button`; only + the explicit prohibited-preference blames `canvas`.""" + tree = node( + "frame", + "", + children=[node("push button", "Coro "), node("canvas", "אבג")], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + self.assertIn("prohibited", verdict.reason) + + def test_a_correct_split_run_nested_under_an_unnamed_container_still_passes(self): + """Confirms excluding empty-named nodes does not disturb a + legitimate PASS: the concatenation is unchanged whether the + empty-named wrapper is included or excluded (it contributes zero + bytes either way), so a correct split run nested under one must + still pass.""" + tree = node( + "application", + "p", + children=[node("frame", "", children=[node("text", "Coro "), node("text", "אבג")])], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "PASS") + + +class ExactNamePrecedence(unittest.TestCase): + """C1: exact-name matching must evaluate every node before deciding, + never return on the first match — an accepted-role node carrying + expected_name wins regardless of where in the tree it sits, even when a + prohibited-role node carrying the *same* exact name is listed first.""" + + @staticmethod + def _forest(canvas_first): + canvas = node("canvas", "Coro אבג") + text = node("text", "Coro אבג") + return [canvas, text] if canvas_first else [text, canvas] + + def test_prohibited_role_node_listed_first_still_passes(self): + exp = expectation("Coro אבג") + verdict = classify(exp, self._forest(canvas_first=True)) + self.assertEqual(verdict.verdict, "PASS") + self.assertEqual(verdict.observed_role, "text") + self.assertIsNone(verdict.prohibited_outcome) + + def test_accepted_role_node_listed_first_still_passes(self): + exp = expectation("Coro אבג") + verdict = classify(exp, self._forest(canvas_first=False)) + self.assertEqual(verdict.verdict, "PASS") + self.assertEqual(verdict.observed_role, "text") + self.assertIsNone(verdict.prohibited_outcome) + + def test_both_orderings_of_the_forest_produce_the_same_verdict(self): + """The direct C1 reproduction: the coordinator measured opposite + verdicts for the two orderings of this exact forest. Pinned here as + one assertion comparing both `classify` calls, not two independently + hand-written expectations that could each be individually wrong in + the same direction.""" + exp = expectation("Coro אבג") + v_canvas_first = classify(exp, self._forest(canvas_first=True)) + v_text_first = classify(exp, self._forest(canvas_first=False)) + self.assertEqual(v_canvas_first.verdict, v_text_first.verdict) + self.assertEqual(v_canvas_first.observed_role, v_text_first.observed_role) + self.assertEqual(v_canvas_first.prohibited_outcome, v_text_first.prohibited_outcome) + self.assertEqual(v_canvas_first.verdict, "PASS") + + def test_without_any_accepted_role_match_the_prohibited_one_still_fails(self): + """Mutation guard: confirms the PASSes above happen *because* an + accepted-role match exists, not because exact-name matching became + unconditional PASS — with only the prohibited-role node present, + this must still FAIL.""" + exp = expectation("Coro אבג") + verdict = classify(exp, [node("canvas", "Coro אבג")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + +class SubtreeScopedDiagnoses(unittest.TestCase): + """C2: the alternative-form and visual-order diagnoses are scored per + subtree, exactly like composition (B1) — an unrelated accepted-role node + elsewhere in the application (e.g. a window `label`) must not poison a + legitimate subtree's diagnosis into a generic mismatch.""" + + def test_visual_order_trap_is_found_despite_an_unrelated_window_label(self): + """The direct C2 reproduction: F-D's designed visual-order trap, + with an unrelated `label` elsewhere in the application.""" + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + application = node( + "application", + "p", + children=[ + node("label", "MyApp Window"), + node( + "frame", + "", + children=[ + node("text", "Allegro "), + node("text", "גבא"), + node("text", " con brio"), + ], + ), + ], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, VISUAL_ORDER_TRAP) + self.assertNotIn("no node or composition matched", verdict.reason) + + def test_alternative_form_composition_is_found_despite_an_unrelated_window_label(self): + """The same poisoning bug, for an alternative-form composition (not + visual-order) — the ligature-collapse form split across two text + nodes, so this exercises the *subtree-concatenation* alt-form path + specifically, not the already-order-independent single-node one.""" + exp = expectation( + "Allegro affettuoso — al fine", + alternative_forms={"name-is-shaped-glyphs": ["Allegro afettuoso — al fne"]}, + ) + application = node( + "application", + "p", + children=[ + node("label", "MyApp Window"), + node( + "frame", + "", + children=[ + node("text", "Allegro afettuoso — al "), + node("text", "fne"), + ], + ), + ], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + self.assertNotIn("no node or composition matched", verdict.reason) + + def test_without_the_unrelated_label_the_same_composition_still_matches(self): + """Mutation guard: confirms the two tests above are really about the + label not poisoning the result, not about some other property of + the tree shape — remove the label and the same diagnosis must still + fire.""" + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + frame = node( + "frame", + "", + children=[node("text", "Allegro "), node("text", "גבא"), node("text", " con brio")], + ) + verdict = classify(exp, [frame]) + self.assertEqual(verdict.prohibited_outcome, VISUAL_ORDER_TRAP) + + +class UnlistedRoleComposition(unittest.TestCase): + """C3: text composed across two or more unlisted-role descendants must + not be misreported as absent-from-tree — the same per-subtree + composition scoring applied to roles in neither `accepted_roles` nor + `prohibited_roles`.""" + + def test_two_unlisted_role_nodes_composing_the_exact_name_is_not_absent(self): + """The direct C3 reproduction: two `push button` nodes whose + concatenation is the run's exact text.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "Coro "), node("push button", "אבג")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_name, "Coro אבג") + + def test_unlisted_role_composition_with_unrelated_text_is_still_absent(self): + """The composition-scan analogue of the single-node absent-from-tree + guard: two unlisted-role nodes whose concatenation is *not* the + run's text, an alternative form, or visual_order_name, must still + classify as genuine absence — the scan must not over-fire just + because *some* unlisted-role composition exists.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "something"), node("push button", "unrelated")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class AbsentFromTreeVsMisorderedFragments(unittest.TestCase): + """User ruling, following C3: a permutation probe found that reversing + the same two contributors gave `absent-from-tree` under unlisted roles + but a plain generic mismatch under accepted roles — two different named + outcomes for the same underlying defect (contributors in the wrong + order). Three cases, side by side, each asserting its own distinct + outcome, so a future change cannot silently re-merge them. + + **Behaviour change, reported explicitly (not adjusted quietly), from the + reorder that moved source-bearing detection to run before absence/ + name-empty (the very next ruling in this same sequence):** case 1 below + used to assert "unchanged, a generic composition FAIL" — that was true + only because the fragment scan, at the time, ran solely inside the + `flat_candidates`-empty branch and so never even looked at accepted-role + contributors. Once source-bearing detection (fragments included) was + unified to run over *every* role unconditionally, the identical + reversed-`text` case is now *also* caught by the fragment scan, with a + more specific message than the old generic mismatch — which is the + intended, uniform consequence of "misordered source fragments -> + composition/role failure, never absence" applying without a role + exception. Nothing about *this* file's tests silently changed; the + updated assertion below is that report. + + 1. reversed **accepted**-role contributors — a role/composition FAIL + (fragment-scan diagnosis, naming the fragments), never + `absent-from-tree`; + 2. reversed **unlisted**-role contributors — the original fix: a + role/composition FAIL, never `absent-from-tree`; + 3. the true-absence control — nothing resembling the run's text + anywhere — still reaches `absent-from-tree`, proving the fix + narrowed the bug without making the outcome unreachable. + """ + + def test_reversed_accepted_role_contributors_are_a_composition_failure_not_absence(self): + """Behaviour change (see class docstring): this used to assert a + generic mismatch (`prohibited_outcome=None`, "no node or + composition matched..."). It now asserts the fragment-scan + diagnosis — still `prohibited_outcome=None` (a §8.2 role/composition + failure, not a §8.3 PROHIBITED_OUTCOMES name), but a more specific + reason, because the fragment scan is no longer gated to unlisted + roles only.""" + exp = expectation("Coro אבג") + run = node("frame", "", children=[node("text", "אבג"), node("text", "Coro ")]) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertIsNone(verdict.prohibited_outcome) + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertIn("role/composition failure", verdict.reason) + + def test_reversed_unlisted_role_contributors_are_a_role_failure_not_absence(self): + """Item 2, the fix itself — the coordinator's exact reproduction: + the identical shape, under unlisted roles, must NOT be + `absent-from-tree`. The text is genuinely present; only the order + (and the role) is wrong.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "אבג"), node("push button", "Coro ")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + # Not a §8.3 PROHIBITED_OUTCOMES name either — this is the §8.2 + # role/composition failure category, the same as C3's other cases. + self.assertIsNone(verdict.prohibited_outcome) + + def test_true_absence_is_still_reachable(self): + """Item 1, the control: with nothing resembling the run's text + anywhere, `absent-from-tree` must still fire — proving the fix + narrowed the bug rather than making the outcome unreachable.""" + exp = expectation("Coro אבג") + verdict = classify(exp, []) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class SourceBearingFragmentGuards(unittest.TestCase): + """Prove `_is_source_bearing_fragment` (C3's fix) is not loose enough to + make `absent-from-tree` unreachable in practice — the user's own stated + concern: "a rule loose enough that an application's ordinary window + label counts as a fragment would make absent-from-tree unreachable in + practice, which is a worse failure than the one being fixed." Each + guard below is the specific scenario that concern describes.""" + + def test_a_single_shared_character_is_not_a_fragment(self): + """A one-character coincidence — "o" appears in both "Coro" and + almost any ordinary English text — must not count; this is exactly + the case the length-2 floor exists to exclude.""" + self.assertFalse(_is_source_bearing_fragment("o", {"Coro אבג"})) + + def test_an_ordinary_window_label_is_not_a_fragment(self): + """The user's own example, direct: a whole, realistic window title + is longer than (and unrelated to) the run's short text, so it can + never be a literal substring of it.""" + self.assertFalse(_is_source_bearing_fragment("MyApp Window", {"Coro אבג"})) + + def test_a_whitespace_only_name_is_not_a_fragment(self): + self.assertFalse(_is_source_bearing_fragment(" ", {"Coro אבג"})) + + def test_an_empty_name_is_not_a_fragment(self): + self.assertFalse(_is_source_bearing_fragment("", {"Coro אבג"})) + + def test_a_two_character_real_fragment_does_count(self): + """Anchors the floor at exactly two characters, not three or more — + confirms the guards above are testing the length-1 boundary + specifically, not merely "short strings never match".""" + self.assertTrue(_is_source_bearing_fragment("בג", {"Coro אבג"})) + + def test_an_ordinary_window_label_does_not_rescue_a_tree_from_absence_end_to_end(self): + """The end-to-end version of the guard above: a real, unrelated, + realistic window label under an unlisted role, with nothing else in + the tree, must still classify as absent-from-tree.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + # An unlisted role, not "label" (which is an accepted at-spi2 + # role and would exit the C3 branch this test is about via the + # ordinary accepted-role path instead). + children=[node("push button", "MyApp Window")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class FCTwoSegmentComposition(unittest.TestCase): + """D1/D2 regression group, required together — all four reproduce + against F-C's real shape (`"Coro "` + `"ا"`, with `"Coro "` also being + F-C's own precommitted `name-drops-unresolved-codepoints` form) and must + all hold simultaneously: + + unlisted "ا" for F-C -> NOT absent-from-tree + unrelated single ASCII "o" -> still NOT source-bearing + F-C accepted split ["Coro ", "ا"] -> PASS + F-C lone accepted node named exactly "Coro " -> still name-drops-unresolved-codepoints + + The first two prove D1 (an unresolved segment can be a single character, + which the length-2 substring rule alone cannot catch, but the + coincidence guard must still hold); the last two prove D2 (a legitimate + two-node split now PASSes despite the first segment alone matching a + precommitted alternative form, and the single-node case — which has no + composition to find — still names that outcome exactly as before). + """ + + def _f_c_like_expectation(self): + return expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + source_atoms=["Coro ", "ا"], + ) + + def test_unlisted_role_carrying_f_cs_unresolved_segment_is_not_absent_from_tree(self): + """D1, the reported finding: F-C's unresolved segment `ا` is a + single character — below `_is_source_bearing_fragment`'s length-2 + floor — but it is still a precommitted `source_atoms` entry, so a + node carrying it under an unlisted role must be a role/composition + failure, never absence. §8.3: "the accessibility tree carries the + text, not the ink".""" + exp = self._f_c_like_expectation() + verdict = classify(exp, [node("push button", "ا")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + + def test_an_unrelated_single_ascii_character_is_still_not_source_bearing(self): + """Mutation guard, D1: confirms the atom-matching path is additive + and precommitted, not a blanket "any single character counts" rule + — an unrelated stray `"o"` (present in `"Coro"` only by coincidence, + and not a `source_atoms` entry) must still not rescue the tree from + absence, exactly as `_is_source_bearing_fragment`'s own coincidence + guard already requires on its own.""" + exp = self._f_c_like_expectation() + verdict = classify(exp, [node("push button", "o")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_the_legitimate_two_node_split_passes(self): + """D2, the fix itself — the coordinator's exact reproduction: two + ACCEPTED-role text nodes, one per direction run (exactly what §8.1 + permits: "a tree that exposes one text node per direction run is + not wrong"), must PASS even though the first segment alone happens + to equal F-C's own precommitted `name-drops-unresolved-codepoints` + form.""" + exp = self._f_c_like_expectation() + run = node("frame", "", children=[node("text", "Coro "), node("text", "ا")]) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + + def test_a_lone_accepted_node_named_exactly_coro_is_still_the_named_outcome(self): + """The required guard proving D2's fix did not simply disable the + alternative-form diagnosis: with no second node, there is no + composition to find, so a single `text:"Coro "` node must still be + classified by name, exactly as before D2.""" + exp = self._f_c_like_expectation() + verdict = classify(exp, [node("text", "Coro ")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-drops-unresolved-codepoints") + + +class AbsentFromTreeVsNameEmpty(unittest.TestCase): + """User ruling: `absent-from-tree` must be gated on *no source-bearing + text existing anywhere*, not on *no accepted-or-prohibited-role + candidate existing* — the latter gate is nearly always false for a real + application (a window `label` alone defeats it), which made + `absent-from-tree` — §8.3's own words, "the one this check will most + likely actually catch" — effectively unreachable in practice. + + The pinned precedence: + + 1. Source-bearing text or a precommitted form present anywhere -> + classify its name/role/composition outcome. This runs first, over + the whole forest, any role — before any absence/empty-name + determination. + 2. Otherwise, `name-empty` only when both hold: at least one + **accepted**-role candidate node exists, and every + accepted-or-prohibited-role candidate's name is empty. + 3. Every other no-source-bearing case -> `absent-from-tree`. + + The intended taxonomy, and the required regression lock: four cases, + side by side, so a future change cannot re-merge them. + + unrelated UI text only -> absent-from-tree + empty prohibited canvas only -> absent-from-tree + empty accepted text/label node -> name-empty + misordered source fragments -> composition/role failure, never absence + + The distinction being preserved: `name-empty` means an attempted + static-text exposure without a name; `absent-from-tree` covers + drawing-only or unrelated trees. A prohibited-role empty node is not + "wearing a role" in §8.3's sense — it is the draw-and-stop case. + """ + + def test_unrelated_ui_text_only_is_absent_from_tree(self): + """The coordinator's exact reproduction: ordinary application + chrome — a button, a window label — none of it related to the run. + Every real application has role-listed nodes like this, which is + exactly why the old "no candidate exists anywhere" gate made + `absent-from-tree` nearly unreachable.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "Save"), node("label", "MyApp Window")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_empty_prohibited_canvas_only_is_absent_from_tree(self): + """The case that matters most (§8.3's own words): a toolkit that + drew to a canvas and stopped. A `canvas` node exposing no name is + not "wearing a role" in §8.3's name-empty sense — with no + accepted-role candidate anywhere in the tree, this is the + draw-and-stop case, `absent-from-tree`, not `name-empty`.""" + exp = expectation("Coro אבג") + application = node("frame", "MyApp", children=[node("canvas", "")]) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_empty_accepted_text_node_is_name_empty(self): + """The companion case: an *accepted*-role node attempting to expose + static text, but with no name — this is the genuine name-empty + case, "absence wearing a role".""" + exp = expectation("Coro אבג") + application = node("frame", "MyApp", children=[node("label", "")]) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-empty") + + def test_misordered_source_fragments_are_a_composition_failure_not_absence_or_empty(self): + """The fourth leg: fragments of the run's actual text, present but + in the wrong order, must never be classified as absence or + name-empty — a composition/role failure, per item 1's precedence + over items 2 and 3.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "אבג"), node("push button", "Coro ")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertNotEqual(verdict.prohibited_outcome, "name-empty") + + def test_an_accepted_node_with_real_text_alongside_an_empty_one_is_not_name_empty(self): + """Mutation guard: `name-empty` requires *every* candidate to be + empty, not merely *an* accepted-role candidate existing — an + accepted node with real (if unrelated) text sitting alongside an + empty one must not trigger name-empty, since not every + text-candidate node is actually empty.""" + exp = expectation("Coro אבג") + application = node( + "frame", + "MyApp", + children=[node("label", "Random"), node("label", "")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "name-empty") + + +class ExpectationsFileValidation(unittest.TestCase): + """O1/B2: the loader must fail closed on a malformed or stale oracle + before any live AT-SPI readback — never a FAIL, always a usage error + that the caller (`run_check5`) turns into exit 2.""" + + VALID_DIGEST = "deadbeef" * 8 # a plausible-looking 64-hex-char sha256 + + @staticmethod + def _fixture(fixture_id, name, alternative_forms=None, source_atoms=None): + return { + "fixture_id": fixture_id, + "expected_name": name, + "expected_name_hex": name.encode("utf-8").hex(), + "expected_name_byte_len": len(name.encode("utf-8")), + "accepted_roles": ["label", "static", "text", "paragraph"], + "prohibited_roles": ["image", "canvas", "filler", "panel", "unknown"], + # Defaults to one atom equal to the whole name (trivially + # satisfies the join-equals-name invariant) — tests of other + # fields don't need more than one segment. + "source_atoms": source_atoms if source_atoms is not None else [name], + "alternative_forms": alternative_forms or {}, + } + + def _valid_file(self): + """A fully self-consistent, five-fixture file — every B2/O1/D1 check + passes against this by construction. Each test below mutates + exactly one thing away from it, so a raised error is attributable to + the one defect under test rather than an incidental other one.""" + return { + "contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13", + "recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8", + "platform": PLATFORM, + "source_fixtures_digest": self.VALID_DIGEST, + "fixtures": [ + self._fixture("F-A", "Allegro affettuoso — al fine"), + self._fixture("F-B", "Coro אבג", source_atoms=["Coro ", "אבג"]), + self._fixture("F-C", "Coro ا", source_atoms=["Coro ", "ا"]), + self._fixture( + "F-D", "Allegro אבג con brio", source_atoms=["Allegro ", "אבג", " con brio"] + ), + self._fixture("F-E", "Café"), + ], + } + + def _validate(self, file): + validate_expectations_file( + file, expected_platform=PLATFORM, expected_source_digest=self.VALID_DIGEST + ) + + # ---- baseline ---- + + def test_a_fully_valid_file_is_accepted(self): + self._validate(self._valid_file()) # must not raise + + # ---- platform ---- + + def test_a_wrong_platform_is_refused(self): + bad = self._valid_file() + bad["platform"] = "aria" + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("aria", msg) + self.assertIn(PLATFORM, msg) + + # ---- source_fixtures_digest (B2) ---- + + def test_a_stale_source_digest_is_refused(self): + bad = self._valid_file() + bad["source_fixtures_digest"] = "stale" + "0" * 60 + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("stale", msg) + self.assertIn(self.VALID_DIGEST, msg) + + # ---- fixture id completeness/uniqueness ---- + + def test_a_duplicate_fixture_id_is_refused(self): + bad = self._valid_file() + bad["fixtures"].append(self._fixture("F-A", "duplicate")) + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("duplicate", str(ctx.exception).lower()) + self.assertIn("F-A", str(ctx.exception)) + + def test_a_missing_fixture_is_refused(self): + bad = self._valid_file() + bad["fixtures"] = [f for f in bad["fixtures"] if f["fixture_id"] != "F-E"] + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("F-E", str(ctx.exception)) + + def test_an_extra_fixture_id_is_refused(self): + bad = self._valid_file() + bad["fixtures"].append(self._fixture("F-Z", "unexpected")) + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("F-Z", str(ctx.exception)) + + def test_the_expected_fixture_id_set_is_exactly_f_a_through_f_e(self): + """Anchors `EXPECTED_FIXTURE_IDS` itself, independent of + `validate_expectations_file` — if this constant silently gained or + lost an id, the two tests above could pass against the wrong set.""" + self.assertEqual(EXPECTED_FIXTURE_IDS, frozenset({"F-A", "F-B", "F-C", "F-D", "F-E"})) + + # ---- expected_name / expected_name_hex / expected_name_byte_len self-consistency ---- + + def test_a_wrong_hex_is_refused(self): + bad = self._valid_file() + bad["fixtures"][0]["expected_name_hex"] = "ff" * 10 + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("F-A", msg) + self.assertIn("hex", msg) + + def test_an_uppercase_hex_is_refused(self): + """§8.1 specifically requires *lowercase* hex — an otherwise-correct + but uppercase rendering must still be refused, not accepted as + "close enough".""" + bad = self._valid_file() + bad["fixtures"][0]["expected_name_hex"] = bad["fixtures"][0]["expected_name_hex"].upper() + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("hex", str(ctx.exception)) + + def test_a_wrong_byte_length_is_refused(self): + bad = self._valid_file() + bad["fixtures"][0]["expected_name_byte_len"] += 1 + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("F-A", msg) + self.assertIn("byte_len", msg) + + def test_a_correct_hex_and_length_pair_is_not_refused(self): + """Mutation guard: confirms the two tests above are checking the + actual computed hex/length, not merely "is a string of digits" or + some other weaker property.""" + self._validate(self._valid_file()) # must not raise + + # ---- O1: cross-outcome collision (unchanged, folded into this entry point) ---- + + def test_a_colliding_file_is_refused_naming_the_fixture_and_both_outcomes(self): + bad = self._valid_file() + bad["fixtures"][2]["alternative_forms"] = { # F-C + "name-drops-unresolved-codepoints": ["Coro "], + "name-is-shaped-glyphs": ["Coro "], + } + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("F-C", msg) + self.assertIn("name-drops-unresolved-codepoints", msg) + self.assertIn("name-is-shaped-glyphs", msg) + + def test_a_collision_between_list_entries_is_also_refused(self): + """The collision can be between any entry in one outcome's list and + any entry in another's, not just single-value outcomes.""" + bad = self._valid_file() + bad["fixtures"][0]["alternative_forms"] = { # F-A + "name-normalized": ["alpha", "shared"], + "name-is-shaped-glyphs": ["beta", "shared"], + } + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("shared", str(ctx.exception)) + + def test_a_repeated_value_within_the_same_outcome_is_not_a_collision(self): + """Mutation guard: if the collision check fired on *any* repeated + value rather than specifically a *cross-outcome* one, this would + wrongly raise — two entries in one outcome's own list happening to + repeat is not the ambiguity O1 refuses.""" + fine = self._valid_file() + fine["fixtures"][0]["alternative_forms"] = {"name-normalized": ["same", "same"]} + self._validate(fine) # must not raise + + def test_the_real_committed_file_is_valid(self): + """Grounds the synthetic tests above in the actual generated + artifact — the file `run_check5` will really load. Uses the file's + own `platform`/`source_fixtures_digest` as the "expected" values + (this is the one place a real digest isn't known statically), so + this test is really only exercising the fixture-id/name-consistency/ + collision checks against real data, not the digest-mismatch check.""" + import json + import os + + path = os.path.join( + os.path.dirname(__file__), "..", "round2-a11y-oracle", "a11y_expectations.json" + ) + if not os.path.exists(path): + self.skipTest(f"{path} absent — run the round2-a11y-oracle generator first") + with open(path, "r", encoding="utf-8") as f: + real_file = json.load(f) + validate_expectations_file( + real_file, + expected_platform=real_file.get("platform"), + expected_source_digest=real_file.get("source_fixtures_digest"), + ) # must not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/spikes/editor-toolkit/a11y-verifier/verify.py b/spikes/editor-toolkit/a11y-verifier/verify.py index 7addf6f..861dc38 100644 --- a/spikes/editor-toolkit/a11y-verifier/verify.py +++ b/spikes/editor-toolkit/a11y-verifier/verify.py @@ -1,42 +1,53 @@ #!/usr/bin/env python3 -"""Round 0 accessibility readback verifier. +"""AT-SPI2 accessibility verifier — Round 0 readback mode, and Round 2 check 5. -An AT-SPI client, independent of any candidate's own process, that walks -the live platform accessibility tree (via the AT-SPI2 registry over D-Bus) -looking for an accessible node with a given role and name. This is a real -client query of the tree, per CONTRACT_EDITOR_T4_SPIKE.md Round 0: "Setting -the node in your own process and printing your own struct is NOT a -readback." +An AT-SPI client, independent of any candidate's own process, that walks the +live platform accessibility tree (via the AT-SPI2 registry over D-Bus). Two +modes, selected by which flags are given: -Uses gi.repository.Atspi, the official GObject-introspection binding for -AT-SPI2 (the same library backing Orca and Accerciser). This is used in -place of the `atspi` Rust crate as an "equivalent AT-SPI client" (the -contract's own wording) — chosen because its API is stable, documented, and -already verified reachable on this machine, rather than reverse-engineering -an unfamiliar async zbus proxy API under this round's timebox. That -substitution is a named deviation, reported as such. +Round 0 mode — unchanged, byte-for-byte, from the version that produced +`round0-evidence/c1-egui-readback.txt` and `c2-vello-readback.txt`: -Usage: verify.py --role "push button" --name "EpiphanyProbeButton" [--app-name SUBSTR] [--max-depth N] [--timeout SECONDS] -Exit code 0 and prints "READBACK: PASS" with the path from desktop root to -the matched node, if found within the timeout. Exit code 1 and prints -"READBACK: FAIL" with a dump of what *was* found, if the bus is reachable -but no match appears before the timeout. Exit code 2 and prints -"READBACK: NOT RUN" if the AT-SPI bus itself cannot be reached at all. +Looks for one exact (role, name) match anywhere under the desktop (optionally +restricted to apps whose name contains --app-name). Exit 0 "READBACK: PASS", +exit 1 "READBACK: FAIL", exit 2 "READBACK: NOT RUN" (bus unreachable). + +Round 2 check 5 mode — `spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md` §8, an +accessibility oracle packet 2B-A precommits (`round2-a11y-oracle`): + + verify.py --expectations round2-a11y-oracle/a11y_expectations.json --fixture F-A \ + --expect-source-digest \ + --app-name SUBSTR [--timeout N] [--json PATH] + +Scores one fixture's check 5 against the live tree under the candidate's +application (matched by --app-name, required in this mode). Exit 0 "CHECK5: +PASS", exit 1 "CHECK5: FAIL" (naming exactly one of +`round2_textkit::a11y::PROHIBITED_OUTCOMES`, or a role/composition-specific +diagnosis, when applicable), exit 2 "CHECK5: NOT RUN" — reserved *only* for +the AT-SPI bus itself being unreachable. A candidate that simply never built +an accessibility tree is `absent-from-tree`, which is a FAIL, not NOT RUN. + +Uses gi.repository.Atspi, the official GObject-introspection binding for +AT-SPI2 (the same library backing Orca and Accerciser). Used in place of the +`atspi` Rust crate as an "equivalent AT-SPI client" (the contract's own +wording) — chosen because its API is stable, documented, and already +verified reachable on this machine, rather than reverse-engineering an +unfamiliar async zbus proxy API under this round's timebox. That substitution +is a named deviation, reported as such. """ import argparse +import json import sys import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple -try: - import gi - - gi.require_version("Atspi", "2.0") - from gi.repository import Atspi -except Exception as exc: # pragma: no cover - environment probe - print(f"READBACK: NOT RUN — could not import gi.repository.Atspi: {exc}") - sys.exit(2) +# --------------------------------------------------------------------------- +# Round 0 mode — unmodified from the version that produced the committed +# round0-evidence transcripts. Do not change this function's behaviour. +# --------------------------------------------------------------------------- def walk(node, role, name, app_name_substr, max_depth, path, found, all_seen): @@ -77,16 +88,7 @@ def walk(node, role, name, app_name_substr, max_depth, path, found, all_seen): ) -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--role", required=True) - ap.add_argument("--name", required=True) - ap.add_argument("--app-name", default=None, help="only descend into apps whose name contains this substring") - ap.add_argument("--max-depth", type=int, default=12) - ap.add_argument("--timeout", type=float, default=20.0) - ap.add_argument("--poll-interval", type=float, default=0.5) - args = ap.parse_args() - +def run_round0(args, Atspi): try: Atspi.init() except Exception as exc: @@ -162,5 +164,939 @@ def main(): sys.exit(1) +# --------------------------------------------------------------------------- +# Round 2 check 5 mode. +# --------------------------------------------------------------------------- + +# A verifier-specific diagnostic name for the §8.1 composition trap — the +# concatenation matches `visual_order_name`, not `expected_name`. This is +# deliberately *not* one of `round2_textkit::a11y::PROHIBITED_OUTCOMES`: it is +# a structural composition failure (assembled in the wrong order), not one of +# the five name-transformation outcomes §8.3 pins. Naming it distinctly is +# the whole point of the requirement: "the report must name it rather than +# emit a generic mismatch." +VISUAL_ORDER_TRAP = "composed-in-visual-order" + +# The platform row this verifier scores against — this machine's live AT +# client is AT-SPI2 (recipe §8.2, round0-evidence's precedent), matching +# `round2_a11y_oracle::PLATFORM`. Used by `validate_expectations_file` (B2) +# to refuse an expectations file generated for a different platform, rather +# than silently scoring against the wrong role vocabulary. +PLATFORM = "at-spi2" + +# The exact five fixture ids the recipe names (ROUND2_TEXT_RECIPE.md §2), +# restated here — not read back out of the file being validated — the same +# discipline `round2_textkit::output::FixtureFile::validate`'s +# `EXPECTED_FIXTURES` uses, so a file missing one or carrying an extra id is +# caught against a literal, not against its own other contents. +EXPECTED_FIXTURE_IDS = frozenset({"F-A", "F-B", "F-C", "F-D", "F-E"}) + + +def validate_expectations_file( + expectations_file: dict, *, expected_platform: str, expected_source_digest: str +) -> None: + """B2/O1: fail closed on a malformed or stale oracle **before** any live + AT-SPI readback. Check 5 is disqualifying, so a defect in the oracle + artifact itself must never be silently absorbed into a candidate's + verdict — every check below raises `ValueError` (which the caller turns + into a usage error, exit 2, never a FAIL: a malformed oracle is not a + candidate defect) naming exactly what disagreed. + + - `platform` must equal `expected_platform` — scoring F-A's at-spi2 role + vocabulary against a file generated for a different platform would + silently check the wrong roles. + - `source_fixtures_digest` must equal `expected_source_digest` — the + caller passes `round2_textkit::output::expected_artifact_digest()` + (via `--expect-source-digest`), so an oracle generated against a + *different* `fixtures.json` (stale, or regenerated on a machine with + different fonts — recipe §1) cannot score a candidate under the + pretense of being current. + - The fixture id set is exactly `EXPECTED_FIXTURE_IDS`: no duplicates, none + missing, none extra. + - Per fixture, `expected_name` / `expected_name_hex` / `expected_name_byte_len` + are mutually consistent — recipe §8.1 carries the name three ways + specifically so a divergence between them is detectable; this is what + detects it. (lowercase hex, per §8.1's own "lowercase hex" wording.) + - D1: `source_atoms` is a list of strings whose concatenation, in order, + equals `expected_name` — the same partition property + `round2_a11y_oracle::source_atoms`'s own doc comment claims and tests + on the generation side; this is the verifier-side half of that same + check, so a hand-edited or differently-generated file cannot silently + carry atoms that no longer add up to the name they are supposed to be + components of. + - O1: no two different outcome names in one fixture's `alternative_forms` + produce the same string (unchanged from the earlier fix, folded into + this same fail-closed entry point). + + Does not touch AT-SPI or any live state, so it is testable without a bus, + the same as `classify`. + """ + actual_platform = expectations_file.get("platform") + if actual_platform != expected_platform: + raise ValueError( + f"platform is {actual_platform!r}, expected {expected_platform!r} — this oracle was " + "not generated for the platform being scored" + ) + + actual_digest = expectations_file.get("source_fixtures_digest") + if actual_digest != expected_source_digest: + raise ValueError( + f"source_fixtures_digest is {actual_digest!r}, expected {expected_source_digest!r} " + "(round2_textkit::output::expected_artifact_digest()) — this oracle may have been " + "generated against a different fixtures.json and must not score a candidate" + ) + + fixtures = expectations_file.get("fixtures", []) + ids = [fx.get("fixture_id") for fx in fixtures] + if len(ids) != len(set(ids)): + duplicates = sorted({i for i in ids if ids.count(i) > 1}) + raise ValueError(f"duplicate fixture_id(s) in expectations file: {duplicates}") + id_set = set(ids) + missing = sorted(EXPECTED_FIXTURE_IDS - id_set) + extra = sorted(id_set - EXPECTED_FIXTURE_IDS) + if missing or extra: + raise ValueError( + f"fixture id set is {sorted(id_set)}, expected exactly {sorted(EXPECTED_FIXTURE_IDS)} " + f"(missing: {missing}, extra: {extra})" + ) + + for fx in fixtures: + fixture_id = fx.get("fixture_id", "") + + name = fx.get("expected_name") + name_hex = fx.get("expected_name_hex") + name_byte_len = fx.get("expected_name_byte_len") + if not isinstance(name, str): + raise ValueError(f"{fixture_id!r}: expected_name is not a string: {name!r}") + actual_name_bytes = name.encode("utf-8") + actual_hex = actual_name_bytes.hex() # Python's .hex() is always lowercase + if name_hex != actual_hex: + raise ValueError( + f"{fixture_id!r}: expected_name_hex is {name_hex!r}, but the lowercase hex of " + f"expected_name's UTF-8 bytes is {actual_hex!r} — the name and its hex have " + "diverged" + ) + if name_byte_len != len(actual_name_bytes): + raise ValueError( + f"{fixture_id!r}: expected_name_byte_len is {name_byte_len!r}, but " + f"expected_name's UTF-8 byte length is {len(actual_name_bytes)}" + ) + + atoms = fx.get("source_atoms") + if not isinstance(atoms, list) or not all(isinstance(a, str) for a in atoms): + raise ValueError(f"{fixture_id!r}: source_atoms is not a list of strings: {atoms!r}") + joined_atoms = "".join(atoms) + if joined_atoms != name: + raise ValueError( + f"{fixture_id!r}: source_atoms {atoms!r} concatenate to {joined_atoms!r}, which " + f"does not equal expected_name {name!r} — the atoms no longer partition the name " + "they are supposed to be components of" + ) + + forms_by_outcome: Dict[str, List[str]] = fx.get("alternative_forms", {}) or {} + owner_of: Dict[str, str] = {} + for outcome, forms in forms_by_outcome.items(): + for form in forms: + existing = owner_of.get(form) + if existing is not None and existing != outcome: + raise ValueError( + f"{fixture_id!r}: alternative forms {existing!r} and {outcome!r} both " + f"produce {form!r} — an oracle that returns two different " + "classifications for the same observed string must fail closed, not " + "let iteration order pick one" + ) + owner_of[form] = outcome + + +@dataclass +class Verdict: + """One check-5 scoring outcome. `verdict` is always exactly one of + "PASS" / "FAIL" (`prohibited_outcome` distinguishes NOT RUN, which is + handled by the caller before a `Verdict` is ever constructed — NOT RUN is + reserved for the AT-SPI bus itself being unreachable, never for a + classification the tree walk produced).""" + + verdict: str + reason: str + observed_role: Optional[str] = None + observed_name: Optional[str] = None + prohibited_outcome: Optional[str] = None + + +@dataclass +class ObservedNode: + """One node of a live AT-SPI subtree, as walked by `walk_for_check5` — + role, name, and children, preserving the structure `classify` needs to + score §8.1 composition per-subtree (B1). Deliberately holds nothing else + (no live AT-SPI object reference): once built, this is inert data, which + is what lets `classify` stay pure and bus-free.""" + + role: str + name: str + children: List["ObservedNode"] = field(default_factory=list) + + +def _iter_nodes(node: ObservedNode): + """Every node in `node`'s subtree, `node` itself included, pre-order.""" + yield node + for child in node.children: + yield from _iter_nodes(child) + + +def _iter_forest(roots: List[ObservedNode]): + """Every node in every tree in `roots`, pre-order, roots first.""" + for root in roots: + yield from _iter_nodes(root) + + +def _flatten_candidates( + roots: List[ObservedNode], accepted: set, prohibited: set +) -> List[Tuple[str, str]]: + """Every `(role, name)` pair, for every node anywhere in the forest whose + role is a text-candidate (`accepted | prohibited`), in tree order. + + Used for exactly one thing now: `classify`'s final `name-empty` vs. + `absent-from-tree` decision (user ruling), reached only after every + source-bearing scan (which considers *every* role, not just + `accepted | prohibited`) has found nothing. `name-empty` is specifically + about accepted/prohibited-role candidates existing with no name, so it + is the one remaining check that legitimately wants this narrower, + role-filtered list rather than the whole forest. + """ + return [ + (n.role, n.name) for n in _iter_forest(roots) if n.role in accepted or n.role in prohibited + ] + + +def _all_descendants(root: ObservedNode) -> List[Tuple[str, str]]: + """Every `(role, name)` pair for **every** descendant of `root` **with a + non-empty name**, regardless of role — `root` itself excluded, since a + node's own name matching `expected_name` (or an alternative/visual-order + form) is the separate single-node case (§8.1's first alternative; this + is its second), in tree order. + + Deliberately **not** filtered by role before the caller concatenates: a + non-accepted-role contributor's name is still part of what the + subtree's composition actually says, and dropping it before summing + would let a subtree "pass" by silently ignoring a contributor it + doesn't like — precisely the wrong fix for B1. The caller concatenates + first, checks role-acceptability only once the concatenation is already + confirmed to equal `expected_name` (or a precommitted alternative/ + visual-order form). + + **Empty-named nodes are excluded entirely, not merely ignored when + picking whom to blame.** An empty name contributes zero bytes to the + concatenation — including or excluding it never changes `subtree_concat` + — so the only thing including it can do is let a purely structural + wrapper (a `frame` or `panel` around the real contributors, exposing no + name of its own) be *named* as the offending contributor merely because + it happens to sort first in tree order, hiding the actual, non-empty, + possibly prohibited-role contributor that is the real §8.2 violation. + Excluding it here, at the source, fixes this the same way regardless of + which subtree in `classify`'s scan happens to be tried (and matched) + first — relying on the wrapper's own name to corrupt a *different* + subtree's concatenation would only fix the cases where that subtree + happened to be visited later. + + Used by `classify`'s composition scan for **every** subtree, regardless + of which roles (if any) appear elsewhere in the tree — an earlier + version of this function only admitted unlisted-role contributors when + *no* accepted-or-prohibited-role node existed anywhere in the tree, + which is exactly the gating the "absent-from-tree vs. name-empty" fix + removed: a real application's window `label` must not prevent the run's + actual text, exposed under an unlisted role elsewhere in the same tree, + from being found. + """ + out: List[Tuple[str, str]] = [] + for child in root.children: + for n in _iter_nodes(child): + if n.name != "": + out.append((n.role, n.name)) + return out + + +def _is_source_bearing_fragment(name: str, targets) -> bool: + """**One of two additive paths** (D1) `classify`'s fragment scan uses to + decide "the run's text is present, even if not composed correctly" + (user ruling, following C3) — this is the general, heuristic, + coincidence-guarded substring rule; `source_atoms` exact matching (see + `classify`'s fragment scan) is the other, precommitted, no-length-floor + path. To distinguish real (if misordered or incomplete) evidence of the + run from an unrelated node's text that happens to share a coincidental + substring, `name` counts as a source-bearing fragment of one of + `targets` (`expected_name`, or a precommitted alternative/visual-order + form) only if it is: + + - non-empty and not whitespace-only (`name.strip()` is non-empty) — a + bare space is not evidence of anything, even though a space is + technically a substring of e.g. `"Coro "`; + - **at least two characters** after stripping — a single character is + not distinguishable from coincidence: almost any two unrelated + strings of ordinary language share *some* one character (a window + title and `"Coro אבג"` both very plausibly contain the letter `"o"`); + - a literal substring of at least one target, compared **as given** — + never normalized, and the containment test itself uses the raw + (unstripped) `name`, so incidental surrounding whitespace in `name` + that isn't present in the target correctly fails to match; only the + length/whitespace *gate* above is computed on the stripped form. + + **Stated limit, not hidden — this rule deliberately under-detects, and + is deliberately never loosened to cover it.** A genuine run fragment + shorter than two characters — F-C's unresolved segment `ا` is exactly + this case, a single character — is never caught by *this* function, on + purpose: loosening the floor to catch it would risk exactly what + `SourceBearingFragmentGuards`' guard tests exist to catch — an + application's ordinary window title coincidentally sharing a short + substring with `expected_name` and permanently disabling + `absent-from-tree` for that fixture, "which is a worse failure than the + one being fixed" (the ruling's own words). F-C's single-character + segment is instead caught by the *other* path — an exact match against + a precommitted `source_atoms` entry, which needs no length floor at all + because it is a comparison against precommitted data, not a heuristic + guess from length alone. The two paths are independent; this function's + own contract does not change. + """ + stripped = name.strip() + if len(stripped) < 2: + return False + return any(name in target for target in targets) + + +def classify(expectation: dict, roots: List[ObservedNode]) -> Verdict: + """The whole of check 5's scoring logic, and nothing else. + + `expectation` is one fixture's entry from `a11y_expectations.json` + (`round2-a11y-oracle`) — a plain dict with `expected_name`, + `accepted_roles`, `prohibited_roles`, `alternative_forms` (an outcome + name mapped to a **list** of precommitted forms — O2: one outcome can + have more than one plausible rendering, e.g. `name-is-shaped-glyphs` + carries both a cluster-collapse form and a ligature presentation-form + substitution for F-A; matched if the observed name equals *any* entry), + and (optionally) `visual_order_name`. The caller must have already run + this file through `validate_expectations_file` (O1/B2) — `classify` + itself does not re-check the oracle's own integrity, since a malformed + oracle is exactly what validation exists to refuse before this function + ever runs. + + `roots` is the forest of `ObservedNode` trees the live tree walk found + under the candidate's application (usually one tree, the matched app's + own node) — this function does not touch AT-SPI, D-Bus, or any live + state, which is what makes it testable without a bus. + + **Shape (user ruling): source-bearing detection runs first, in full, + across every role, before any absence or empty-name determination — + never the other way around.** An earlier version of this function only + looked for the run's text under unlisted roles when *no* + accepted-or-prohibited-role node existed anywhere in the tree at all. + That gate was wrong: a real application always has *some* accepted-role + node (a window title `label`, at minimum), so the run's actual text, + exposed under an unlisted role *alongside* that unrelated label, was + never even looked for — the tree fell straight into the ordinary + (non-source-bearing) scoring path and reported whatever that path says + for "some accepted-role text exists, none of it matches," which used to + be a generic mismatch and is now (see below) `absent-from-tree`. So + every check in this section runs over the **whole forest, every role, + unconditionally** — never against just the first match, and never + gated on whether some *other*, unrelated node happens to carry an + accepted or prohibited role. + + **PRECEDENCE (pinned, C1) — exact-name matches.** §8.1's rule — "the + run's own accessible name ... must equal the source string" — is + evaluated over **every** node in the forest, regardless of role, not the + first one found. If *any* node with an **accepted** role carries + `expected_name` byte-for-byte, the verdict is PASS, regardless of where + in the tree that node sits or whether some *other* node (prohibited- or + unlisted-role) also happens to carry it. Failing that, a **prohibited**- + role match is named preferentially over an **unlisted**-role one (more + specific, per §8.2's own vocabulary); failing that, an unlisted-role + match is named. This is a pinned rule, not an implementation shortcut: a + tree that lists a `canvas` node before the real `text` node is exactly + the same candidate as one that lists them in the other order, and must + score the same way. Do not "simplify" this back to returning on the + first exact-name match — that reintroduces order-dependence on a + disqualifying check. + + **B1/C2: composition and its alternative-form/visual-order diagnoses are + all scored per subtree, never against a whole-application + concatenation, and admit every role as a contributor.** §8.1's second + alternative — "the names of its text descendants concatenated in + logical order" — is a statement about *one run's* subtree, and nothing + in §8.1 restricts which roles may compose it (an unlisted role composing + correctly is still wrong — see the fragment/role check below — but that + is a role failure to report, not a reason to exclude the node from the + concatenation in the first place). This function tries every node in the + forest as a candidate "this is the run" subtree root in turn, and for + each one: + + - if that subtree's own descendants (any role) concatenate + byte-exactly to `expected_name` **and** every one of those + descendants has an accepted role, PASS; + - if they concatenate to `expected_name` but include a non-accepted-role + contributor, that is a FAIL naming that contributor specifically — an + otherwise-correct composition failed by one contributor's role, + **never** silently dropped from consideration or averaged away by + unrelated nodes elsewhere in the tree (B1's original bug: a stray + `canvas` node absorbed into a whole-application PASS; C2's bug on the + diagnosis side: a stray `label` node corrupting an F-D-style + visual-order composition into a generic mismatch instead of naming + `composed-in-visual-order`); + - if instead they concatenate to one of a `PROHIBITED_OUTCOMES` + alternative form, or to `visual_order_name`, that subtree's diagnosis + is recorded (not returned immediately — a PASS found in a *different* + subtree still wins, since a candidate that got it right anywhere in a + legitimate run subtree has satisfied §8.1). + + A single node's own name is also checked against every alternative form + and `visual_order_name` (not only `expected_name`), regardless of role — + that has no subtree/aggregation ambiguity (one node's own name is + unambiguous regardless of tree position), so it stays a simple + whole-forest scan. + + **PRECEDENCE (pinned, D2) — a byte-exact PASS outranks every + alternative-form or visual-order diagnosis, per-node or per-subtree.** + Both PASS checks above (exact-name, and composition) already scan the + *entire* forest before either can return a PASS, so evaluating them + first and in full is what makes this safe: nothing is skipped to get to + the diagnosis checks below them. Concretely, the single-node and + subtree-level alternative-form/visual-order checks run **only after** + both PASS checks have been exhausted with nothing found — never + interleaved with them. This is why F-C's legitimate two-node split + (`text:"Coro "` + `text:"ا"`, both accepted — exactly the "one text node + per direction run" composition §8.1 permits) PASSes even though + `"Coro "` alone happens to equal F-C's own precommitted + `name-drops-unresolved-codepoints` form: the composition check finds the + byte-exact two-node PASS first. Do not "simplify" this by moving an + alternative-form check earlier for convenience — doing so previously + turned a legitimate F-C composition into a false FAIL naming a + `PROHIBITED_OUTCOMES` name that did not apply. + + **C3: fragments of the run's text present anywhere, under any role, even + out of the logical order §8.1 requires, are still evidence against + absence.** Failing an exact single-node or composition match above, any + node meeting the narrow `_is_source_bearing_fragment` definition (see + its own doc comment for the rule and its stated limits) is still + evidence the run's text is present, however it is arranged — this is + the case an exact-match/composition scan alone cannot see: text that is + genuinely present but misordered or incomplete. + + **`absent-from-tree` vs. `name-empty` (user ruling, pinned) — decided + only after every check above has found nothing.** The distinction being + preserved: `name-empty` means an attempted **static-text exposure** + without a name (§8.3: "absence wearing a role"); `absent-from-tree` + covers a **drawing-only** tree or an **unrelated** one. Concretely: + + - `name-empty` fires **only** when both hold: at least one + **accepted**-role candidate node exists somewhere in the tree, *and* + every accepted-or-prohibited-role candidate's name is empty. A lone + empty **prohibited**-role node (a canvas that drew nothing and + exposed nothing) is *not* "wearing a role" in §8.3's sense — it is + the draw-and-stop case §8.3 calls "the one this check will most + likely actually catch," and it is `absent-from-tree`. + - every other case that reaches this point — a genuinely empty tree, a + drawing-only tree, or a tree whose only text (under any role) bears no + relation to the run at all — is `absent-from-tree`. + + The required regression lock for this exact distinction lives in + `AbsentFromTreeVsNameEmpty` (`test_verify.py`): + + unrelated UI text only -> absent-from-tree + empty prohibited canvas only -> absent-from-tree + empty accepted text/label node -> name-empty + misordered source fragments -> composition/role failure, never absence + + **Contributor order stays semantically significant everywhere in this + function** — only **non-contributor** permutations (an unrelated + sibling moving around the tree) are required to be verdict-invariant. + This function never "fixes" composition into an order-insensitive + match; that would defeat the entire point of the F-D visual-order trap + (§8.1). + + Comparisons are always on the Python `str` (which is Unicode + codepoints), never bytes directly, but every string compared here is + already the exact source string on the Rust side (`str == str` is + codepoint-exact, which for valid UTF-8 is byte-exact) — the caller is + responsible for hex-encoding whatever `observed_name` this returns if a + byte-level report is needed (see `run_check5`). + """ + expected_name = expectation["expected_name"] + accepted = set(expectation["accepted_roles"]) + prohibited = set(expectation["prohibited_roles"]) + alt_forms: Dict[str, List[str]] = expectation.get("alternative_forms", {}) or {} + visual_order_name = expectation.get("visual_order_name") + # D1: precommitted per-segment source atoms (`round2-a11y-oracle`'s + # `source_atoms`), e.g. F-C's `["Coro ", "ا"]`. A node name exactly + # matching one is source-bearing regardless of length — this is what + # catches F-C's single-character unresolved segment `ا`, which the + # length-2 `_is_source_bearing_fragment` substring rule cannot (and must + # not be loosened to) catch on its own. + source_atoms = set(expectation.get("source_atoms", []) or []) + + interesting_names = {expected_name} + for forms in alt_forms.values(): + interesting_names.update(forms) + if visual_order_name is not None: + interesting_names.add(visual_order_name) + + # Every node in the forest, any role — the source-bearing scans below + # are unconditional on role, per the user ruling: gating them on whether + # some *other*, unrelated node happens to carry an accepted/prohibited + # role is exactly the bug being fixed. + all_nodes: List[Tuple[str, str]] = [(n.role, n.name) for n in _iter_forest(roots)] + + # 1. C1: exact-name matches, evaluated over the *entire* forest, every + # role, before deciding anything — never the first match found, and + # never gated on some other node's role. + exact_matches = [(role, name) for role, name in all_nodes if name == expected_name] + if exact_matches: + accepted_matches = [rn for rn in exact_matches if rn[0] in accepted] + if accepted_matches: + role, name = accepted_matches[0] + return Verdict( + "PASS", + f"a node with an accepted role ({role!r}) carries the accessible name " + "byte-for-byte", + observed_role=role, + observed_name=name, + ) + prohibited_matches = [rn for rn in exact_matches if rn[0] in prohibited] + if prohibited_matches: + role, name = prohibited_matches[0] + return Verdict( + "FAIL", + f"a node's name matches expected_name byte-for-byte, but its role {role!r} " + "is in the at-spi2 prohibited set (no accepted-role node also carries it)", + observed_role=role, + observed_name=name, + ) + # Every remaining match's role is in neither accepted nor prohibited. + role, name = exact_matches[0] + return Verdict( + "FAIL", + f"a node's name matches expected_name byte-for-byte, but its role {role!r} is " + "neither accepted nor prohibited for at-spi2 (no accepted- or prohibited-role node " + "also carries it)", + observed_role=role, + observed_name=name, + ) + + # 2. B1/C2: composition and its alternative-form/visual-order diagnoses, + # all scored per subtree in one pass, every role admitted as a + # contributor. Try every node in the forest as a candidate run-subtree + # root; every check below is decided by that node's own descendants + # alone, never by nodes outside it. + first_bad_composition: Optional[Verdict] = None + first_alt_form_fail: Optional[Verdict] = None + first_visual_order_fail: Optional[Verdict] = None + for candidate_root in _iter_forest(roots): + contributors = _all_descendants(candidate_root) + if not contributors: + continue + subtree_concat = "".join(name for _, name in contributors) + + if subtree_concat == expected_name: + bad = [(role, name) for role, name in contributors if role not in accepted] + if not bad: + return Verdict( + "PASS", + "the descendants of one run subtree concatenate to expected_name " + "byte-for-byte, and every contributor's role is accepted", + observed_name=subtree_concat, + ) + if first_bad_composition is None: + # Prefer naming a prohibited-role contributor over a merely + # unlisted one: prohibited is the specific, named §8.2 + # divergence, and the report exists to say that, not the + # weaker "nobody listed this role" case — pick the first + # prohibited-role entry if any exists, else fall back to the + # first non-accepted entry (necessarily unlisted-role, since + # `bad` excludes accepted roles by construction). + prohibited_bad = [rn for rn in bad if rn[0] in prohibited] + bad_role, _bad_name = prohibited_bad[0] if prohibited_bad else bad[0] + classification = ( + "prohibited" if bad_role in prohibited else "neither accepted nor prohibited" + ) + other_count = len(bad) - 1 + mention_others = ( + f" ({other_count} other non-accepted contributor(s) also present)" + if other_count > 0 + else "" + ) + first_bad_composition = Verdict( + "FAIL", + "a run subtree's descendants concatenate to expected_name byte-for-byte, " + f"but contributor role {bad_role!r} is {classification} for at-spi2{mention_others} " + "— an otherwise-correct composition, failed by this contributor's role", + observed_role=bad_role, + observed_name=subtree_concat, + ) + continue + + if first_alt_form_fail is None: + for outcome, forms in alt_forms.items(): + if subtree_concat in forms: + first_alt_form_fail = Verdict( + "FAIL", + "one run subtree's concatenated contributors match a precommitted " + f"{outcome!r} alternative form byte-for-byte", + observed_name=subtree_concat, + prohibited_outcome=outcome, + ) + break + + if ( + first_visual_order_fail is None + and visual_order_name is not None + and subtree_concat == visual_order_name + ): + first_visual_order_fail = Verdict( + "FAIL", + "one run subtree's concatenated contributors match visual_order_name, not " + "expected_name — the tree was assembled by walking the visual runs left to " + "right instead of logical order", + observed_name=subtree_concat, + prohibited_outcome=VISUAL_ORDER_TRAP, + ) + + if first_bad_composition is not None: + return first_bad_composition + + # D2 (user ruling): a byte-exact PASS — single-node (step 1, above) or + # subtree composition (step 2, above) — outranks every alternative-form + # or visual-order diagnosis, per-node or per-subtree. Both PASS checks + # already scan the *entire* forest before this point is ever reached, so + # by construction nothing above this line has skipped a legitimate PASS + # to get here. Only now, with every PASS opportunity exhausted, do the + # alternative-form/visual-order diagnoses get a turn — starting with a + # single node's own name (no subtree ambiguity: one node's own name is + # unambiguous regardless of position or role, so this stays a flat, + # whole-forest scan), then the subtree-level matches the composition + # loop above already recorded. + # + # This ordering is why F-C's legitimate two-node split + # (`text:"Coro "` + `text:"ا"`, both accepted) now PASSes even though + # `"Coro "` alone is also F-C's precommitted `name-drops-unresolved- + # codepoints` form: the composition loop above finds the byte-exact PASS + # across both nodes and returns before this per-node check ever runs. A + # single `text:"Coro "` node with **no** second node still reaches this + # check (no composition to find), so the outcome stays named exactly as + # before — see `FCTwoSegmentComposition`'s regression group + # (`test_verify.py`) for both halves of that guarantee. + for role, name in all_nodes: + for outcome, forms in alt_forms.items(): + if name in forms: + return Verdict( + "FAIL", + f"a node's name matches a precommitted {outcome!r} alternative form " + "byte-for-byte", + observed_role=role, + observed_name=name, + prohibited_outcome=outcome, + ) + if visual_order_name is not None and name == visual_order_name: + return Verdict( + "FAIL", + "a node's name matches visual_order_name, not expected_name — the tree was " + "assembled by walking the visual runs left to right instead of logical order", + observed_role=role, + observed_name=name, + prohibited_outcome=VISUAL_ORDER_TRAP, + ) + if first_alt_form_fail is not None: + return first_alt_form_fail + if first_visual_order_fail is not None: + return first_visual_order_fail + + # 3. C3/D1: fragments of the run's text present anywhere, any role, even + # when they do not compose to any target string in the required + # logical order — the case an exact-match/composition scan alone + # cannot see: text that is genuinely present but misordered or + # incomplete. Whole-forest, not subtree-scoped: the safety valve here + # is the narrow fragment definition itself + # (`_is_source_bearing_fragment`), not tree structure — the policy is + # "any fragment anywhere is evidence against absence," which a + # subtree restriction would contradict. + # + # D1: a node counts as source-bearing via **either** of two additive + # paths — `_is_source_bearing_fragment`'s length-2-or-more substring + # rule, **or** an exact match against a precommitted `source_atoms` + # entry, regardless of length. The atom path is what catches F-C's + # unresolved segment `ا`: a single character, which the substring + # rule's coincidence guard correctly refuses (an unrelated stray "o" + # must never rescue a tree from absence) but which is nonetheless a + # real, precommitted, exact source component §8.3 requires to appear + # in the name. The two paths are independent and neither replaces the + # other — F-A (a single-segment run) has no atom shorter than its + # whole `expected_name`, so it depends entirely on the substring path, + # same as before D1. + fragments = [ + (role, name) + for role, name in all_nodes + if _is_source_bearing_fragment(name, interesting_names) or name in source_atoms + ] + if fragments: + roles = sorted({role for role, _ in fragments}) + fragment_concat = "".join(name for _, name in fragments) + return Verdict( + "FAIL", + f"{len(fragments)} fragment(s) of the run's text are present under role(s) {roles}, " + "but do not compose to expected_name or a precommitted form in the required logical " + "order — a role/composition failure, not absent-from-tree", + observed_role=roles[0] if len(roles) == 1 else None, + observed_name=fragment_concat, + ) + + # 4. Nothing above found any source-bearing evidence anywhere, under any + # role, in any shape. Only one distinction remains (user ruling, + # pinned in the docstring above): `name-empty` requires an attempted + # *static-text* exposure — at least one accepted-role candidate node + # — with every accepted-or-prohibited-role candidate's name empty. + # Every other no-source-bearing case, including a lone empty + # prohibited-role node (draw-and-stop, §8.3's own headline case) and + # unrelated text under any role, is `absent-from-tree`. + flat_candidates = _flatten_candidates(roots, accepted, prohibited) + has_accepted_candidate = any(role in accepted for role, _ in flat_candidates) + if ( + flat_candidates + and has_accepted_candidate + and all(name == "" for _, name in flat_candidates) + ): + return Verdict( + "FAIL", + "an accepted-role candidate node is present, but every accepted- or prohibited-role " + "candidate's accessible name is empty — an attempted static-text exposure with no " + "name", + observed_name="", + prohibited_outcome="name-empty", + ) + + return Verdict( + "FAIL", + "no accessible-text-candidate node (accepted or prohibited role) found under the " + "candidate's application, on any single node, composed across any subtree, or as a " + "source-bearing fragment, under any role", + prohibited_outcome="absent-from-tree", + ) + + +def walk_for_check5(node, path, all_seen, max_depth) -> Optional[ObservedNode]: + """Recursively mirrors the live AT-SPI subtree under `node` into an + `ObservedNode` tree, and records every node's `role:name` into + `all_seen` for the human/JSON "full tree" report — the same diagnostic + output this produced before B1, alongside a tree instead of a flat list. + + Unlike the pre-B1 version, this does **not** decide which nodes are + text-candidates — that decision now happens in `classify`, scoped per + subtree (B1): filtering roles *while* flattening the walk into a list is + exactly what threw away the subtree structure composition scoring needs. + """ + if node is None: + return None + try: + name = node.get_name() + except Exception: + name = "" + try: + role = node.get_role_name() + except Exception: + role = "" + all_seen.append(" / ".join(path + [f"{role}:{name!r}"])) + observed = ObservedNode(role=role, name=name) + if max_depth <= 0: + return observed + try: + n = node.get_child_count() + except Exception: + return observed + for i in range(n): + try: + child = node.get_child_at_index(i) + except Exception: + continue + child_observed = walk_for_check5( + child, path + [f"{role}:{name!r}"], all_seen, max_depth - 1 + ) + if child_observed is not None: + observed.children.append(child_observed) + return observed + + +def hex_lower(s: Optional[str]) -> Optional[str]: + if s is None: + return None + return s.encode("utf-8").hex() + + +def run_check5(args, Atspi): + try: + with open(args.expectations, "r", encoding="utf-8") as f: + expectations_file = json.load(f) + except Exception as exc: + print(f"CHECK5: usage error — could not read/parse {args.expectations!r}: {exc}") + sys.exit(2) + + try: + validate_expectations_file( + expectations_file, + expected_platform=PLATFORM, + expected_source_digest=args.expect_source_digest, + ) + except ValueError as exc: + print(f"CHECK5: usage error — {args.expectations!r} failed validation: {exc}") + sys.exit(2) + + expectation = next( + (f for f in expectations_file.get("fixtures", []) if f.get("fixture_id") == args.fixture), + None, + ) + if expectation is None: + print( + f"CHECK5: usage error — {args.fixture!r} is not a fixture in {args.expectations!r} " + f"(has: {[f.get('fixture_id') for f in expectations_file.get('fixtures', [])]})" + ) + sys.exit(2) + + try: + Atspi.init() + except Exception as exc: + print(f"CHECK5: NOT RUN — Atspi.init() failed: {exc}") + sys.exit(2) + + deadline = time.monotonic() + args.timeout + verdict = None + all_seen: List[str] = [] + attempt = 0 + while time.monotonic() < deadline: + attempt += 1 + try: + desktop = Atspi.get_desktop(0) + except Exception as exc: + print(f"CHECK5: NOT RUN — Atspi.get_desktop(0) failed: {exc}") + sys.exit(2) + if desktop is None: + print("CHECK5: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI registry?)") + sys.exit(2) + + roots: List[ObservedNode] = [] + all_seen = [] + try: + n_apps = desktop.get_child_count() + except Exception as exc: + print(f"CHECK5: NOT RUN — desktop.get_child_count() failed: {exc}") + sys.exit(2) + + for i in range(n_apps): + try: + app = desktop.get_child_at_index(i) + except Exception: + continue + if app is None: + continue + try: + app_name = app.get_name() + except Exception: + app_name = "" + if args.app_name not in app_name: + continue + app_observed = walk_for_check5(app, ["desktop"], all_seen, args.max_depth) + if app_observed is not None: + roots.append(app_observed) + + verdict = classify(expectation, roots) + if verdict.verdict == "PASS": + break + time.sleep(args.poll_interval) + + assert verdict is not None # the while loop above always runs at least once before a timeout + + print(f"CHECK5: {verdict.verdict}") + print(f"fixture: {args.fixture}") + print(f"attempt: {attempt}, timeout: {args.timeout}s") + print(f"reason: {verdict.reason}") + if verdict.observed_role is not None: + print(f"observed role: {verdict.observed_role}") + if verdict.observed_name is not None: + print(f"observed name: {verdict.observed_name!r}") + print(f"observed name (hex): {hex_lower(verdict.observed_name)}") + if verdict.prohibited_outcome is not None: + print(f"prohibited outcome: {verdict.prohibited_outcome}") + print("full tree (role:name) seen during the last walk:") + if not all_seen: + print(" ") + for line in all_seen: + print(" " + line) + + if args.json: + payload = { + "fixture_id": args.fixture, + "verdict": verdict.verdict, + "reason": verdict.reason, + "observed_role": verdict.observed_role, + "observed_name": verdict.observed_name, + "observed_name_hex": hex_lower(verdict.observed_name), + "prohibited_outcome": verdict.prohibited_outcome, + "walked_tree": all_seen, + } + with open(args.json, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + f.write("\n") + + sys.exit({"PASS": 0, "FAIL": 1}[verdict.verdict]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--role", default=None, help="Round 0 mode: exact role to match") + ap.add_argument("--name", default=None, help="Round 0 mode: exact name to match") + ap.add_argument( + "--expectations", + default=None, + help="Round 2 check 5 mode: path to round2-a11y-oracle's a11y_expectations.json", + ) + ap.add_argument("--fixture", default=None, help="Round 2 check 5 mode: fixture id (e.g. F-A)") + ap.add_argument( + "--expect-source-digest", + default=None, + help="Round 2 check 5 mode (required): round2_textkit::output::expected_artifact_digest() " + "— refuses the expectations file (usage error, exit 2) if its source_fixtures_digest " + "disagrees, so a stale oracle cannot score a candidate (B2)", + ) + ap.add_argument("--json", default=None, help="Round 2 check 5 mode: write the machine-readable verdict here") + ap.add_argument("--app-name", default=None, help="only descend into apps whose name contains this substring") + ap.add_argument("--max-depth", type=int, default=12) + ap.add_argument("--timeout", type=float, default=20.0) + ap.add_argument("--poll-interval", type=float, default=0.5) + args = ap.parse_args() + + round0_mode = args.role is not None and args.name is not None + check5_mode = args.expectations is not None and args.fixture is not None + + if round0_mode and check5_mode: + ap.error("--role/--name (Round 0 mode) and --expectations/--fixture (check 5 mode) are mutually exclusive") + if not round0_mode and not check5_mode: + ap.error("either --role and --name, or --expectations and --fixture, must be given") + if check5_mode and not args.app_name: + ap.error("--app-name is required in check 5 mode, to scope the walk to the candidate's application") + if check5_mode and not args.expect_source_digest: + ap.error( + "--expect-source-digest is required in check 5 mode (B2) — pass " + "round2_textkit::output::expected_artifact_digest()" + ) + + try: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + except Exception as exc: # pragma: no cover - environment probe + label = "READBACK" if round0_mode else "CHECK5" + print(f"{label}: NOT RUN — could not import gi.repository.Atspi: {exc}") + sys.exit(2) + + if round0_mode: + run_round0(args, Atspi) + else: + run_check5(args, Atspi) + + if __name__ == "__main__": main() diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml index f3866e2..5dc28be 100644 --- a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml @@ -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" diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json new file mode 100644 index 0000000..799e759 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json @@ -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'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json new file mode 100644 index 0000000..0baed16 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json @@ -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'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json new file mode 100644 index 0000000..27bf697 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json @@ -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'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json new file mode 100644 index 0000000..0def590 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json @@ -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'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json new file mode 100644 index 0000000..bd85c44 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json @@ -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'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json new file mode 100644 index 0000000..8879214 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json @@ -0,0 +1,1748 @@ +{ + "candidate_id": "C1 egui 0.35 + lyon 1.0 (egui_wgpu::Renderer)", + "check1_faithful_consumption": "Pass", + "check2_fallback": "Pass", + "check3_bidi": { + "NotRun": "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." + }, + "check4_hit_testing": "Pass", + "check5_accessibility": "Pass", + "check5_bus_unreachable_evidence": null, + "supplementary_f_d_bidi": "Pass", + "per_fixture_diffs": { + "F-A": { + "width": 1920, + "height": 1080, + "band_pixel_count": 52244, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 31933.196078430126, + "candidate_ink_mass": 31911.352941176752, + "d2_relative_delta": 0.0006840260273267325, + "d2_pass": true, + "reference_centroid": [ + 890.8902938917568, + 507.18029108563553 + ], + "candidate_centroid": [ + 891.1530317749048, + 507.20871028135434 + ], + "d3_delta": [ + 0.26273788314802005, + 0.028419195718811352 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 65, + "in_band_count_delta_gt_report_threshold": 2953, + "d4_regions": [ + { + "label": "F-A seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1957.9490196078427, + "relative_delta": 0.0025392170181506592, + "pass": true + }, + { + "label": "F-A seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1123.6549019607862, + "relative_delta": 0.002777294400165739, + "pass": true + }, + { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1124.0470588235312, + "relative_delta": 0.0008100558659234804, + "pass": true + }, + { + "label": "F-A seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1346.0039215686281, + "relative_delta": 0.001448815782201049, + "pass": true + }, + { + "label": "F-A seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2223.4941176470616, + "relative_delta": 0.002068057574723852, + "pass": true + }, + { + "label": "F-A seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1013.9098039215687, + "relative_delta": 0.0012353424105833444, + "pass": true + }, + { + "label": "F-A seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1435.1529411764718, + "relative_delta": 0.0011517907343631284, + "pass": true + }, + { + "label": "F-A seg0.glyph8 (face 0, gid 66)", + "reference_mass": 1406.149019607845, + "candidate_mass": 1406.2078431372556, + "relative_delta": 0.000041833069319338136, + "pass": true + }, + { + "label": "F-A seg0.glyph9 (face 0, gid 234)", + "reference_mass": 2515.3490196078515, + "candidate_mass": 2514.1294117647076, + "relative_delta": 0.00048486624863455964, + "pass": true + }, + { + "label": "F-A seg0.glyph10 (face 0, gid 70)", + "reference_mass": 1349.2196078431382, + "candidate_mass": 1344.7411764705887, + "relative_delta": 0.003319275339993548, + "pass": true + }, + { + "label": "F-A seg0.glyph11 (face 0, gid 85)", + "reference_mass": 1090.1529411764707, + "candidate_mass": 1092.3176470588246, + "relative_delta": 0.0019856900812631135, + "pass": true + }, + { + "label": "F-A seg0.glyph12 (face 0, gid 85)", + "reference_mass": 1090.2745098039222, + "candidate_mass": 1092.66274509804, + "relative_delta": 0.0021904898928135587, + "pass": true + }, + { + "label": "F-A seg0.glyph13 (face 0, gid 86)", + "reference_mass": 1597.7372549019626, + "candidate_mass": 1599.1215686274522, + "relative_delta": 0.0008664213851448065, + "pass": true + }, + { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "reference_mass": 1438.9960784313753, + "candidate_mass": 1431.9215686274504, + "relative_delta": 0.004916281503446981, + "pass": true + }, + { + "label": "F-A seg0.glyph15 (face 0, gid 84)", + "reference_mass": 1181.8352941176502, + "candidate_mass": 1180.5529411764714, + "relative_delta": 0.0010850521621425348, + "pass": true + }, + { + "label": "F-A seg0.glyph16 (face 0, gid 80)", + "reference_mass": 1437.9294117647094, + "candidate_mass": 1434.7725490196074, + "relative_delta": 0.0021954226120374756, + "pass": true + }, + { + "label": "F-A seg0.glyph18 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-A seg0.glyph20 (face 0, gid 66)", + "reference_mass": 1401.8509803921577, + "candidate_mass": 1406.5764705882366, + "relative_delta": 0.0033708933846570387, + "pass": true + }, + { + "label": "F-A seg0.glyph21 (face 0, gid 77)", + "reference_mass": 1125.5294117647059, + "candidate_mass": 1123.419607843139, + "relative_delta": 0.0018744991463697039, + "pass": true + }, + { + "label": "F-A seg0.glyph23 (face 0, gid 97)", + "reference_mass": 2139.2784313725547, + "candidate_mass": 2139.8549019607913, + "relative_delta": 0.00026946963975452555, + "pass": true + }, + { + "label": "F-A seg0.glyph24 (face 0, gid 79)", + "reference_mass": 1628.7137254901959, + "candidate_mass": 1632.3764705882386, + "relative_delta": 0.0022488575129679866, + "pass": true + }, + { + "label": "F-A seg0.glyph25 (face 0, gid 70)", + "reference_mass": 1347.239215686276, + "candidate_mass": 1343.86274509804, + "relative_delta": 0.0025062145971724357, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "reference_mass": 1438.9960784313753, + "candidate_mass": 1431.9215686274504, + "relative_delta": 0.004916281503446981, + "pass": true + }, + "pass": true + }, + "F-B": { + "width": 1920, + "height": 1080, + "band_pixel_count": 16456, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 9836.71764705878, + "candidate_ink_mass": 9843.047058823493, + "d2_relative_delta": 0.0006434475392922739, + "d2_pass": true, + "reference_centroid": [ + 407.43842537942237, + 504.8943554023082 + ], + "candidate_centroid": [ + 407.6547103818109, + 504.90202460022675 + ], + "d3_delta": [ + 0.2162850023885312, + 0.0076691979185739 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 64, + "in_band_count_delta_gt_report_threshold": 869, + "d4_regions": [ + { + "label": "F-B seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1868.686274509805, + "relative_delta": 0.0008989953516886158, + "pass": true + }, + { + "label": "F-B seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1434.1529411764707, + "relative_delta": 0.0030640645743452, + "pass": true + }, + { + "label": "F-B seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1013.3058823529412, + "relative_delta": 7.740207669876935e-6, + "pass": true + }, + { + "label": "F-B seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1433.5215686274512, + "relative_delta": 0.003701220476091557, + "pass": true + }, + { + "label": "F-B seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 849.0705882352955, + "candidate_mass": 851.4274509803922, + "relative_delta": 0.0027758148471438452, + "pass": true + }, + { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1436.7019607843135, + "relative_delta": 0.00791788291547465, + "pass": true + }, + { + "label": "F-B seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1804.5176470588276, + "candidate_mass": 1805.2509803921562, + "relative_delta": 0.0004063874545775871, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1436.7019607843135, + "relative_delta": 0.00791788291547465, + "pass": true + }, + "pass": true + }, + "F-C": { + "width": 1920, + "height": 1080, + "band_pixel_count": 9595, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 5757.713725490225, + "candidate_ink_mass": 5749.666666666617, + "d2_relative_delta": 0.0013976135680353488, + "d2_pass": true, + "reference_centroid": [ + 295.4923870245324, + 505.39794798725103 + ], + "candidate_centroid": [ + 295.38524313430213, + 505.42091647256933 + ], + "d3_delta": [ + 0.10714389023024751, + 0.022968485318301646 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 64, + "in_band_count_delta_gt_report_threshold": 509, + "d4_regions": [ + { + "label": "F-C seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1868.686274509805, + "relative_delta": 0.0008989953516886158, + "pass": true + }, + { + "label": "F-C seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1434.1529411764707, + "relative_delta": 0.0030640645743452, + "pass": true + }, + { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1013.3058823529412, + "relative_delta": 7.740207669876935e-6, + "pass": true + }, + { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1433.5215686274512, + "relative_delta": 0.003701220476091557, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1433.5215686274512, + "relative_delta": 0.003701220476091557, + "pass": true + }, + "pass": true + }, + "F-D": { + "width": 1920, + "height": 1080, + "band_pixel_count": 38964, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 23733.22352941099, + "candidate_ink_mass": 23721.407843137342, + "d2_relative_delta": 0.0004978542530897446, + "d2_pass": true, + "reference_centroid": [ + 707.2675298233589, + 507.4185305219683 + ], + "candidate_centroid": [ + 707.385752325313, + 507.4383441845064 + ], + "d3_delta": [ + 0.11822250195405104, + 0.01981366253806982 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 81, + "in_band_count_delta_gt_report_threshold": 2346, + "d4_regions": [ + { + "label": "F-D seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1957.9490196078427, + "relative_delta": 0.0025392170181506592, + "pass": true + }, + { + "label": "F-D seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1123.6549019607862, + "relative_delta": 0.002777294400165739, + "pass": true + }, + { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1124.0470588235312, + "relative_delta": 0.0008100558659234804, + "pass": true + }, + { + "label": "F-D seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1346.0039215686281, + "relative_delta": 0.001448815782201049, + "pass": true + }, + { + "label": "F-D seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2223.4941176470616, + "relative_delta": 0.002068057574723852, + "pass": true + }, + { + "label": "F-D seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1013.9098039215687, + "relative_delta": 0.0012353424105833444, + "pass": true + }, + { + "label": "F-D seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1435.1529411764718, + "relative_delta": 0.0011517907343631284, + "pass": true + }, + { + "label": "F-D seg2.glyph1 (face 0, gid 68)", + "reference_mass": 1028.6627450980407, + "candidate_mass": 1026.4196078431376, + "relative_delta": 0.00218063429009409, + "pass": true + }, + { + "label": "F-D seg2.glyph2 (face 0, gid 80)", + "reference_mass": 1438.725490196082, + "candidate_mass": 1434.8039215686283, + "relative_delta": 0.0027257240204448694, + "pass": true + }, + { + "label": "F-D seg2.glyph3 (face 0, gid 79)", + "reference_mass": 1629.4039215686275, + "candidate_mass": 1631.6431372549055, + "relative_delta": 0.001374254509049074, + "pass": true + }, + { + "label": "F-D seg2.glyph5 (face 0, gid 67)", + "reference_mass": 1962.7607843137268, + "candidate_mass": 1960.8431372549023, + "relative_delta": 0.0009770151687104411, + "pass": true + }, + { + "label": "F-D seg2.glyph6 (face 0, gid 83)", + "reference_mass": 1013.5490196078433, + "candidate_mass": 1013.8039215686275, + "relative_delta": 0.0002514944574489558, + "pass": true + }, + { + "label": "F-D seg2.glyph7 (face 0, gid 74)", + "reference_mass": 901.1450980392156, + "candidate_mass": 902.0745098039216, + "relative_delta": 0.0010313674975632112, + "pass": true + }, + { + "label": "F-D seg2.glyph8 (face 0, gid 80)", + "reference_mass": 1438.1529411764725, + "candidate_mass": 1434.5411764705889, + "relative_delta": 0.0025113912453073543, + "pass": true + }, + { + "label": "F-D seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 851.0117647058834, + "candidate_mass": 850.8196078431373, + "relative_delta": 0.00022579812725911154, + "pass": true + }, + { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1426.3529411764723, + "candidate_mass": 1436.858823529412, + "relative_delta": 0.0073655559221367795, + "pass": true + }, + { + "label": "F-D seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1805.0784313725549, + "candidate_mass": 1805.388235294118, + "relative_delta": 0.00017162906396679163, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1426.3529411764723, + "candidate_mass": 1436.858823529412, + "relative_delta": 0.0073655559221367795, + "pass": true + }, + "pass": true + }, + "F-E": { + "width": 1920, + "height": 1080, + "band_pixel_count": 27495, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 16241.003921568197, + "candidate_ink_mass": 16239.494117647086, + "d2_relative_delta": 0.00009296247500479724, + "d2_pass": true, + "reference_centroid": [ + 606.0381566289889, + 506.37877838133613 + ], + "candidate_centroid": [ + 605.8534678589202, + 506.39771522874446 + ], + "d3_delta": [ + 0.1846887700686466, + 0.018936847408326685 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 65, + "in_band_count_delta_gt_report_threshold": 1456, + "d4_regions": [ + { + "label": "F-E seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1868.686274509805, + "relative_delta": 0.0008989953516886158, + "pass": true + }, + { + "label": "F-E seg0.glyph1 (face 0, gid 66)", + "reference_mass": 1406.5058823529423, + "candidate_mass": 1404.9647058823539, + "relative_delta": 0.0010957483291930445, + "pass": true + }, + { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "reference_mass": 1293.4627450980406, + "candidate_mass": 1298.0549019607865, + "relative_delta": 0.0035502815060961435, + "pass": true + }, + { + "label": "F-E seg0.glyph3 (face 0, gid 198)", + "reference_mass": 1576.113725490198, + "candidate_mass": 1573.3725490196093, + "relative_delta": 0.0017391996695772355, + "pass": true + }, + { + "label": "F-E seg0.glyph5 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "reference_mass": 1013.5764705882351, + "candidate_mass": 1014.1686274509804, + "relative_delta": 0.0005842251472171408, + "pass": true + }, + { + "label": "F-E seg0.glyph8 (face 0, gid 70)", + "reference_mass": 1347.929411764708, + "candidate_mass": 1346.4823529411774, + "relative_delta": 0.0010735419903302715, + "pass": true + }, + { + "label": "F-E seg0.glyph9 (face 0, gid 84)", + "reference_mass": 1183.2627450980413, + "candidate_mass": 1180.0549019607845, + "relative_delta": 0.002711015072980235, + "pass": true + }, + { + "label": "F-E seg0.glyph10 (face 0, gid 86)", + "reference_mass": 1597.6784313725498, + "candidate_mass": 1599.3764705882356, + "relative_delta": 0.0010628166359028194, + "pass": true + }, + { + "label": "F-E seg0.glyph11 (face 0, gid 78)", + "reference_mass": 2515.4901960784387, + "candidate_mass": 2517.082352941179, + "relative_delta": 0.0006329409930607496, + "pass": true + }, + { + "label": "F-E seg0.glyph12 (face 0, gid 198)", + "reference_mass": 1554.384313725492, + "candidate_mass": 1549.6235294117657, + "relative_delta": 0.003062810317685005, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "reference_mass": 1293.4627450980406, + "candidate_mass": 1298.0549019607865, + "relative_delta": 0.0035502815060961435, + "pass": true + }, + "pass": true + } + }, + "hittest_probe_results": [ + { + "fixture_id": "F-A", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 652.880859375, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 704.833984375, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 744.7265625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 795.3125, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 846.826171875, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 888.525390625, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 947.998046875, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1021.533203125, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1083.59375, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1145.703125, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1196.630859375, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1276.611328125, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1356.640625, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1404.638671875, + "y": 540.0 + }, + "expected_source_offset": 23, + "expected_affinity": "Downstream", + "actual_source_offset": 23, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1455.2734375, + "y": 540.0 + }, + "expected_source_offset": 24, + "expected_affinity": "Downstream", + "actual_source_offset": 24, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1489.892578125, + "y": 540.0 + }, + "expected_source_offset": 25, + "expected_affinity": "Downstream", + "actual_source_offset": 25, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1525.244140625, + "y": 540.0 + }, + "expected_source_offset": 26, + "expected_affinity": "Downstream", + "actual_source_offset": 26, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1563.96484375, + "y": 540.0 + }, + "expected_source_offset": 27, + "expected_affinity": "Downstream", + "actual_source_offset": 27, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1620.556640625, + "y": 540.0 + }, + "expected_source_offset": 28, + "expected_affinity": "Downstream", + "actual_source_offset": 28, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1677.8125, + "y": 540.0 + }, + "expected_source_offset": 29, + "expected_affinity": "Downstream", + "actual_source_offset": 29, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 494.189453125, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 546.142578125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 596.953125, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 493.046875, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 642.041015625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 693.994140625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 763.232421875, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 817.626953125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 862.01171875, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 925.390625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 997.607421875, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1050.87890625, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1102.24609375, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1162.890625, + "y": 540.0 + }, + "expected_source_offset": 20, + "expected_affinity": "Downstream", + "actual_source_offset": 20, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1206.54296875, + "y": 540.0 + }, + "expected_source_offset": 21, + "expected_affinity": "Downstream", + "actual_source_offset": 21, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1244.90234375, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 282.71484375, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 335.400390625, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 386.71875, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 433.3984375, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 513.37890625, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 593.359375, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 634.66796875, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 690.625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 748.388671875, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 814.111328125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 909.228515625, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 985.72265625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + } + ], + "a11y_evidence": [ + { + "fixture_id": "F-A", + "platform": "at-spi2", + "observed_name": "Allegro affettuoso — al fine", + "observed_name_bytes_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-B", + "platform": "at-spi2", + "observed_name": "Coro אבג", + "observed_name_bytes_hex": "436f726f20d790d791d792", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-C", + "platform": "at-spi2", + "observed_name": "Coro ا", + "observed_name_bytes_hex": "436f726f20d8a7", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-D", + "platform": "at-spi2", + "observed_name": "Allegro אבג con brio", + "observed_name_bytes_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-E", + "platform": "at-spi2", + "observed_name": "Café — resumé", + "observed_name_bytes_hex": "43616665cc8120e2809420726573756d65cc81", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + } + ], + "cost": { + "baseline_commit": "c20bc93", + "dependencies_added": [ + { + "name": "round2-candidatekit", + "version": "0.1.0 (path)", + "reason": "candidate-neutral fixture/oracle loading and the shared report shape / scoring rule Round 2 requires every candidate to consume rather than re-derive." + }, + { + "name": "round2-diff", + "version": "0.1.0 (path)", + "reason": "the precommitted bounded visual differential (D1-D4) checks 1/2 score against." + }, + { + "name": "round2-textkit", + "version": "0.1.0 (path)", + "reason": "SpikeResolvedText fixtures, the staff->device transform (hittest::to_device), the hit-test probe table, and face resolution." + }, + { + "name": "ttf-parser", + "version": "=0.25.1", + "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." + }, + { + "name": "serde_json", + "version": "1", + "reason": "serializing CandidateReport to round2_report.json, and parsing verify.py's --json output." + }, + { + "name": "eframe", + "version": "0.35", + "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." + } + ], + "adapters": [ + { + "Implemented": { + "platform": "at-spi2", + "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).", + "integration_ownership": { + "Inherited": { + "provider": "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)" + } + } + } + }, + { + "Implemented": { + "platform": "accesskit-0.24", + "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.", + "integration_ownership": { + "Inherited": { + "provider": "eframe 0.35 (bundled AccessKit integration; accesskit was already in this crate's Round 1 dependency graph at c20bc93 via egui 0.35)" + } + } + } + }, + { + "NotBuilt": { + "platform": "aria", + "reason": "no web/ARIA target exists for this candidate — egui/eframe here is a native desktop app, not a web build." + } + }, + { + "NotBuilt": { + "platform": "macos-nsaccessibility", + "reason": "no macOS runner available in this environment." + } + }, + { + "NotBuilt": { + "platform": "windows-uia", + "reason": "no Windows runner available in this environment." + } + } + ], + "integration_wiring": [ + "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.", + "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.", + "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.", + "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 = [\"source 5..7 (\\\"ا\\\"): face resolved to None (no declared face covers this span) — 0 glyphs drawn, no substitution\"]", + "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.", + "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).", + "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." + ], + "loc_by_part": [ + { + "part": "TextRendering", + "lines": 651 + }, + { + "part": "HitTestResolution", + "lines": 228 + }, + { + "part": "AccessibilityTreeConstruction", + "lines": 64 + }, + { + "part": "AccessibilityIntegrationWiring", + "lines": 62 + }, + { + "part": "FixtureAndReportPlumbing", + "lines": 1845 + } + ] + } +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs new file mode 100644 index 0000000..be26f15 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs @@ -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, + })) + }), + ) +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs new file mode 100644 index 0000000..37f2de3 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs @@ -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, + }); + }); +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs new file mode 100644 index 0000000..1e3341b --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs @@ -0,0 +1,1004 @@ +//! `ReportPart::AccessibilityIntegrationWiring`, half two — "the subprocess +//! orchestration of the verifier" (the F3 fix's own definition of this row, +//! naming this exact responsibility). `a11y_app.rs` is the other half (the +//! window/event-loop/adapter-lifecycle side). +//! +//! This module spawns `bin/c1_round2_a11y.rs` once per fixture, runs +//! `a11y-verifier/verify.py` out-of-process against the live AT-SPI2 bus, +//! and turns its exit status + `--json` output into a +//! [`FixtureCheck5Outcome`] — never a same-process self-report. +//! +//! ## F1 — freshness +//! +//! Every invocation writes to a **fresh, unique, run-private scratch path** +//! ([`fresh_temp_path`]: candidate PID + a monotonic counter + a nanosecond +//! timestamp, in the system temp directory) that cannot have existed before +//! this call, so [`interpret_verify_output`] can never read a file this run +//! did not write — the earlier revision of this file used one fixed path +//! per fixture (`round2_a11y_evidence/.json`) and checked +//! `json_path.exists()` *before* looking at the exit status, so a stale +//! file from a previous run could be read back as if it were this run's +//! own output, and a usage +//! error's exit 2 with no file present was treated identically to a +//! genuine bus-unreachable NOT RUN. [`interpret_verify_output`] now +//! requires the exit status and the JSON verdict to **agree** (disagreement +//! is a hard [`anyhow::Error`], never a guess) and validates the JSON's own +//! `fixture_id` field against the fixture actually requested. +//! +//! ## G1 — an allow-list, not a deny-list +//! +//! F1's first cut still admitted almost anything at exit 2 as bus- +//! unreachable evidence: it rejected only stdout *beginning* with +//! `"CHECK5: usage error"` and treated everything else — empty stdout, an +//! unrecognised message, a stderr-only argparse failure (which leaves +//! stdout empty) — as NOT RUN. [`interpret_verify_output`] now **requires** +//! the exact `"CHECK5: NOT RUN"` prefix **and** one of +//! [`APPROVED_NOT_RUN_MARKERS`], read directly from `verify.py`'s own exit +//! points rather than guessed. Everything else at exit 2 is a hard `Err`. +//! +//! ## F2 — ordering independence +//! +//! [`aggregate_check5`] checks for **any** `Fail` first, over the whole +//! fixture set, before it ever looks at whether a `NotRun` also occurred — +//! the earlier revision let whichever outcome was seen *last* in the loop +//! decide, so a FAIL on one fixture followed by a legitimate NOT RUN on +//! another silently discarded the FAIL. A FAIL is disqualifying in +//! **either** ordering; an environmental NOT RUN can only apply when +//! nothing failed. +//! +//! ## G3 — publish, don't accumulate +//! +//! F1's freshness fix put the per-run private path inside `evidence_dir` +//! itself, so every run left its own PID/timestamp-named file behind and +//! the directory grew without bound (ten files after two runs of five +//! fixtures, never cleaned). *Validating* freshness and *publishing* +//! evidence are now two separate steps: [`fresh_temp_path`] writes into the +//! system temp directory (never `evidence_dir`), [`interpret_verify_output`] +//! validates it there, and only a validated outcome is +//! [`publish_canonical`]-ed to `evidence_dir`'s canonical `.json` +//! — overwriting any earlier run's file, never adding to it, and never +//! itself read back by anything in this module. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; + +use round2_candidatekit::{scoring, A11yEvidence, BusUnreachableEvidence, CheckOutcome}; + +/// Must match `bin/c1_round2_a11y.rs`'s built binary name (the file's own +/// stem — Cargo auto-names a `src/bin/*.rs` target after its file, and this +/// crate declares no `[[bin]] name` override). This is what +/// `a11y-verifier/verify.py --app-name` filters against, since AT-SPI's own +/// application name tracks the process/binary name, not the `eframe` +/// window title (measured in `round0-evidence/c1-egui-readback.txt`). +/// +/// F4: renamed from `round2_a11y` to `c1_round2_a11y` — both Round 2 +/// candidates had built a binary literally named `round2_text`/`round2_a11y`, +/// which collided in the shared `target/` output directory (a locked +/// release build can hold only one binary per name; C2's silently won). +pub const A11Y_APP_NAME: &str = "c1_round2_a11y"; + +/// One fixture's check-5 outcome, as actually observed — never inferred +/// from an absent file or a convenient default. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FixtureCheck5Outcome { + Pass { + observed_role: Option, + observed_name: Option, + }, + Fail { + reason: String, + observed_role: Option, + observed_name: Option, + prohibited_outcome: Option, + }, + /// Admissible **only** when verify.py itself reported the AT-SPI bus + /// unreachable: the exact `"CHECK5: NOT RUN"` prefix **and** one of + /// [`APPROVED_NOT_RUN_MARKERS`] (G1). Never constructed for a usage + /// error on this candidate's own invocation, empty stdout, unrecognised + /// stdout, or a stderr-only failure — all of those are a hard `Err` + /// from [`interpret_verify_output`] instead. + NotRun { reason: String }, +} + +/// **G1.** Every substring `a11y-verifier/verify.py` itself prints +/// immediately after its `"CHECK5: NOT RUN — "` prefix, at every exit point +/// that reaches `sys.exit(2)` for a genuine environmental cause (read +/// directly from `verify.py`, not guessed): +/// +/// - `"could not import gi.repository.Atspi"` — `main()`, before dispatch +/// - `"Atspi.init() failed"` — `run_check5` +/// - `"Atspi.get_desktop(0) failed"` — `run_check5` +/// - `"Atspi.get_desktop(0) returned None"` — `run_check5` +/// - `"desktop.get_child_count() failed"` — `run_check5` +/// +/// `verify.py` also exits 2 for three **usage** errors (`"CHECK5: usage +/// error — "`, not this prefix at all), which are refused below regardless +/// of marker. G1's fix is that admission now requires **both** the exact +/// `"CHECK5: NOT RUN"` prefix **and** one of these markers — empty stdout, +/// unrecognised stdout, and a stderr-only failure (argparse writes to +/// stderr, leaving stdout empty) all fail every one of these checks and are +/// refused, where the previous revision admitted all three as bus- +/// unreachable evidence merely because they did not start with +/// `"CHECK5: usage error"`. +const APPROVED_NOT_RUN_MARKERS: &[&str] = &[ + "could not import gi.repository.Atspi", + "Atspi.init() failed", + "Atspi.get_desktop(0) failed", + "Atspi.get_desktop(0) returned None", + "desktop.get_child_count() failed", +]; + +static EVIDENCE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// **G3.** A run-private **scratch** path that cannot have existed before +/// this call: the system temp directory (never `evidence_dir` — see +/// [`publish_canonical`]'s doc comment for why the two must be different +/// directories) + process id + a monotonically increasing in-process +/// counter + a nanosecond timestamp, none of which repeat across +/// invocations of this binary. F1: this is what makes "never read a file +/// this run did not write" true *structurally*, rather than by convention — +/// there is no other writer that could have created a file at this exact +/// path first. [`run_check5_for_fixture`] removes this file again once it +/// has been validated and (if applicable) published, so it never +/// accumulates. +fn fresh_temp_path(fixture_id: &str) -> PathBuf { + let pid = std::process::id(); + let seq = EVIDENCE_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "c1-round2-a11y-{fixture_id}-{pid}-{seq}-{nanos}.json" + )) +} + +/// **G3: publish, never read back.** Copies the already-validated scratch +/// JSON at `temp_path` to `evidence_dir`'s **canonical** name for +/// `fixture_id` (`.json`) — overwriting whatever an earlier run +/// left there — for a `Pass`/`Fail` outcome; for a `NotRun` outcome there is +/// no fresh JSON at all (verify.py never writes one at exit 2), so any +/// stale canonical file from an earlier run is removed instead, so the +/// directory always reflects only this run's own evidence. +/// +/// **This function is write-only with respect to `evidence_dir`.** Nothing +/// in this module ever opens a file under `evidence_dir` to decide +/// anything — every decision ([`interpret_verify_output`]) is made from the +/// private scratch path before this function is ever called. A canonical +/// file is a **report artifact**, not an input: keeping the two directories +/// distinct (scratch in the system temp dir, published in `evidence_dir`) +/// makes "the run must still refuse to read a file it did not write this +/// run" true by construction — there is no code path that reads from +/// `evidence_dir` at all. +/// +/// Idempotent: calling this once per fixture per run, for a fixed +/// five-fixture set, always leaves exactly five files in `evidence_dir` — +/// never a growing pile of timestamped names (the defect this fix closes; +/// see `publishing_is_idempotent_two_runs_leave_exactly_five_files`). +fn publish_canonical( + evidence_dir: &Path, + fixture_id: &str, + temp_path: &Path, + outcome: &FixtureCheck5Outcome, +) -> Result<()> { + let canonical = evidence_dir.join(format!("{fixture_id}.json")); + match outcome { + FixtureCheck5Outcome::Pass { .. } | FixtureCheck5Outcome::Fail { .. } => { + std::fs::copy(temp_path, &canonical).with_context(|| { + format!( + "failed to publish {} -> {}", + temp_path.display(), + canonical.display() + ) + })?; + } + FixtureCheck5Outcome::NotRun { .. } => { + // No fresh JSON exists for this fixture this run; do not leave + // a stale one behind claiming otherwise. + let _ = std::fs::remove_file(&canonical); + } + } + Ok(()) +} + +/// Turns one `verify.py` invocation's exit status + stdout + (possibly +/// absent) fresh `--json` output into a [`FixtureCheck5Outcome`], or a hard +/// [`anyhow::Error`] when the two disagree, when the JSON's own +/// `fixture_id` does not match what was requested, or when a file exists +/// where the exit status says none should. +/// +/// **This function never trusts the file alone, and never trusts the exit +/// code alone** — F1's whole point. `json_path` must be a path this +/// specific invocation was told to write to ([`fresh_temp_path`]); a +/// caller must never pass a path some earlier run might have written, and +/// this function never reads from `evidence_dir` — see [`publish_canonical`] +/// (G3). +fn interpret_verify_output( + json_path: &Path, + code: Option, + fixture_id: &str, + stdout: &str, +) -> Result { + let exists = json_path.exists(); + + match code { + Some(0) | Some(1) => { + if !exists { + return Err(anyhow!( + "verify.py exited {code:?} for {fixture_id} but wrote no JSON at {} — exit \ + status and output must agree; refusing to guess", + json_path.display() + )); + } + let text = std::fs::read_to_string(json_path) + .with_context(|| format!("failed to read {}", json_path.display()))?; + let v: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", json_path.display()))?; + let json_fixture_id = v["fixture_id"].as_str().unwrap_or(""); + if json_fixture_id != fixture_id { + return Err(anyhow!( + "verify.py's JSON at {} reports fixture_id {json_fixture_id:?}, but this \ + invocation asked for {fixture_id:?} — refusing to attribute someone else's \ + result", + json_path.display() + )); + } + let verdict = v["verdict"].as_str().unwrap_or(""); + let expected = if code == Some(0) { "PASS" } else { "FAIL" }; + if verdict != expected { + return Err(anyhow!( + "verify.py exited {code:?} (implying {expected}) for {fixture_id}, but its \ + own JSON verdict is {verdict:?} — exit status and JSON output disagree, \ + which must never be resolved by trusting either one silently" + )); + } + let reason = v["reason"].as_str().unwrap_or("").to_string(); + let observed_role = v["observed_role"].as_str().map(|s| s.to_string()); + let observed_name = v["observed_name"].as_str().map(|s| s.to_string()); + let prohibited_outcome = v["prohibited_outcome"].as_str().map(|s| s.to_string()); + if verdict == "PASS" { + Ok(FixtureCheck5Outcome::Pass { + observed_role, + observed_name, + }) + } else { + Ok(FixtureCheck5Outcome::Fail { + reason, + observed_role, + observed_name, + prohibited_outcome, + }) + } + } + Some(2) => { + // F1: a file existing here at all is a contradiction — exit 2 + // means verify.py's own `run_check5` returned before ever + // reaching its `--json` write (every usage-error and NOT-RUN + // exit point in verify.py precedes that write). A file present + // anyway (a stale leftover, a path collision) is refused rather + // than read, which is exactly the "stale PASS file" failure + // mode F1 exists to close. + if exists { + return Err(anyhow!( + "verify.py exited 2 (usage error / NOT RUN) for {fixture_id}, but a JSON \ + file exists at {} anyway — verify.py's own contract is that exit 2 never \ + writes --json, so this is refused rather than read as if it were fresh", + json_path.display() + )); + } + // G1: an **allow-list**, not a deny-list. Admission requires the + // exact "CHECK5: NOT RUN" prefix AND one of + // APPROVED_NOT_RUN_MARKERS naming the actual environmental + // cause — not merely "did not say usage error". Empty stdout, + // unrecognised stdout, and a stderr-only argparse failure (which + // leaves stdout empty) all fail this and are refused below, + // exactly the "almost anything qualifies" failure mode G1 + // exists to close. + let first_line = stdout.lines().next().unwrap_or(""); + let has_marker = APPROVED_NOT_RUN_MARKERS + .iter() + .any(|m| first_line.contains(m)); + if first_line.starts_with("CHECK5: NOT RUN") && has_marker { + Ok(FixtureCheck5Outcome::NotRun { + reason: stdout.to_string(), + }) + } else { + // Everything else at exit 2: a usage error on THIS + // candidate's own invocation, empty stdout, unrecognised + // stdout, or a stderr-only failure — never an environmental + // absence, and must not become admissible bus-unreachable + // evidence. + Err(anyhow!( + "verify.py exited 2 for {fixture_id} without an approved NOT-RUN marker — \ + refusing to admit this as bus-unreachable evidence. stdout={stdout:?}" + )) + } + } + other => Err(anyhow!( + "verify.py exited with unexpected status {other:?} for {fixture_id}: {stdout}" + )), + } +} + +/// Spawns `bin/c1_round2_a11y.rs` for `fixture_id`, runs `verify.py` against +/// it into a run-private [`fresh_temp_path`], kills the app, validates the +/// result via [`interpret_verify_output`], **then** publishes it to +/// `evidence_dir` via [`publish_canonical`] (G3) — validation and +/// publishing are two separate steps, in that order, so a rejected result +/// is never published. +fn run_check5_for_fixture( + root: &Path, + a11y_bin: &Path, + evidence_dir: &Path, + fixture_id: &str, +) -> Result { + let mut child: Child = Command::new(a11y_bin) + .arg("--fixture") + .arg(fixture_id) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .with_context(|| format!("failed to spawn {}", a11y_bin.display()))?; + + let temp_path = fresh_temp_path(fixture_id); + + let expectations = root.join("round2-a11y-oracle/a11y_expectations.json"); + let verify_py = root.join("a11y-verifier/verify.py"); + let digest = round2_textkit::output::expected_artifact_digest(); + + let output = Command::new("python3") + .arg(&verify_py) + .arg("--expectations") + .arg(&expectations) + .arg("--fixture") + .arg(fixture_id) + .arg("--app-name") + .arg(A11Y_APP_NAME) + .arg("--expect-source-digest") + .arg(digest) + .arg("--json") + .arg(&temp_path) + .arg("--timeout") + .arg("15") + .output(); + + // Always try to kill the app, whatever verify.py did. + let _ = child.kill(); + let _ = child.wait(); + + let output = output.context("failed to invoke a11y-verifier/verify.py")?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let code = output.status.code(); + + let outcome = interpret_verify_output(&temp_path, code, fixture_id, &stdout); + // Publish only on a validated outcome — never a rejected one — and + // clean up the scratch file regardless, so it never accumulates. + let result = match &outcome { + Ok(o) => publish_canonical(evidence_dir, fixture_id, &temp_path, o), + Err(_) => Ok(()), + }; + let _ = std::fs::remove_file(&temp_path); + result?; + outcome +} + +/// Reduces every fixture's [`FixtureCheck5Outcome`] to the report's +/// `check5_accessibility` cell + (when applicable) +/// [`BusUnreachableEvidence`]. +/// +/// **F2: any `Fail`, anywhere in `outcomes`, wins over any `NotRun`, +/// regardless of which was observed first.** An environmental `NotRun` is +/// admissible only when *nothing* failed. +fn aggregate_check5( + outcomes: &[(String, FixtureCheck5Outcome)], +) -> (CheckOutcome, Option) { + let failing: Vec<&str> = outcomes + .iter() + .filter(|(_, o)| matches!(o, FixtureCheck5Outcome::Fail { .. })) + .map(|(id, _)| id.as_str()) + .collect(); + if !failing.is_empty() { + return ( + CheckOutcome::fail(format!( + "{}/{} fixtures failed check 5: {} — see a11y_evidence for the exact \ + prohibited_outcome per fixture", + failing.len(), + outcomes.len(), + failing.join(", ") + )) + .expect("non-empty reason"), + None, + ); + } + + if let Some((id, reason)) = outcomes.iter().find_map(|(id, o)| match o { + FixtureCheck5Outcome::NotRun { reason } => Some((id.clone(), reason.clone())), + _ => None, + }) { + return ( + CheckOutcome::not_run(format!( + "{id}: verify.py reported the AT-SPI2 bus unreachable: {reason}" + )) + .expect("non-empty reason"), + Some(BusUnreachableEvidence { + probe_description: format!( + "a11y-verifier/verify.py --expectations ... --fixture {id} --app-name \ + {A11Y_APP_NAME} (gi.repository.Atspi, connected to the live AT-SPI2 session \ + bus)" + ), + probe_output: reason, + }), + ); + } + + (CheckOutcome::Pass, None) +} + +/// The whole of check 5: `check5_accessibility`, its bus-unreachable +/// evidence (if any), and every fixture's recorded [`A11yEvidence`]. +pub struct Check5Result { + pub check5_accessibility: CheckOutcome, + pub check5_bus_unreachable_evidence: Option, + pub a11y_evidence: Vec, +} + +/// Runs check 5 for every fixture in `fixture_ids`, in order, printing +/// progress as it goes. +pub fn run_all( + root: &Path, + a11y_bin: &Path, + evidence_dir: &Path, + fixture_ids: &[String], +) -> Result { + std::fs::create_dir_all(evidence_dir) + .with_context(|| format!("failed to create {}", evidence_dir.display()))?; + let mut outcomes = Vec::with_capacity(fixture_ids.len()); + for fixture_id in fixture_ids { + println!( + "check 5: running {} for {fixture_id}...", + a11y_bin.display() + ); + let outcome = run_check5_for_fixture(root, a11y_bin, evidence_dir, fixture_id)?; + println!(" {fixture_id} -> {outcome:?}"); + outcomes.push((fixture_id.clone(), outcome)); + } + + let a11y_evidence = outcomes + .iter() + .map(|(fixture_id, outcome)| match outcome { + FixtureCheck5Outcome::Pass { + observed_role, + observed_name, + } => A11yEvidence { + fixture_id: fixture_id.clone(), + platform: scoring::ROUND_PLATFORM.to_string(), + observed_name: observed_name.clone(), + observed_name_bytes_hex: observed_name + .as_ref() + .map(|s| s.as_bytes().iter().map(|b| format!("{b:02x}")).collect()), + observed_role: observed_role.clone(), + prohibited_outcome: None, + pass: true, + notes: "PASS".to_string(), + }, + FixtureCheck5Outcome::Fail { + reason, + observed_role, + observed_name, + prohibited_outcome, + } => A11yEvidence { + fixture_id: fixture_id.clone(), + platform: scoring::ROUND_PLATFORM.to_string(), + observed_name: observed_name.clone(), + observed_name_bytes_hex: observed_name + .as_ref() + .map(|s| s.as_bytes().iter().map(|b| format!("{b:02x}")).collect()), + observed_role: observed_role.clone(), + prohibited_outcome: prohibited_outcome.clone(), + pass: false, + notes: reason.clone(), + }, + FixtureCheck5Outcome::NotRun { reason } => A11yEvidence { + fixture_id: fixture_id.clone(), + platform: scoring::ROUND_PLATFORM.to_string(), + observed_name: None, + observed_name_bytes_hex: None, + observed_role: None, + prohibited_outcome: None, + pass: false, + notes: reason.clone(), + }, + }) + .collect(); + + let (check5_accessibility, check5_bus_unreachable_evidence) = aggregate_check5(&outcomes); + + Ok(Check5Result { + check5_accessibility, + check5_bus_unreachable_evidence, + a11y_evidence, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "c1-a11y-subprocess-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_json(path: &Path, fixture_id: &str, verdict: &str) { + std::fs::write( + path, + serde_json::json!({ + "fixture_id": fixture_id, + "verdict": verdict, + "reason": "test", + "observed_role": "label", + "observed_name": "x", + "observed_name_hex": "78", + "prohibited_outcome": serde_json::Value::Null, + "walked_tree": [], + }) + .to_string(), + ) + .unwrap(); + } + + // ---- F1: freshness ---- + + /// Required kill: a stale PASS file sitting at the path this + /// invocation was told to use, when the exit status is 2 (usage error + /// / NOT RUN, which per verify.py's own contract never writes --json), + /// must be refused — never silently read as this run's own PASS. + #[test] + fn a_stale_pass_file_at_exit_2_is_refused_not_picked_up() { + let dir = scratch_dir("stale-pass-exit-2"); + let path = dir.join("F-A-stale.json"); + write_json(&path, "F-A", "PASS"); + let err = interpret_verify_output(&path, Some(2), "F-A", "CHECK5: NOT RUN — bus gone") + .unwrap_err(); + assert!( + err.to_string().contains("exit 2"), + "must name the exit-2/file-exists contradiction: {err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The legitimate case this must not break: exit 2, no file present, + /// verify.py's own "NOT RUN" prefix (bus genuinely unreachable) -> + /// admissible NotRun. + #[test] + fn a_genuine_bus_unreachable_exit_2_with_no_file_is_not_run() { + let dir = scratch_dir("genuine-not-run"); + let path = dir.join("F-A-fresh.json"); + let outcome = interpret_verify_output( + &path, + Some(2), + "F-A", + "CHECK5: NOT RUN — Atspi.init() failed", + ) + .unwrap(); + assert!(matches!(outcome, FixtureCheck5Outcome::NotRun { .. })); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: exit 2 with no fresh file present but verify.py's own + /// output says "usage error" (a defect in how THIS candidate invoked + /// it, e.g. a bad digest or path) must be a hard failure, never + /// admissible bus-unreachable evidence — the exact "every exit 2 + /// becomes admissible" failure mode F1 names. + #[test] + fn a_usage_error_at_exit_2_is_a_hard_failure_not_admissible_not_run() { + let dir = scratch_dir("usage-error"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "CHECK5: usage error — 'a11y_expectations.json' failed validation: ...", + ) + .unwrap_err(); + assert!(err.to_string().contains("usage error"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 required kill: empty stdout at exit 2 must be a hard failure, not + /// admissible NOT RUN — the exact "almost anything qualifies" bug: no + /// marker at all was previously sufficient because it merely didn't + /// start with "CHECK5: usage error". + #[test] + fn g1_empty_stdout_at_exit_2_is_a_hard_failure() { + let dir = scratch_dir("g1-empty-stdout"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output(&path, Some(2), "F-A", "").unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 required kill: unrecognised stdout (present, non-empty, but + /// naming nothing verify.py's own NOT-RUN exit points actually print) + /// must be a hard failure. + #[test] + fn g1_unrecognised_stdout_at_exit_2_is_a_hard_failure() { + let dir = scratch_dir("g1-unrecognised-stdout"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "some unrelated diagnostic banner, not one of verify.py's own messages", + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 required kill: an argparse-style failure (Python's argparse + /// writes usage errors to **stderr**, leaving stdout empty) must be a + /// hard failure — this function only ever sees stdout, so this is the + /// same code path as the empty-stdout case, exercised under its own + /// name because it is the specific real-world trigger G1 names. + #[test] + fn g1_argparse_stderr_only_failure_is_a_hard_failure() { + let dir = scratch_dir("g1-argparse-stderr-only"); + let path = dir.join("F-A-fresh.json"); + // stdout empty, as it would be for a real argparse ap.error() exit + // (argparse prints usage + the error to stderr and calls + // sys.exit(2), never touching stdout). + let err = interpret_verify_output(&path, Some(2), "F-A", "").unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1: a genuine "CHECK5: NOT RUN" with one of every approved marker + /// must still be admitted — the allow-list must not have become so + /// strict it refuses verify.py's own real exit points. + #[test] + fn g1_every_approved_marker_is_admitted() { + let dir = scratch_dir("g1-every-marker"); + for marker in APPROVED_NOT_RUN_MARKERS { + let stdout = format!("CHECK5: NOT RUN — {marker}: simulated"); + let path = dir.join("F-A-fresh.json"); + let outcome = interpret_verify_output(&path, Some(2), "F-A", &stdout) + .unwrap_or_else(|e| panic!("marker {marker:?} must be admitted: {e}")); + assert!(matches!(outcome, FixtureCheck5Outcome::NotRun { .. })); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 gap fix, half one: the `"CHECK5: NOT RUN"` prefix is present, but + /// nothing after it matches any [`APPROVED_NOT_RUN_MARKERS`] entry. + /// verify.py itself never actually emits this (every real NOT-RUN exit + /// point pairs the prefix with one of the approved markers), but the + /// guard against it is what actually proves the **marker** half of the + /// conjunction is load-bearing: this is the one case that distinguishes + /// `prefix && marker` from `prefix && true` (a marker check silently + /// dropped) — every case already covered (empty stdout, unrelated + /// stdout, argparse's stderr-only failure) lacks the prefix too, so a + /// dropped marker check could not be seen through those alone. + #[test] + fn g1_prefix_present_marker_absent_is_a_hard_failure() { + let dir = scratch_dir("g1-prefix-no-marker"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "CHECK5: NOT RUN — something we do not recognise", + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 gap fix, half two: a marker substring appears somewhere in the + /// output, but the line does not carry the `"CHECK5: NOT RUN"` prefix — + /// an unrelated diagnostic that happens to mention e.g. `Atspi.init() + /// failed` is not a NOT-RUN verdict. This is the case that distinguishes + /// `prefix && marker` from `true && marker` (a prefix check silently + /// dropped) — every case already covered lacks a marker too, so a + /// dropped prefix check could not be seen through those alone. + #[test] + fn g1_marker_present_prefix_absent_is_a_hard_failure() { + let dir = scratch_dir("g1-marker-no-prefix"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "unrelated preamble mentioning Atspi.init() failed in passing, not a verdict line", + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: exit 0 (PASS) but no file at the fresh path at all — + /// must error rather than silently treat the run as failed/not-run. + #[test] + fn exit_0_with_no_file_is_a_hard_error() { + let dir = scratch_dir("exit0-no-file"); + let path = dir.join("F-A-missing.json"); + let err = interpret_verify_output(&path, Some(0), "F-A", "").unwrap_err(); + assert!(err.to_string().contains("wrote no JSON"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: exit status and JSON verdict disagreement (exit 0 + /// implies PASS, but the JSON says FAIL) must be a hard error, never + /// resolved by trusting either source silently. + #[test] + fn exit_status_and_json_verdict_disagreement_is_a_hard_error() { + let dir = scratch_dir("disagreement"); + let path = dir.join("F-A.json"); + write_json(&path, "F-A", "FAIL"); + let err = interpret_verify_output(&path, Some(0), "F-A", "").unwrap_err(); + assert!(err.to_string().contains("disagree"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: the JSON's own `fixture_id` must match what was + /// requested — otherwise a result meant for a different fixture (e.g. a + /// path mixup) could be silently attributed to this one. + #[test] + fn a_mismatched_fixture_id_in_the_json_is_refused() { + let dir = scratch_dir("mismatched-fixture"); + let path = dir.join("F-A.json"); + write_json(&path, "F-B", "PASS"); // wrong fixture id inside the file + let err = interpret_verify_output(&path, Some(0), "F-A", "").unwrap_err(); + assert!(err.to_string().contains("fixture_id"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The legitimate PASS path still works after all the above refusals. + #[test] + fn a_genuine_matching_pass_is_accepted() { + let dir = scratch_dir("genuine-pass"); + let path = dir.join("F-A.json"); + write_json(&path, "F-A", "PASS"); + let outcome = interpret_verify_output(&path, Some(0), "F-A", "").unwrap(); + assert!(matches!(outcome, FixtureCheck5Outcome::Pass { .. })); + let _ = std::fs::remove_dir_all(&dir); + } + + /// [`fresh_temp_path`] never repeats across calls, even for the same + /// fixture id in the same process — the structural property F1's whole + /// fix rests on. + #[test] + fn fresh_temp_path_never_repeats() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..50 { + let p = fresh_temp_path("F-A"); + assert!(seen.insert(p.clone()), "path repeated: {}", p.display()); + assert!( + !p.exists(), + "a fresh path must never already exist: {}", + p.display() + ); + } + } + + // ---- F2: ordering independence ---- + + fn fail(id: &str) -> (String, FixtureCheck5Outcome) { + ( + id.to_string(), + FixtureCheck5Outcome::Fail { + reason: "absent-from-tree".to_string(), + observed_role: None, + observed_name: None, + prohibited_outcome: Some("absent-from-tree".to_string()), + }, + ) + } + fn not_run(id: &str) -> (String, FixtureCheck5Outcome) { + ( + id.to_string(), + FixtureCheck5Outcome::NotRun { + reason: "bus unreachable".to_string(), + }, + ) + } + fn pass(id: &str) -> (String, FixtureCheck5Outcome) { + ( + id.to_string(), + FixtureCheck5Outcome::Pass { + observed_role: Some("label".to_string()), + observed_name: Some("x".to_string()), + }, + ) + } + + /// Required kill: FAIL-then-NotRun must still report FAIL, not NotRun — + /// the exact bug (a known FAIL erased by a later bus-unreachable). + #[test] + fn fail_then_not_run_reports_fail() { + let outcomes = vec![fail("F-A"), not_run("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_fail(), "{outcome:?}"); + assert!(evidence.is_none()); + } + + /// The other ordering must produce the SAME outcome — proving the + /// aggregation does not depend on which was observed first. + #[test] + fn not_run_then_fail_reports_fail_too() { + let outcomes = vec![not_run("F-A"), fail("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_fail(), "{outcome:?}"); + assert!(evidence.is_none()); + } + + #[test] + fn all_pass_reports_pass() { + let outcomes = vec![pass("F-A"), pass("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_pass()); + assert!(evidence.is_none()); + } + + /// NotRun with no FAIL anywhere is admissible, and carries evidence. + #[test] + fn not_run_with_no_fail_is_admissible_with_evidence() { + let outcomes = vec![pass("F-A"), not_run("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_not_run(), "{outcome:?}"); + assert!(evidence.is_some()); + } + + #[test] + fn not_run_before_pass_is_admissible_too() { + let outcomes = vec![not_run("F-A"), pass("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_not_run(), "{outcome:?}"); + assert!(evidence.is_some()); + } + + // ---- G3: publish, don't accumulate ---- + + /// Required kill: publishing five fixtures' outcomes, twice in a row, + /// must leave **exactly five** canonical files — never ten. This is the + /// exact defect G3 closes: the earlier revision wrote a fresh + /// PID/timestamp-named file straight into `evidence_dir` on every run, + /// so `evidence_dir` accumulated without bound. + #[test] + fn publishing_is_idempotent_two_runs_leave_exactly_five_files() { + let dir = scratch_dir("idempotent-publish"); + let fixture_ids = ["F-A", "F-B", "F-C", "F-D", "F-E"]; + for round in 0..2 { + for id in fixture_ids { + let temp = dir.join(format!("scratch-{id}-{round}.json")); + write_json(&temp, id, "PASS"); + let outcome = FixtureCheck5Outcome::Pass { + observed_role: Some("label".to_string()), + observed_name: Some("x".to_string()), + }; + publish_canonical(&dir, id, &temp, &outcome).unwrap(); + let _ = std::fs::remove_file(&temp); + } + let files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!( + files.len(), + 5, + "round {round}: expected exactly 5 canonical files, got {files:?}" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `Pass`/`Fail` outcome's canonical file carries the actually- + /// published content (not merely "a file exists") — republishing with a + /// different verdict must overwrite, not append. + #[test] + fn publishing_overwrites_the_previous_verdict() { + let dir = scratch_dir("overwrite-verdict"); + let temp1 = dir.join("scratch-1.json"); + write_json(&temp1, "F-A", "PASS"); + publish_canonical( + &dir, + "F-A", + &temp1, + &FixtureCheck5Outcome::Pass { + observed_role: None, + observed_name: None, + }, + ) + .unwrap(); + + let temp2 = dir.join("scratch-2.json"); + write_json(&temp2, "F-A", "FAIL"); + publish_canonical( + &dir, + "F-A", + &temp2, + &FixtureCheck5Outcome::Fail { + reason: "x".to_string(), + observed_role: None, + observed_name: None, + prohibited_outcome: None, + }, + ) + .unwrap(); + + let canonical = dir.join("F-A.json"); + let text = std::fs::read_to_string(&canonical).unwrap(); + assert!(text.contains("FAIL"), "{text}"); + assert!(!text.contains("PASS"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `NotRun` outcome removes any stale canonical file rather than + /// leaving an earlier run's PASS/FAIL lingering under a claim this run + /// never made. + #[test] + fn a_not_run_outcome_removes_a_stale_canonical_file() { + let dir = scratch_dir("not-run-removes-stale"); + let temp1 = dir.join("scratch-1.json"); + write_json(&temp1, "F-A", "PASS"); + publish_canonical( + &dir, + "F-A", + &temp1, + &FixtureCheck5Outcome::Pass { + observed_role: None, + observed_name: None, + }, + ) + .unwrap(); + assert!(dir.join("F-A.json").exists()); + + // No temp file exists for a NotRun outcome (verify.py never wrote + // one) — pass a path that does not exist, matching the real + // caller's situation. + let missing_temp = dir.join("does-not-exist.json"); + publish_canonical( + &dir, + "F-A", + &missing_temp, + &FixtureCheck5Outcome::NotRun { + reason: "bus unreachable".to_string(), + }, + ) + .unwrap(); + assert!( + !dir.join("F-A.json").exists(), + "a stale canonical file must be removed on NotRun, not left claiming PASS" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs new file mode 100644 index 0000000..dfb79b4 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs @@ -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 = 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 "); + 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) +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs new file mode 100644 index 0000000..33b0d5b --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs @@ -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 = 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 = 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 = BTreeMap::new(); + let mut unresolved_by_fixture: BTreeMap> = BTreeMap::new(); + let mut check1_failures: Vec = 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 = 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 = 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> { + fn walk(dir: &Path, base: &Path, out: &mut Vec) -> 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> { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let count = |rel: &str| -> Result { + 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:?}"); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs new file mode 100644 index 0000000..0522995 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs @@ -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 { + 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> { + 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" + ); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs new file mode 100644 index 0000000..c0e67e0 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs @@ -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 { + let mut stops: Vec = 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 + ); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs new file mode 100644 index 0000000..fb3202c --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs @@ -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; diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs new file mode 100644 index 0000000..de53319 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs @@ -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 { + 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> { + 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, + pub unresolved_segments: Vec, +} + +/// 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 { + 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(""); + 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, + }) +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml b/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml index 3e69518..630f462 100644 --- a/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml @@ -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" diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json b/spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json new file mode 100644 index 0000000..d10d8a4 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json @@ -0,0 +1,1749 @@ +{ + "candidate_id": "C2 vello 0.9 + kurbo (Round 2 text)", + "check1_faithful_consumption": "Pass", + "check2_fallback": "Pass", + "check3_bidi": { + "NotRun": "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." + }, + "check4_hit_testing": "Pass", + "check5_accessibility": "Pass", + "check5_bus_unreachable_evidence": null, + "supplementary_f_d_bidi": "Pass", + "per_fixture_diffs": { + "F-A": { + "width": 1920, + "height": 1080, + "band_pixel_count": 52244, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 31933.196078430126, + "candidate_ink_mass": 31976.490196078663, + "d2_relative_delta": 0.0013557715157043208, + "d2_pass": true, + "reference_centroid": [ + 890.8902938917568, + 507.18029108563553 + ], + "candidate_centroid": [ + 890.7587757181873, + 507.20303433710444 + ], + "d3_delta": [ + 0.13151817356947504, + 0.022743251468909875 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 128, + "in_band_count_delta_gt_report_threshold": 4286, + "d4_regions": [ + { + "label": "F-A seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1970.2470588235328, + "relative_delta": 0.0037259163956297717, + "pass": true + }, + { + "label": "F-A seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1127.4156862745117, + "relative_delta": 0.0005603313263507999, + "pass": true + }, + { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + { + "label": "F-A seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1352.4078431372557, + "relative_delta": 0.003302019905215551, + "pass": true + }, + { + "label": "F-A seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2215.4745098039234, + "relative_delta": 0.005667357779241938, + "pass": true + }, + { + "label": "F-A seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1020.921568627451, + "relative_delta": 0.008159455984634176, + "pass": true + }, + { + "label": "F-A seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1432.4274509803936, + "relative_delta": 0.003048697275551783, + "pass": true + }, + { + "label": "F-A seg0.glyph8 (face 0, gid 66)", + "reference_mass": 1406.149019607845, + "candidate_mass": 1406.2352941176491, + "relative_delta": 0.00006135516833637676, + "pass": true + }, + { + "label": "F-A seg0.glyph9 (face 0, gid 234)", + "reference_mass": 2515.3490196078515, + "candidate_mass": 2519.992156862748, + "relative_delta": 0.001845921666815148, + "pass": true + }, + { + "label": "F-A seg0.glyph10 (face 0, gid 70)", + "reference_mass": 1349.2196078431382, + "candidate_mass": 1348.9921568627458, + "relative_delta": 0.0001685796582485247, + "pass": true + }, + { + "label": "F-A seg0.glyph11 (face 0, gid 85)", + "reference_mass": 1090.1529411764707, + "candidate_mass": 1094.6941176470598, + "relative_delta": 0.004165632453083475, + "pass": true + }, + { + "label": "F-A seg0.glyph12 (face 0, gid 85)", + "reference_mass": 1090.2745098039222, + "candidate_mass": 1095.3098039215693, + "relative_delta": 0.0046183727789367345, + "pass": true + }, + { + "label": "F-A seg0.glyph13 (face 0, gid 86)", + "reference_mass": 1597.7372549019626, + "candidate_mass": 1609.674509803924, + "relative_delta": 0.007471350414679895, + "pass": true + }, + { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "reference_mass": 1438.9960784313753, + "candidate_mass": 1433.2745098039234, + "relative_delta": 0.003976083544083664, + "pass": true + }, + { + "label": "F-A seg0.glyph15 (face 0, gid 84)", + "reference_mass": 1181.8352941176502, + "candidate_mass": 1175.3803921568633, + "relative_delta": 0.005461761036343089, + "pass": true + }, + { + "label": "F-A seg0.glyph16 (face 0, gid 80)", + "reference_mass": 1437.9294117647094, + "candidate_mass": 1433.2745098039234, + "relative_delta": 0.003237225640355481, + "pass": true + }, + { + "label": "F-A seg0.glyph18 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-A seg0.glyph20 (face 0, gid 66)", + "reference_mass": 1401.8509803921577, + "candidate_mass": 1404.6000000000026, + "relative_delta": 0.0019609927490837425, + "pass": true + }, + { + "label": "F-A seg0.glyph21 (face 0, gid 77)", + "reference_mass": 1125.5294117647059, + "candidate_mass": 1126.929411764708, + "relative_delta": 0.001243859098987997, + "pass": true + }, + { + "label": "F-A seg0.glyph23 (face 0, gid 97)", + "reference_mass": 2139.2784313725547, + "candidate_mass": 2139.7490196078525, + "relative_delta": 0.00021997521612736234, + "pass": true + }, + { + "label": "F-A seg0.glyph24 (face 0, gid 79)", + "reference_mass": 1628.7137254901959, + "candidate_mass": 1637.3960784313776, + "relative_delta": 0.0053308035692820254, + "pass": true + }, + { + "label": "F-A seg0.glyph25 (face 0, gid 70)", + "reference_mass": 1347.239215686276, + "candidate_mass": 1349.9999999999998, + "relative_delta": 0.0020492161166176664, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + "pass": true + }, + "F-B": { + "width": 1920, + "height": 1080, + "band_pixel_count": 16456, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 9836.71764705878, + "candidate_ink_mass": 9847.207843137221, + "d2_relative_delta": 0.00106643256976859, + "d2_pass": true, + "reference_centroid": [ + 407.43842537942237, + 504.8943554023082 + ], + "candidate_centroid": [ + 407.6930699575255, + 504.8968972193984 + ], + "d3_delta": [ + 0.25464457810312524, + 0.002541817090218501 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 96, + "in_band_count_delta_gt_report_threshold": 1241, + "d4_regions": [ + { + "label": "F-B seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1865.7019607843133, + "relative_delta": 0.0006994519909196683, + "pass": true + }, + { + "label": "F-B seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1433.0235294117663, + "relative_delta": 0.0038491629706171315, + "pass": true + }, + { + "label": "F-B seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1020.4313725490197, + "relative_delta": 0.007039718875657539, + "pass": true + }, + { + "label": "F-B seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1434.4078431372564, + "relative_delta": 0.0030852588946502185, + "pass": true + }, + { + "label": "F-B seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 849.0705882352955, + "candidate_mass": 853.2901960784314, + "relative_delta": 0.004969678495054142, + "pass": true + }, + { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1437.1921568627452, + "relative_delta": 0.008261779845438142, + "pass": true + }, + { + "label": "F-B seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1804.5176470588276, + "candidate_mass": 1803.160784313725, + "relative_delta": 0.000751925450723222, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1437.1921568627452, + "relative_delta": 0.008261779845438142, + "pass": true + }, + "pass": true + }, + "F-C": { + "width": 1920, + "height": 1080, + "band_pixel_count": 9595, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 5757.713725490225, + "candidate_ink_mass": 5753.564705882305, + "d2_relative_delta": 0.0007206019273851708, + "d2_pass": true, + "reference_centroid": [ + 295.4923870245324, + 505.39794798725103 + ], + "candidate_centroid": [ + 295.5585188790057, + 505.37681328336237 + ], + "d3_delta": [ + 0.06613185447332626, + 0.021134703888662898 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 96, + "in_band_count_delta_gt_report_threshold": 862, + "d4_regions": [ + { + "label": "F-C seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1865.7019607843133, + "relative_delta": 0.0006994519909196683, + "pass": true + }, + { + "label": "F-C seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1433.0235294117663, + "relative_delta": 0.0038491629706171315, + "pass": true + }, + { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1020.4313725490197, + "relative_delta": 0.007039718875657539, + "pass": true + }, + { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1434.4078431372564, + "relative_delta": 0.0030852588946502185, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1020.4313725490197, + "relative_delta": 0.007039718875657539, + "pass": true + }, + "pass": true + }, + "F-D": { + "width": 1920, + "height": 1080, + "band_pixel_count": 38964, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 23733.22352941099, + "candidate_ink_mass": 23750.796078431424, + "d2_relative_delta": 0.0007404198169143979, + "d2_pass": true, + "reference_centroid": [ + 707.2675298233589, + 507.4185305219683 + ], + "candidate_centroid": [ + 706.8135733902325, + 507.4434049104257 + ], + "d3_delta": [ + 0.45395643312645007, + 0.024874388457419627 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 128, + "in_band_count_delta_gt_report_threshold": 3232, + "d4_regions": [ + { + "label": "F-D seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1970.2470588235328, + "relative_delta": 0.0037259163956297717, + "pass": true + }, + { + "label": "F-D seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1127.4156862745117, + "relative_delta": 0.0005603313263507999, + "pass": true + }, + { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + { + "label": "F-D seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1352.4078431372557, + "relative_delta": 0.003302019905215551, + "pass": true + }, + { + "label": "F-D seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2215.4745098039234, + "relative_delta": 0.005667357779241938, + "pass": true + }, + { + "label": "F-D seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1020.921568627451, + "relative_delta": 0.008159455984634176, + "pass": true + }, + { + "label": "F-D seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1432.4274509803936, + "relative_delta": 0.003048697275551783, + "pass": true + }, + { + "label": "F-D seg2.glyph1 (face 0, gid 68)", + "reference_mass": 1028.6627450980407, + "candidate_mass": 1023.6705882352942, + "relative_delta": 0.004853054984771197, + "pass": true + }, + { + "label": "F-D seg2.glyph2 (face 0, gid 80)", + "reference_mass": 1438.725490196082, + "candidate_mass": 1433.5372549019605, + "relative_delta": 0.003606132879048713, + "pass": true + }, + { + "label": "F-D seg2.glyph3 (face 0, gid 79)", + "reference_mass": 1629.4039215686275, + "candidate_mass": 1635.2980392156915, + "relative_delta": 0.0036173459318728373, + "pass": true + }, + { + "label": "F-D seg2.glyph5 (face 0, gid 67)", + "reference_mass": 1962.7607843137268, + "candidate_mass": 1961.5960784313743, + "relative_delta": 0.0005934018509340657, + "pass": true + }, + { + "label": "F-D seg2.glyph6 (face 0, gid 83)", + "reference_mass": 1013.5490196078433, + "candidate_mass": 1020.0549019607844, + "relative_delta": 0.006418912383200051, + "pass": true + }, + { + "label": "F-D seg2.glyph7 (face 0, gid 74)", + "reference_mass": 901.1450980392156, + "candidate_mass": 898.5843137254902, + "relative_delta": 0.0028417003202894663, + "pass": true + }, + { + "label": "F-D seg2.glyph8 (face 0, gid 80)", + "reference_mass": 1438.1529411764725, + "candidate_mass": 1432.7764705882369, + "relative_delta": 0.0037384553716779552, + "pass": true + }, + { + "label": "F-D seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 851.0117647058834, + "candidate_mass": 853.0666666666667, + "relative_delta": 0.002414657524145445, + "pass": true + }, + { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1426.3529411764723, + "candidate_mass": 1435.4549019607844, + "relative_delta": 0.006381282305068645, + "pass": true + }, + { + "label": "F-D seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1805.0784313725549, + "candidate_mass": 1801.0235294117651, + "relative_delta": 0.0022463854701905853, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + "pass": true + }, + "F-E": { + "width": 1920, + "height": 1080, + "band_pixel_count": 27495, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 16241.003921568197, + "candidate_ink_mass": 16262.019607843147, + "d2_relative_delta": 0.0012939893602907742, + "d2_pass": true, + "reference_centroid": [ + 606.0381566289889, + 506.37877838133613 + ], + "candidate_centroid": [ + 606.2924338317501, + 506.4077932099732 + ], + "d3_delta": [ + 0.25427720276127275, + 0.02901482863705951 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 112, + "in_band_count_delta_gt_report_threshold": 2223, + "d4_regions": [ + { + "label": "F-E seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1865.7019607843133, + "relative_delta": 0.0006994519909196683, + "pass": true + }, + { + "label": "F-E seg0.glyph1 (face 0, gid 66)", + "reference_mass": 1406.5058823529423, + "candidate_mass": 1404.9568627451004, + "relative_delta": 0.001101324656567097, + "pass": true + }, + { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "reference_mass": 1293.4627450980406, + "candidate_mass": 1291.156862745099, + "relative_delta": 0.0017827203463574063, + "pass": true + }, + { + "label": "F-E seg0.glyph3 (face 0, gid 198)", + "reference_mass": 1576.113725490198, + "candidate_mass": 1577.1450980392176, + "relative_delta": 0.0006543769858350831, + "pass": true + }, + { + "label": "F-E seg0.glyph5 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "reference_mass": 1013.5764705882351, + "candidate_mass": 1025.7019607843142, + "relative_delta": 0.011963073875463992, + "pass": true + }, + { + "label": "F-E seg0.glyph8 (face 0, gid 70)", + "reference_mass": 1347.929411764708, + "candidate_mass": 1351.5176470588242, + "relative_delta": 0.002662035016669373, + "pass": true + }, + { + "label": "F-E seg0.glyph9 (face 0, gid 84)", + "reference_mass": 1183.2627450980413, + "candidate_mass": 1175.768627450981, + "relative_delta": 0.006333434968781389, + "pass": true + }, + { + "label": "F-E seg0.glyph10 (face 0, gid 86)", + "reference_mass": 1597.6784313725498, + "candidate_mass": 1603.2784313725497, + "relative_delta": 0.0035050858107842165, + "pass": true + }, + { + "label": "F-E seg0.glyph11 (face 0, gid 78)", + "reference_mass": 2515.4901960784387, + "candidate_mass": 2524.2117647058844, + "relative_delta": 0.003467144750173305, + "pass": true + }, + { + "label": "F-E seg0.glyph12 (face 0, gid 198)", + "reference_mass": 1554.384313725492, + "candidate_mass": 1554.4627450980395, + "relative_delta": 0.000050458160092726724, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "reference_mass": 1013.5764705882351, + "candidate_mass": 1025.7019607843142, + "relative_delta": 0.011963073875463992, + "pass": true + }, + "pass": true + } + }, + "hittest_probe_results": [ + { + "fixture_id": "F-A", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 652.880859375, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 704.833984375, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 744.7265625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 795.3125, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 846.826171875, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 888.525390625, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 947.998046875, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1021.533203125, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1083.59375, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1145.703125, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1196.630859375, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1276.611328125, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1356.640625, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1404.638671875, + "y": 540.0 + }, + "expected_source_offset": 23, + "expected_affinity": "Downstream", + "actual_source_offset": 23, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1455.2734375, + "y": 540.0 + }, + "expected_source_offset": 24, + "expected_affinity": "Downstream", + "actual_source_offset": 24, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1489.892578125, + "y": 540.0 + }, + "expected_source_offset": 25, + "expected_affinity": "Downstream", + "actual_source_offset": 25, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1525.244140625, + "y": 540.0 + }, + "expected_source_offset": 26, + "expected_affinity": "Downstream", + "actual_source_offset": 26, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1563.96484375, + "y": 540.0 + }, + "expected_source_offset": 27, + "expected_affinity": "Downstream", + "actual_source_offset": 27, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1620.556640625, + "y": 540.0 + }, + "expected_source_offset": 28, + "expected_affinity": "Downstream", + "actual_source_offset": 28, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1677.8125, + "y": 540.0 + }, + "expected_source_offset": 29, + "expected_affinity": "Downstream", + "actual_source_offset": 29, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 494.189453125, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 546.142578125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 596.953125, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 493.046875, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 642.041015625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 693.994140625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 763.232421875, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 817.626953125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 862.01171875, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 925.390625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 997.607421875, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1050.87890625, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1102.24609375, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1162.890625, + "y": 540.0 + }, + "expected_source_offset": 20, + "expected_affinity": "Downstream", + "actual_source_offset": 20, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1206.54296875, + "y": 540.0 + }, + "expected_source_offset": 21, + "expected_affinity": "Downstream", + "actual_source_offset": 21, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1244.90234375, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 282.71484375, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 335.400390625, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 386.71875, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 433.3984375, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 513.37890625, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 593.359375, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 634.66796875, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 690.625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 748.388671875, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 814.111328125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 909.228515625, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 985.72265625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + } + ], + "a11y_evidence": [ + { + "fixture_id": "F-A", + "platform": "at-spi2", + "observed_name": "Allegro affettuoso — al fine", + "observed_name_bytes_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-B", + "platform": "at-spi2", + "observed_name": "Coro אבג", + "observed_name_bytes_hex": "436f726f20d790d791d792", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-C", + "platform": "at-spi2", + "observed_name": "Coro ا", + "observed_name_bytes_hex": "436f726f20d8a7", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-D", + "platform": "at-spi2", + "observed_name": "Allegro אבג con brio", + "observed_name_bytes_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-E", + "platform": "at-spi2", + "observed_name": "Café — resumé", + "observed_name_bytes_hex": "43616665cc8120e2809420726573756d65cc81", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + } + ], + "cost": { + "baseline_commit": "c20bc93", + "dependencies_added": [ + { + "name": "round2-candidatekit", + "version": "0.1.0 (path)", + "reason": "the shared fixture/oracle loader and report shape both Round 2 candidates report through (neutrality boundary)." + }, + { + "name": "round2-diff", + "version": "0.1.0 (path)", + "reason": "the bounded visual differential (D1-D4) check 1 is scored against." + }, + { + "name": "round2-textkit", + "version": "0.1.0 (path)", + "reason": "SpikeResolvedText and the declared-face loader/hasher (pin 9); the neutral staff->device transform (hittest::to_device)." + }, + { + "name": "ttf-parser", + "version": "0.25.1", + "reason": "candidate-owned outline extraction from the resolved face into a kurbo BezPath (task instructions) — not reused from round2-svgref, whose output is an SVG path string for the reference emitter, not kurbo geometry." + }, + { + "name": "serde_json", + "version": "1.0.151", + "reason": "serializes CandidateReport to JSON and parses a11y-verifier/verify.py's --json output." + }, + { + "name": "winit", + "version": "0.30.13", + "reason": "check 5 needs a real window on the AT-SPI bus; matches probe-vello's Round 0 pin exactly." + }, + { + "name": "accesskit", + "version": "0.24.1", + "reason": "the accessibility node/tree types this candidate builds by hand (vello ships no accessibility layer)." + }, + { + "name": "accesskit_winit", + "version": "0.33.2", + "reason": "the manual winit<->accesskit bridge; matches probe-vello's Round 0 pin exactly." + } + ], + "adapters": [ + { + "Implemented": { + "platform": "accesskit-0.24", + "notes": "the in-process accessibility tree this binary constructs by hand (Role::Window root, Role::Paragraph children) — the same tree every platform bridge below is built from. vello ships no accessibility layer, so accesskit was absent from this crate's Round 1 dependency graph at c20bc93 and is declared here directly.", + "integration_ownership": "CandidateOwned" + } + }, + { + "Implemented": { + "platform": "at-spi2", + "notes": "the round's own platform: a live window pushed through accesskit_winit 0.33 -> accesskit_unix 0.22.1, read back out-of-process by a11y-verifier/verify.py (an AT-SPI2 client via gi.repository.Atspi) for all five fixtures. See a11y_evidence in this run's report.", + "integration_ownership": "CandidateOwned" + } + }, + { + "NotBuilt": { + "platform": "aria", + "reason": "no wasm/web accesskit embedding target in this spike." + } + }, + { + "NotBuilt": { + "platform": "macos-nsaccessibility", + "reason": "no macOS runner available to this spike." + } + }, + { + "NotBuilt": { + "platform": "windows-uia", + "reason": "no Windows runner available to this spike." + } + } + ], + "integration_wiring": [ + "render.rs: a ttf_parser::OutlineBuilder implementation collecting one glyph's outline directly into a device-space kurbo::BezPath (scale + y-flip), reused per glyph across every segment/face in a fixture; one vello::Scene::fill(NonZero) call per glyph, matching the reference emitter's fill rule.", + "hittest.rs: an independent point -> (byte offset, affinity) resolver — every Downstream caret stop across every cluster, converted to device space via the shared to_device transform, sorted by device x, floor-looked-up against the query point. Does not call any of round2_textkit::hittest's probe-generation functions.", + "a11y_tree.rs: builds the accessible node content by hand — one Role::Window root plus five Role::Paragraph siblings (one per fixture), each carrying that fixture's exact source string as its accessible name. No platform/adapter code.", + "a11y_wiring.rs (AccessibilityIntegrationWiring, product-side only, H1/J2): manual accesskit_winit wiring behind a generic, verifier-agnostic surface -- run_window owns the winit event loop, the accesskit_winit::Adapter's lifecycle, window/bridge setup, and publishing a11y_tree.rs's tree, then blocks until any caller delivers a T via FinishHandle::finish and returns it. Nothing in this file names a verifier, a subprocess, or A11yRoundResult -- every line here is what a real editor shipping this stack would keep.", + "a11y_subprocess.rs (FixtureAndReportPlumbing, shared spike/report harness, H1/J2): drives a11y_wiring::run_window, spawning the worker thread whose only jobs are calling run_all_fixtures and delivering its result -- the coordination that exists solely because this spike scores itself out-of-process, moved out of the wiring file entirely. run_all_fixtures itself runs a11y-verifier/verify.py once per fixture as a subprocess, against a fresh run-unique --json output path deleted immediately before each invocation (F1), cross-checking the exit status against the json's own verdict and fixture_id fields before trusting either; requires the exact 'CHECK5: NOT RUN' prefix AND one of verify.py's approved environmental markers before treating exit 2 as bus-unreachable, checked as two independent conditions (H2); reduces all five fixtures' outcomes to the round's verdict only after every one has been attempted, so a FAIL found on any fixture always wins over a BusUnreachable found on another regardless of ordering (F2). This harness is common to both Round 2 candidates and is not part of either one's accessibility stack.", + "c2_round2_text.rs (FixtureAndReportPlumbing): orchestration, plus the check2/supplementary structural verifications (segment face-index and direction checks against the resolved data actually drawn from) that round2-candidatekit's neutrality boundary leaves to the candidate; the F3/H1 loc_by_part file mapping and its exhaustiveness guard." + ], + "loc_by_part": [ + { + "part": "TextRendering", + "lines": 342 + }, + { + "part": "HitTestResolution", + "lines": 254 + }, + { + "part": "AccessibilityTreeConstruction", + "lines": 109 + }, + { + "part": "AccessibilityIntegrationWiring", + "lines": 199 + }, + { + "part": "FixtureAndReportPlumbing", + "lines": 1925 + } + ] + } +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs new file mode 100644 index 0000000..b03f351 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs @@ -0,0 +1,1145 @@ +//! **Packet 2B-C2**: candidate C2 (vello) against Round 2's five text checks +//! (`spec/CONTRACT_EDITOR_T4_SPIKE.md` Round 2; `ROUND2_TEXT_RECIPE.md`). +//! +//! This is a **separate entry point** from `src/main.rs` (Round 1's frozen +//! evidence, untouched by this packet) — headless offscreen rendering for +//! checks 1/2/4 plus a real windowed accessibility mode for check 5, which +//! cannot be headless (`ROUND2_TEXT_RECIPE.md` §8 / task instructions: "This +//! needs a real window on the AT-SPI bus"). **This file is +//! `c2_round2_text.rs`**, not `main.rs` — `main.rs` is the untouched Round 1 +//! binary named above, and every reference in this file's own cost record +//! below to "this file" means `c2_round2_text.rs` specifically (an earlier +//! revision said `main.rs` in two places by mistake; both are review +//! findings F5, fixed here). +//! +//! Every rendering, hit-test-resolution, and accessibility decision in this +//! binary is candidate-owned (`round2-candidatekit`'s neutrality boundary, +//! its own module doc comment): this file and its `c2_round2_text/` +//! submodules never call into `round2-svgref` (the reference emitter) or +//! `round2_textkit::hittest`'s probe-*generation* functions — see +//! `c2_round2_text/render.rs` and `c2_round2_text/hittest.rs` for exactly +//! why. +//! +//! ## F3/H1 — the cost-table LOC categories, and why they are whole files +//! +//! Every [`ReportPart`] below maps to a **disjoint set of whole files** — +//! `loc_by_part_files` (in `main`) states the mapping once, prints it, and +//! [`assert_loc_by_part_is_exhaustive`] fails loudly if any `.rs` file under +//! this binary's Round 2 sources is unclaimed or claimed twice. +//! [`CostRecord::loc_by_part`] is `wc -l` over exactly the claimed files: +//! +//! | part | file(s) | +//! |---|---| +//! | `TextRendering` | `c2_round2_text/render.rs` | +//! | `HitTestResolution` | `c2_round2_text/hittest.rs` | +//! | `AccessibilityTreeConstruction` | `c2_round2_text/a11y_tree.rs` | +//! | `AccessibilityIntegrationWiring` | `c2_round2_text/a11y_wiring.rs` | +//! | `FixtureAndReportPlumbing` | `c2_round2_text.rs` (this file), `c2_round2_text/a11y_subprocess.rs` | +//! +//! No file contributes to two rows, and `FixtureAndReportPlumbing` is +//! legitimately backed by **two** files — the rule is "disjoint", not "one +//! file per part": a part may be the sum of several files, as long as no +//! file is counted under more than one part. +//! +//! **H1 ruling: the `verify.py` subprocess harness is not +//! `AccessibilityIntegrationWiring`.** `a11y_wiring.rs` used to also own +//! running the verifier, decoding its output, and reducing five fixtures' +//! outcomes to one — but that harness exists identically for both Round 2 +//! candidates and is not part of either one's accessibility *stack*, so it +//! is `FixtureAndReportPlumbing` (shared spike/report plumbing), not +//! wiring. It is now `a11y_subprocess.rs`: verifier subprocesses, result +//! decoding/reduction, bus-unreachable evidence, and temporary/canonical +//! evidence-file handling. `a11y_wiring.rs` keeps only the product-side +//! path — adapter lifecycle, event loop, window/bridge setup, tree +//! publication. (Earlier still, before F3, both of those were one file with +//! `a11y_tree.rs`'s tree-construction content mixed in too, split by a +//! hand-picked LOC constant; that estimate is gone, replaced first by the +//! `a11y_tree.rs`/`a11y_wiring.rs` file boundary and now by this second one.) + +#[path = "c2_round2_text/a11y_subprocess.rs"] +mod a11y_subprocess; +#[path = "c2_round2_text/a11y_tree.rs"] +mod a11y_tree; +#[path = "c2_round2_text/a11y_wiring.rs"] +mod a11y_wiring; +#[path = "c2_round2_text/hittest.rs"] +mod hittest; +#[path = "c2_round2_text/render.rs"] +mod render; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; + +use round2_candidatekit::report::{ + AdapterStatus, CostRecord, DependencyDelta, DiffReportRecord, HitTestProbeResult, + IntegrationOwnership, LocByPart, ReportPart, +}; +use round2_candidatekit::{CandidateReport, CheckOutcome}; +use round2_diff::DiffReport; +use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace}; +use round2_textkit::types::{SpikeResolvedText, SpikeTextDirection}; + +use crate::a11y_subprocess::A11yRoundResult; + +/// The Round 1 baseline commit this packet's cost delta is measured against +/// (task instructions). +const BASELINE_COMMIT: &str = "c20bc93"; + +/// `CARGO_MANIFEST_DIR` is `.../spikes/editor-toolkit/round1-candidates/c2-vello`; +/// the spike workspace root is two levels up. +fn spike_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("spike root must exist") +} + +/// Check 1 (faithful consumption): the criterion cell is the worst of all +/// five fixtures' bounded visual differential (`ROUND2_TEXT_RECIPE.md` §10). +fn check1_outcome(diffs: &BTreeMap) -> Result { + let mut failing = Vec::new(); + for (id, d) in diffs { + if !d.pass() { + let mut rules = Vec::new(); + if !d.d1_pass { + rules.push("D1"); + } + if !d.d2_pass { + rules.push("D2"); + } + if let Some(false) = d.d3_pass { + rules.push("D3"); + } + if !d.d4_pass { + rules.push("D4"); + } + failing.push(format!("{id} ({})", rules.join(","))); + } + } + if failing.is_empty() { + Ok(CheckOutcome::Pass) + } else { + CheckOutcome::fail(format!( + "bounded visual differential (recipe §10) FAILed for: {}", + failing.join("; ") + )) + .map_err(|e| anyhow!(e)) + } +} + +/// Check 2 (fallback, forced — DISQUALIFYING): F-C's U+0627 is covered by +/// neither declared face and must be **reported**, never substituted; F-B +/// must actually traverse the declared chain (Latin head on face 0, Hebrew +/// tail on face 1), never host-substitute. Both are checked directly against +/// the resolved data this binary drew from (never re-derived from the +/// source text), plus the two fixtures' own bounded-differential result. +fn check2_outcome( + resolved_by_id: &BTreeMap<&str, &SpikeResolvedText>, + diffs: &BTreeMap, +) -> Result { + let f_c = *resolved_by_id + .get("F-C") + .ok_or_else(|| anyhow!("F-C missing from loaded fixtures"))?; + let unresolved: Vec<_> = f_c.segments.iter().filter(|s| s.face.is_none()).collect(); + if unresolved.len() != 1 { + return CheckOutcome::fail(format!( + "F-C: expected exactly one unresolved (face=None) segment, found {}", + unresolved.len() + )) + .map_err(|e| anyhow!(e)); + } + let seg = unresolved[0]; + if !seg.glyphs.is_empty() { + return CheckOutcome::fail( + "F-C: the unresolved segment carries glyphs — this consumer must never substitute a \ + fallback/`.notdef` glyph for an uncovered codepoint" + .to_string(), + ) + .map_err(|e| anyhow!(e)); + } + let range = seg.source.start as usize..seg.source.end as usize; + let uncovered_text = f_c.text.get(range.clone()).ok_or_else(|| { + anyhow!("F-C: unresolved segment source range is not on a UTF-8 boundary") + })?; + // Surfaced explicitly, per task instructions: "a candidate that silently + // draws nothing looks identical to one that correctly reported, and the + // check is about which of those you did." + println!( + "check 2 (fallback, forced): F-C reports an UNCOVERED span — byte {}..{} = {uncovered_text:?} \ + ({} codepoint(s)), resolved in NEITHER declared face. This binary drew NO ink for it and \ + substituted NOTHING — SpikeShapedSegment::glyphs is empty by construction for a face:None \ + segment (shaping is never attempted against a face that cannot represent the codepoint).", + seg.source.start, + seg.source.end, + uncovered_text.chars().count() + ); + + let f_b = *resolved_by_id + .get("F-B") + .ok_or_else(|| anyhow!("F-B missing from loaded fixtures"))?; + let f_b_faces: Vec> = f_b.segments.iter().map(|s| s.face).collect(); + if f_b_faces != [Some(0), Some(1)] { + return CheckOutcome::fail(format!( + "F-B: expected two segments resolved to faces [Some(0), Some(1)] (Latin head on face \ + 0, Hebrew tail on face 1 — the declared fallback traversal), got {f_b_faces:?}" + )) + .map_err(|e| anyhow!(e)); + } + println!( + "check 2 (fallback, forced): F-B traverses the declared chain — segment 0 -> face 0 \ + (Latin), segment 1 -> face 1 (Hebrew). No host substitution." + ); + + let d_c = diffs.get("F-C").expect("F-C diff already computed"); + if !d_c.pass() { + return CheckOutcome::fail(format!( + "F-C rendering diverged from the reference (bounded visual differential FAILed) — \ + worst region {:?}", + d_c.d4_worst + )) + .map_err(|e| anyhow!(e)); + } + let d_b = diffs.get("F-B").expect("F-B diff already computed"); + if !d_b.pass() { + return CheckOutcome::fail(format!( + "F-B rendering diverged from the reference (bounded visual differential FAILed) — \ + worst region {:?}", + d_b.d4_worst + )) + .map_err(|e| anyhow!(e)); + } + + Ok(CheckOutcome::Pass) +} + +/// Check 4 (hit testing): resolves every committed probe against this +/// binary's own `hittest::resolve_hit`, and records every result, pass and +/// fail (task instructions: "Record every probe result, pass and fail"). +fn check4_outcome( + probes: &round2_textkit::hittest::HitTestProbeFile, + resolved_by_id: &BTreeMap<&str, &SpikeResolvedText>, +) -> Result<(CheckOutcome, Vec)> { + let mut results = Vec::new(); + let mut fail_count = 0usize; + for table in &probes.fixtures { + let rt = *resolved_by_id + .get(table.fixture_id.as_str()) + .ok_or_else(|| anyhow!("{}: missing resolved text", table.fixture_id))?; + for probe in &table.probes { + let (actual_source_offset, actual_affinity) = hittest::resolve_hit(rt, &probe.point); + let pass = actual_source_offset == probe.expected_source_offset + && actual_affinity == probe.expected_affinity; + if !pass { + fail_count += 1; + } + results.push(HitTestProbeResult { + fixture_id: table.fixture_id.clone(), + point: probe.point, + expected_source_offset: probe.expected_source_offset, + expected_affinity: probe.expected_affinity, + actual_source_offset, + actual_affinity, + pass, + }); + } + } + let outcome = if fail_count == 0 { + CheckOutcome::Pass + } else { + CheckOutcome::fail(format!( + "{fail_count}/{} hit-test probes FAILed (recipe §7)", + results.len() + )) + .map_err(|e| anyhow!(e))? + }; + Ok((outcome, results)) +} + +/// F-D's supplementary bidi row (`ROUND2_TEXT_RECIPE.md` §1.2) — never +/// reaches the check 3 criterion cell (`round2_candidatekit::scoring` reads +/// only `check3_bidi`, which this binary always reports as `NotRun` per the +/// standing ruling). Verified structurally against the resolved segments +/// themselves (three segments, faces [0,1,0], directions [Ltr,Rtl,Ltr]) plus +/// F-D's own bounded-differential result. +fn supplementary_f_d_outcome( + f_d: &SpikeResolvedText, + diff_f_d: &DiffReport, +) -> Result { + let faces: Vec> = f_d.segments.iter().map(|s| s.face).collect(); + if faces != [Some(0), Some(1), Some(0)] { + return CheckOutcome::fail(format!( + "F-D: expected segment faces [Some(0), Some(1), Some(0)] (outer Latin, inner Hebrew), \ + got {faces:?}" + )) + .map_err(|e| anyhow!(e)); + } + let dirs: Vec = f_d.segments.iter().map(|s| s.direction).collect(); + if dirs + != [ + SpikeTextDirection::Ltr, + SpikeTextDirection::Rtl, + SpikeTextDirection::Ltr, + ] + { + return CheckOutcome::fail(format!( + "F-D: expected segment directions [Ltr, Rtl, Ltr] (three visual runs at levels \ + 0/1/0), got {dirs:?}" + )) + .map_err(|e| anyhow!(e)); + } + if !diff_f_d.pass() { + return CheckOutcome::fail(format!( + "F-D rendering diverged from the reference (bounded visual differential FAILed) — \ + worst region {:?}", + diff_f_d.d4_worst + )) + .map_err(|e| anyhow!(e)); + } + Ok(CheckOutcome::Pass) +} + +fn count_file_lines(path: &std::path::Path) -> u64 { + std::fs::read_to_string(path) + .map(|s| s.lines().count() as u64) + .unwrap_or(0) +} + +/// J1: aggregates one `(part, lines)` pair per *file* into exactly one row +/// per distinct `ReportPart`, order-preserving (first-seen order) — the fix +/// for the serialized report carrying two rows for the same part once a +/// part gained a second contributing file (H1's split gave +/// `FixtureAndReportPlumbing` two: `a11y_subprocess.rs` and this file). +/// +/// `ReportPart` derives neither `Ord` nor `Hash`, so this aggregates by +/// linear scan (`==`, from its `PartialEq` derive) rather than a +/// `BTreeMap`/`HashMap` key — fine at this scale (five parts, at most a +/// handful of files each). +fn aggregate_loc_by_part(per_file: &[(ReportPart, u64)]) -> Vec { + let mut aggregated: Vec<(ReportPart, u64)> = Vec::new(); + for (part, lines) in per_file { + match aggregated.iter_mut().find(|(p, _)| p == part) { + Some((_, total)) => *total += lines, + None => aggregated.push((part.clone(), *lines)), + } + } + aggregated + .into_iter() + .map(|(part, lines)| LocByPart { part, lines }) + .collect() +} + +/// The F3/H1 file-to-part mapping, stated once. `src_dir` is +/// `src/bin/c2_round2_text/`; `this_file` is `src/bin/c2_round2_text.rs` +/// itself. +fn loc_by_part_files( + src_dir: &std::path::Path, + this_file: &std::path::Path, +) -> Vec<(ReportPart, PathBuf)> { + vec![ + (ReportPart::TextRendering, src_dir.join("render.rs")), + (ReportPart::HitTestResolution, src_dir.join("hittest.rs")), + ( + ReportPart::AccessibilityTreeConstruction, + src_dir.join("a11y_tree.rs"), + ), + ( + ReportPart::AccessibilityIntegrationWiring, + src_dir.join("a11y_wiring.rs"), + ), + ( + ReportPart::FixtureAndReportPlumbing, + src_dir.join("a11y_subprocess.rs"), + ), + ( + ReportPart::FixtureAndReportPlumbing, + this_file.to_path_buf(), + ), + ] +} + +/// H1's exhaustiveness guard: every `.rs` file actually present under this +/// binary's Round 2 sources (`src_dir`'s contents, plus `this_file`) must be +/// claimed by [`loc_by_part_files`] **exactly once** — never zero times +/// (silently uncounted cost), never twice (double-counted cost). Panics, +/// naming every file that violates either half, rather than silently +/// under- or over-reporting the table C1's identical split is compared +/// against. +fn assert_loc_by_part_is_exhaustive( + claimed: &[(ReportPart, PathBuf)], + src_dir: &std::path::Path, + this_file: &std::path::Path, +) { + let mut claim_count: BTreeMap = BTreeMap::new(); + for (_part, path) in claimed { + *claim_count.entry(path.clone()).or_insert(0) += 1; + } + let claimed_set: BTreeSet = claim_count.keys().cloned().collect(); + + let mut actual: BTreeSet = BTreeSet::new(); + actual.insert(this_file.to_path_buf()); + for entry in std::fs::read_dir(src_dir) + .unwrap_or_else(|e| panic!("{}: could not list Round 2 sources: {e}", src_dir.display())) + { + let entry = entry.expect("readable directory entry"); + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("rs") { + actual.insert(path); + } + } + + let unclaimed: Vec<&PathBuf> = actual.difference(&claimed_set).collect(); + let nonexistent_claims: Vec<&PathBuf> = claimed_set.difference(&actual).collect(); + let double_claimed: Vec<(&PathBuf, u32)> = claim_count + .iter() + .filter(|(_, &n)| n > 1) + .map(|(p, &n)| (p, n)) + .collect(); + + if !unclaimed.is_empty() || !nonexistent_claims.is_empty() || !double_claimed.is_empty() { + panic!( + "loc_by_part is not exhaustive/disjoint over this binary's Round 2 sources:\n \ + unclaimed files (exist on disk, no ReportPart claims them): {unclaimed:?}\n \ + claimed files that do not exist on disk: {nonexistent_claims:?}\n \ + files claimed by more than one ReportPart entry: {double_claimed:?}" + ); + } +} + +fn main() -> Result<()> { + let spike_root = spike_root(); + println!("== Packet 2B-C2: C2 (vello) against Round 2's five text checks =="); + println!("spike root: {}", spike_root.display()); + + // ---- Load the candidate-neutral apparatus (fixtures, hit-test probes, + // reference rasters + D4 regions) — Packet 2A/2B-0, frozen, read-only. ---- + let inputs = round2_candidatekit::load_all(&spike_root).map_err(|e| anyhow!(e))?; + println!( + "loaded {} fixtures, {} reference rasters, {} hit-test probes", + inputs.fixtures.fixtures.len(), + inputs.reference.len(), + inputs + .hittest_probes + .fixtures + .iter() + .map(|f| f.probes.len()) + .sum::() + ); + + // ---- Resolve the declared face chain (pin 9): neutral geometry/data — + // "faces come from round2_textkit::faces" (task instructions). ---- + let resolved_faces = resolve_declared_chain(); + let mut faces: Vec = Vec::with_capacity(resolved_faces.len()); + for r in resolved_faces { + match r { + FaceResolution::Loaded(lf) => faces.push(lf), + FaceResolution::Missing { path } => { + // pin 14: an absent required face is an environment absence, + // never a candidate failure — this run cannot proceed at all. + anyhow::bail!( + "NOT RUN: declared face missing at {} — every check in this round requires \ + the pin-9 declared chain (environment absence, not a candidate defect)", + path.display() + ); + } + } + } + println!("resolved {} declared faces", faces.len()); + + let digest = round2_textkit::output::expected_artifact_digest().to_string(); + + // ---- Checks 1, 2 (rendering half), 4's data: render every fixture + // offscreen and diff against the frozen reference raster. ---- + let mut gpu = render::init_gpu()?; + println!( + "GPU: {} ({}), Vulkan", + gpu.adapter_name, gpu.adapter_device_type + ); + + let mut diffs: BTreeMap = BTreeMap::new(); + let mut per_fixture_diffs: BTreeMap = BTreeMap::new(); + let mut resolved_by_id: BTreeMap<&str, &SpikeResolvedText> = BTreeMap::new(); + for f in &inputs.fixtures.fixtures { + resolved_by_id.insert(f.id.as_str(), &f.resolved); + } + + for f in &inputs.fixtures.fixtures { + let candidate_rgba = render::render_fixture(&mut gpu, &f.resolved, &faces)?; + let reference = inputs + .reference + .get(&f.id) + .ok_or_else(|| anyhow!("{}: no reference fixture loaded", f.id))?; + let report = round2_diff::diff( + &reference.reference_rgba, + &candidate_rgba, + round2_candidatekit::inputs::WIDTH, + round2_candidatekit::inputs::HEIGHT, + &reference.regions, + ) + .map_err(|e| anyhow!("{}: diff failed: {e}", f.id))?; + println!( + "{}: d1={} d2={:.4}% d3={:?} d4_worst={:?} pass={}", + f.id, + report.d1_pixels_outside_band_differing, + report.d2_relative_delta * 100.0, + report.d3_delta, + report + .d4_worst + .as_ref() + .map(|w| (w.label.clone(), w.relative_delta)), + report.pass() + ); + per_fixture_diffs.insert(f.id.clone(), DiffReportRecord::from(&report)); + diffs.insert(f.id.clone(), report); + } + + let check1 = check1_outcome(&diffs)?; + let check2 = check2_outcome(&resolved_by_id, &diffs)?; + let check3 = CheckOutcome::not_run(round2_candidatekit::scoring::CHECK_3_RULING) + .map_err(|e| anyhow!(e))?; + let (check4, hittest_probe_results) = check4_outcome(&inputs.hittest_probes, &resolved_by_id)?; + let supplementary_f_d_bidi = supplementary_f_d_outcome( + resolved_by_id["F-D"], + diffs.get("F-D").expect("F-D diff computed"), + )?; + + println!("check1 (faithful consumption): {check1:?}"); + println!("check2 (fallback, forced): {check2:?}"); + println!("check3 (bidi): {check3:?}"); + println!( + "check4 (hit testing): {check4:?} ({} probes total)", + hittest_probe_results.len() + ); + println!("supplementary F-D bidi: {supplementary_f_d_bidi:?}"); + + // ---- Check 5 (accessibility, disqualifying): a real window on the + // AT-SPI bus, scored by the committed out-of-process verifier. ---- + let mut fixture_texts: [String; 5] = Default::default(); + for (slot, id) in a11y_subprocess::FIXTURE_ORDER.iter().enumerate() { + fixture_texts[slot] = resolved_by_id[*id].text.clone(); + } + let a11y_result = a11y_subprocess::run_a11y_round(&spike_root, &digest, fixture_texts)?; + let (check5, check5_bus_unreachable_evidence, a11y_evidence) = match a11y_result { + A11yRoundResult::Scored(evidence) => { + for e in &evidence { + println!( + "check5 {}: {} role={:?} name={:?} prohibited={:?} — {}", + e.fixture_id, + if e.pass { "PASS" } else { "FAIL" }, + e.observed_role, + e.observed_name, + e.prohibited_outcome, + e.notes + ); + } + let failing: Vec = evidence + .iter() + .filter(|e| !e.pass) + .map(|e| { + format!( + "{} ({})", + e.fixture_id, + e.prohibited_outcome + .clone() + .unwrap_or_else(|| "unnamed divergence".to_string()) + ) + }) + .collect(); + let outcome = if failing.is_empty() { + CheckOutcome::Pass + } else { + CheckOutcome::fail(format!("check 5 FAILed for: {}", failing.join("; "))) + .map_err(|e| anyhow!(e))? + }; + (outcome, None, evidence) + } + // F2: reaching this arm already means `a11y_subprocess::reduce_outcomes` + // found no FAIL anywhere among the fixtures it *did* score — a FAIL + // observed on any fixture, in either order relative to this + // BusUnreachable, is reported through the `Scored` arm above + // instead, never here. `partial_scored` still carries whatever was + // observed before/around the bus issue, so it is not discarded. + A11yRoundResult::BusUnreachable { + evidence: ev, + partial_scored, + } => { + println!( + "check5: NOT RUN — AT-SPI bus unreachable mid-run (probe: {}); {} fixture(s) \ + scored before the bus issue, none of them FAILed", + ev.probe_description, + partial_scored.len() + ); + let outcome = CheckOutcome::not_run(format!( + "AT-SPI bus unreachable mid-run: {}", + ev.probe_output + )) + .map_err(|e| anyhow!(e))?; + (outcome, Some(ev), partial_scored) + } + }; + println!("check5 (accessibility): {check5:?}"); + + // ---- Cost record. ---- + // F3/H1: every ReportPart maps to a disjoint set of whole files (see + // this file's own module doc comment for the table) -- printed here so + // the mapping itself, not just the resulting counts, can be checked, and + // checked exhaustively against what is actually on disk. + let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text"); + let this_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text.rs"); + let loc_by_part_files = loc_by_part_files(&src_dir, &this_file); + assert_loc_by_part_is_exhaustive(&loc_by_part_files, &src_dir, &this_file); + println!("loc_by_part file mapping (F3/H1 -- disjoint, exhaustive, per file):"); + let mut per_file_lines: Vec<(ReportPart, u64)> = Vec::with_capacity(loc_by_part_files.len()); + for (part, path) in &loc_by_part_files { + let lines = count_file_lines(path); + println!(" {part:?} <- {} ({lines} lines)", path.display()); + per_file_lines.push((part.clone(), lines)); + } + // J1: the *serialized* loc_by_part is one aggregated row per ReportPart + // -- the printed per-file mapping above stays as the auditable detail, + // but the report itself must not carry two rows for the same part + // (this file used to, for FixtureAndReportPlumbing, once it gained a + // second contributing file in H1's split) forcing every reader to know + // to sum them. + let loc_by_part = aggregate_loc_by_part(&per_file_lines); + println!("loc_by_part serialized rows (J1 -- one row per ReportPart):"); + for row in &loc_by_part { + println!(" {:?}: {} lines", row.part, row.lines); + } + + 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: "the shared fixture/oracle loader and report shape both Round 2 \ + candidates report through (neutrality boundary)." + .to_string(), + }, + DependencyDelta { + name: "round2-diff".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "the bounded visual differential (D1-D4) check 1 is scored against." + .to_string(), + }, + DependencyDelta { + name: "round2-textkit".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "SpikeResolvedText and the declared-face loader/hasher (pin 9); the \ + neutral staff->device transform (hittest::to_device)." + .to_string(), + }, + DependencyDelta { + name: "ttf-parser".to_string(), + version: "0.25.1".to_string(), + reason: "candidate-owned outline extraction from the resolved face into a kurbo \ + BezPath (task instructions) — not reused from round2-svgref, whose output is \ + an SVG path string for the reference emitter, not kurbo geometry." + .to_string(), + }, + DependencyDelta { + name: "serde_json".to_string(), + version: "1.0.151".to_string(), + reason: "serializes CandidateReport to JSON and parses a11y-verifier/verify.py's \ + --json output." + .to_string(), + }, + DependencyDelta { + name: "winit".to_string(), + version: "0.30.13".to_string(), + reason: "check 5 needs a real window on the AT-SPI bus; matches probe-vello's \ + Round 0 pin exactly." + .to_string(), + }, + DependencyDelta { + name: "accesskit".to_string(), + version: "0.24.1".to_string(), + reason: "the accessibility node/tree types this candidate builds by hand (vello \ + ships no accessibility layer)." + .to_string(), + }, + DependencyDelta { + name: "accesskit_winit".to_string(), + version: "0.33.2".to_string(), + reason: "the manual winit<->accesskit bridge; matches probe-vello's Round 0 pin \ + exactly." + .to_string(), + }, + ], + adapters: vec![ + AdapterStatus::Implemented { + platform: "accesskit-0.24".to_string(), + notes: "the in-process accessibility tree this binary constructs by hand \ + (Role::Window root, Role::Paragraph children) — the same tree every platform \ + bridge below is built from. vello ships no accessibility layer, so accesskit \ + was absent from this crate's Round 1 dependency graph at c20bc93 and is \ + declared here directly." + .to_string(), + integration_ownership: IntegrationOwnership::CandidateOwned, + }, + AdapterStatus::Implemented { + platform: "at-spi2".to_string(), + notes: format!( + "the round's own platform: a live window pushed through accesskit_winit \ + 0.33 -> accesskit_unix 0.22.1, read back out-of-process by \ + a11y-verifier/verify.py (an AT-SPI2 client via gi.repository.Atspi) for all \ + five fixtures. See {} in this run's report.", + "a11y_evidence" + ), + integration_ownership: IntegrationOwnership::CandidateOwned, + }, + AdapterStatus::NotBuilt { + platform: "aria".to_string(), + reason: "no wasm/web accesskit embedding target in this spike.".to_string(), + }, + AdapterStatus::NotBuilt { + platform: "macos-nsaccessibility".to_string(), + reason: "no macOS runner available to this spike.".to_string(), + }, + AdapterStatus::NotBuilt { + platform: "windows-uia".to_string(), + reason: "no Windows runner available to this spike.".to_string(), + }, + ], + integration_wiring: vec![ + "render.rs: a ttf_parser::OutlineBuilder implementation collecting one glyph's \ + outline directly into a device-space kurbo::BezPath (scale + y-flip), reused per \ + glyph across every segment/face in a fixture; one vello::Scene::fill(NonZero) \ + call per glyph, matching the reference emitter's fill rule." + .to_string(), + "hittest.rs: an independent point -> (byte offset, affinity) resolver — every \ + Downstream caret stop across every cluster, converted to device space via the \ + shared to_device transform, sorted by device x, floor-looked-up against the \ + query point. Does not call any of round2_textkit::hittest's probe-generation \ + functions." + .to_string(), + "a11y_tree.rs: builds the accessible node content by hand — one Role::Window root \ + plus five Role::Paragraph siblings (one per fixture), each carrying that \ + fixture's exact source string as its accessible name. No platform/adapter code." + .to_string(), + "a11y_wiring.rs (AccessibilityIntegrationWiring, product-side only, H1/J2): manual \ + accesskit_winit wiring behind a generic, verifier-agnostic surface -- \ + run_window owns the winit event loop, the accesskit_winit::Adapter's \ + lifecycle, window/bridge setup, and publishing a11y_tree.rs's tree, then blocks \ + until any caller delivers a T via FinishHandle::finish and returns it. Nothing \ + in this file names a verifier, a subprocess, or A11yRoundResult -- every line \ + here is what a real editor shipping this stack would keep." + .to_string(), + "a11y_subprocess.rs (FixtureAndReportPlumbing, shared spike/report harness, H1/J2): \ + drives a11y_wiring::run_window, spawning the worker thread whose only jobs are \ + calling run_all_fixtures and delivering its result -- the coordination that \ + exists solely because this spike scores itself out-of-process, moved out of the \ + wiring file entirely. run_all_fixtures itself runs a11y-verifier/verify.py once \ + per fixture as a subprocess, against a fresh run-unique --json output path \ + deleted immediately before each invocation (F1), cross-checking the exit status \ + against the json's own verdict and fixture_id fields before trusting either; \ + requires the exact 'CHECK5: NOT RUN' prefix AND one of verify.py's approved \ + environmental markers before treating exit 2 as bus-unreachable, checked as two \ + independent conditions (H2); reduces all five fixtures' outcomes to the round's \ + verdict only after every one has been attempted, so a FAIL found on any fixture \ + always wins over a BusUnreachable found on another regardless of ordering (F2). \ + This harness is common to both Round 2 candidates and is not part of either \ + one's accessibility stack." + .to_string(), + "c2_round2_text.rs (FixtureAndReportPlumbing): orchestration, plus the \ + check2/supplementary structural verifications (segment face-index and direction \ + checks against the resolved data actually drawn from) that \ + round2-candidatekit's neutrality boundary leaves to the candidate; the F3/H1 \ + loc_by_part file mapping and its exhaustiveness guard." + .to_string(), + ], + loc_by_part, + }; + + let report = CandidateReport { + candidate_id: "C2 vello 0.9 + kurbo (Round 2 text)".to_string(), + check1_faithful_consumption: check1, + check2_fallback: check2, + check3_bidi: check3, + check4_hit_testing: check4, + check5_accessibility: check5, + check5_bus_unreachable_evidence, + supplementary_f_d_bidi, + per_fixture_diffs, + hittest_probe_results, + a11y_evidence, + cost, + }; + + let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("round2_report.json"); + std::fs::write(&out_path, serde_json::to_string_pretty(&report)?)?; + println!("wrote report to {}", out_path.display()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use round2_textkit::identity::{ + SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent, + }; + use round2_textkit::types::{ + SpikeBoundingBox, SpikeClusterMap, SpikeGlyphStyle, SpikeLanguageTag, SpikePoint, + SpikePositionedGlyph, SpikeProvenance, SpikeScriptTag, SpikeShapedSegment, SpikeStaffSpace, + SpikeTextAlign, 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, + } + } + + fn seg( + face: Option, + glyphs: Vec, + source: std::ops::Range, + direction: SpikeTextDirection, + ) -> SpikeShapedSegment { + SpikeShapedSegment { + face, + glyphs, + source, + direction, + script: SpikeScriptTag("Latn".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + } + } + + fn one_glyph(id: u32) -> SpikePositionedGlyph { + SpikePositionedGlyph { + glyph_id: id, + offset: SpikePoint::new(0.0, 0.0), + transform: None, + } + } + + fn rt_with_segments(text: &str, segments: Vec) -> SpikeResolvedText { + SpikeResolvedText { + provenance: dummy_provenance(), + text: text.to_string(), + shaping: dummy_identity(), + segments, + clusters: SpikeClusterMap::default(), + bounds: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + reserved_box: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + origin: SpikePoint::new(0.0, 0.0), + align: SpikeTextAlign::Start, + style: SpikeGlyphStyle { rgba: 0 }, + layer: 0, + } + } + + /// A minimal, honest stand-in for F-B/F-C's real resolved shape (recipe + /// §4): F-C has an unresolved (face:None, glyphs:empty) second segment; + /// F-B's second segment resolved to face 1 — exactly the two structural + /// facts `check2_outcome` verifies. + fn base_fixtures() -> BTreeMap<&'static str, SpikeResolvedText> { + let mut m = BTreeMap::new(); + m.insert( + "F-C", + rt_with_segments( + "Coro \u{0627}", + vec![ + seg(Some(0), vec![one_glyph(1)], 0..5, SpikeTextDirection::Ltr), + seg(None, vec![], 5..7, SpikeTextDirection::Rtl), + ], + ), + ); + m.insert( + "F-B", + rt_with_segments( + "Coro \u{05D0}\u{05D1}\u{05D2}", + vec![ + seg(Some(0), vec![one_glyph(1)], 0..5, SpikeTextDirection::Ltr), + seg(Some(1), vec![one_glyph(2)], 5..11, SpikeTextDirection::Rtl), + ], + ), + ); + m + } + + fn as_refs<'a>( + m: &'a BTreeMap<&'static str, SpikeResolvedText>, + ) -> BTreeMap<&'a str, &'a SpikeResolvedText> { + m.iter().map(|(k, v)| (*k, v)).collect() + } + + fn passing_diff() -> DiffReport { + DiffReport { + width: 1, + height: 1, + band_pixel_count: 0, + d1_pixels_outside_band_differing: 0, + d1_pass: true, + reference_ink_mass: 0.0, + candidate_ink_mass: 0.0, + d2_relative_delta: 0.0, + d2_pass: true, + reference_centroid: None, + candidate_centroid: None, + d3_delta: None, + d3_pass: None, + in_band_max_abs_delta_luma: 0, + in_band_count_delta_gt_report_threshold: 0, + d4_regions: Vec::new(), + d4_pass: true, + d4_worst: None, + } + } + + fn passing_diffs() -> BTreeMap { + let mut d = BTreeMap::new(); + d.insert("F-B".to_string(), passing_diff()); + d.insert("F-C".to_string(), passing_diff()); + d + } + + #[test] + fn check2_passes_on_honest_data() { + let fixtures = base_fixtures(); + let refs = as_refs(&fixtures); + let outcome = check2_outcome(&refs, &passing_diffs()).unwrap(); + assert!(outcome.is_pass(), "{outcome:?}"); + } + + /// Mutation-first: if F-C's unresolved segment carries a glyph (a + /// substituted fallback/`.notdef`, exactly what check 2 forbids), the + /// check must FAIL naming that specifically — this is the guard that + /// would catch a regression where `render.rs` started drawing something + /// for an uncovered span. + #[test] + fn check2_fails_if_the_unresolved_segment_carries_a_glyph() { + let mut fixtures = base_fixtures(); + fixtures.get_mut("F-C").unwrap().segments[1] + .glyphs + .push(one_glyph(999)); + let refs = as_refs(&fixtures); + let outcome = check2_outcome(&refs, &passing_diffs()).unwrap(); + assert!( + matches!(&outcome, CheckOutcome::Fail(r) if r.contains("carries glyphs")), + "{outcome:?}" + ); + } + + /// Mutation-first: if F-B's Hebrew segment never actually traversed to + /// the second declared face (host substitution instead of fallback), + /// the check must FAIL. + #[test] + fn check2_fails_if_f_b_never_traversed_to_the_second_face() { + let mut fixtures = base_fixtures(); + fixtures.get_mut("F-B").unwrap().segments[1].face = Some(0); + let refs = as_refs(&fixtures); + let outcome = check2_outcome(&refs, &passing_diffs()).unwrap(); + assert!(outcome.is_fail(), "{outcome:?}"); + } + + #[test] + fn check1_fails_and_names_the_fixture_when_a_diff_fails() { + let mut diffs = passing_diffs(); + let mut broken = passing_diff(); + broken.d1_pass = false; + broken.d1_pixels_outside_band_differing = 42; + diffs.insert("F-C".to_string(), broken); + let outcome = check1_outcome(&diffs).unwrap(); + assert!( + matches!(&outcome, CheckOutcome::Fail(r) if r.contains("F-C") && r.contains("D1")), + "{outcome:?}" + ); + } + + #[test] + fn check1_passes_when_every_diff_passes() { + let outcome = check1_outcome(&passing_diffs()).unwrap(); + assert!(outcome.is_pass(), "{outcome:?}"); + } + + // ---- H1/F3: the loc_by_part mapping is exhaustive and disjoint over + // the real, on-disk Round 2 sources ---- + + fn real_src_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text") + } + + fn real_this_file() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text.rs") + } + + #[test] + fn the_real_mapping_is_exhaustive_and_disjoint() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let claimed = loc_by_part_files(&src_dir, &this_file); + // Must not panic. + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + /// Required kill: a mapping missing one real file (here, dropping + /// `hittest.rs`'s claim) must be rejected, naming it as unclaimed — + /// this is the exact failure mode C1 hit ("its mapping silently omitted + /// a file") that this guard exists to catch. + #[test] + #[should_panic(expected = "unclaimed files")] + fn a_mapping_missing_a_real_file_is_rejected() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let mut claimed = loc_by_part_files(&src_dir, &this_file); + claimed.retain(|(_, p)| p != &src_dir.join("hittest.rs")); + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + /// Required kill: a file claimed by two parts must be rejected, naming + /// it as double-claimed. + #[test] + #[should_panic(expected = "more than one ReportPart")] + fn a_file_claimed_twice_is_rejected() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let mut claimed = loc_by_part_files(&src_dir, &this_file); + claimed.push((ReportPart::TextRendering, src_dir.join("hittest.rs"))); + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + /// Required kill: a claim naming a file that does not exist on disk + /// must be rejected too — the guard is checking the *mapping*, not just + /// counting whatever the mapping happens to list. + #[test] + #[should_panic(expected = "do not exist on disk")] + fn a_claim_naming_a_nonexistent_file_is_rejected() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let mut claimed = loc_by_part_files(&src_dir, &this_file); + claimed.push(( + ReportPart::Other("bogus".to_string()), + src_dir.join("nope.rs"), + )); + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + // ---- J1: the serialized loc_by_part is one aggregated row per + // ReportPart, and each row's count is the true sum of its files ---- + + /// Required kill, half 1: no `ReportPart` appears twice in the + /// aggregated output, even though the input names + /// `FixtureAndReportPlumbing` twice (two files). + #[test] + fn aggregate_loc_by_part_has_no_duplicate_parts() { + let per_file = vec![ + (ReportPart::TextRendering, 342), + (ReportPart::HitTestResolution, 254), + (ReportPart::FixtureAndReportPlumbing, 736), + (ReportPart::FixtureAndReportPlumbing, 1024), + ]; + let aggregated = aggregate_loc_by_part(&per_file); + let mut seen: Vec = Vec::new(); + for row in &aggregated { + assert!( + !seen.contains(&row.part), + "{:?} appears twice in {aggregated:?}", + row.part + ); + seen.push(row.part.clone()); + } + } + + /// Required kill, half 2 — **this is the one that matters most**: each + /// row's line count is the true sum of every file mapped to it, not + /// (for example) the last file's count with earlier ones silently + /// dropped. A uniqueness check alone would pass a buggy aggregator that + /// kept only the last-seen file per part; this catches that directly. + #[test] + fn aggregate_loc_by_part_sums_are_correct_not_just_unique() { + let per_file = vec![ + (ReportPart::TextRendering, 342), + (ReportPart::HitTestResolution, 254), + (ReportPart::FixtureAndReportPlumbing, 736), + (ReportPart::FixtureAndReportPlumbing, 1024), + ]; + let aggregated = aggregate_loc_by_part(&per_file); + let find = |part: &ReportPart| aggregated.iter().find(|r| &r.part == part).unwrap(); + assert_eq!(find(&ReportPart::TextRendering).lines, 342); + assert_eq!(find(&ReportPart::HitTestResolution).lines, 254); + assert_eq!( + find(&ReportPart::FixtureAndReportPlumbing).lines, + 736 + 1024, + "must be the SUM of both contributing files, not just one of them" + ); + } + + /// The same two properties, grounded against the real on-disk mapping + /// rather than synthetic data — end-to-end proof that what `main` will + /// actually serialize is exhaustive, disjoint, and correctly summed. + #[test] + fn the_real_aggregation_has_no_duplicate_parts_and_correct_sums() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let files = loc_by_part_files(&src_dir, &this_file); + let per_file: Vec<(ReportPart, u64)> = files + .iter() + .map(|(p, path)| (p.clone(), count_file_lines(path))) + .collect(); + let aggregated = aggregate_loc_by_part(&per_file); + + let mut seen: Vec = Vec::new(); + for row in &aggregated { + assert!(!seen.contains(&row.part), "{:?} appears twice", row.part); + seen.push(row.part.clone()); + } + + let mut expected_by_part: Vec<(ReportPart, u64)> = Vec::new(); + for (part, path) in &files { + let lines = count_file_lines(path); + match expected_by_part.iter_mut().find(|(p, _)| p == part) { + Some((_, total)) => *total += lines, + None => expected_by_part.push((part.clone(), lines)), + } + } + for (part, expected) in &expected_by_part { + let row = aggregated.iter().find(|r| &r.part == part).unwrap(); + assert_eq!(row.lines, *expected, "{part:?}"); + } + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs new file mode 100644 index 0000000..8e6bc10 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs @@ -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>`): 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), + BusUnreachable { + evidence: BusUnreachableEvidence, + partial_scored: Vec, + }, +} + +/// 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, + 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 { + 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 { + 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) -> A11yRoundResult { + let mut scored = Vec::new(); + let mut bus_unreachable: Option = 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 { + 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`: 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 { + 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"), + } + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs new file mode 100644 index 0000000..dbc2bff --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs @@ -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")); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs new file mode 100644 index 0000000..6efa027 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs @@ -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 { + 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 From for AppEvent { + 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 { + proxy: EventLoopProxy>, +} + +impl FinishHandle { + pub fn finish(&self, value: T) { + let _ = self.proxy.send_event(AppEvent::Finish(value)); + } +} + +struct A11yApp { + proxy: EventLoopProxy>, + fixture_texts: [String; 5], + window: Option>, + adapter: Option, + on_tree_published: Option) + Send>>, + result: Option, +} + +impl ApplicationHandler> for A11yApp { + 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) { + 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( + fixture_texts: [String; 5], + on_tree_published: impl FnOnce(FinishHandle) + Send + 'static, +) -> Result { + let event_loop = EventLoop::>::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") + }) +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs new file mode 100644 index 0000000..c2c91f8 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs @@ -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 { + let mut stops: Vec = 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); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs new file mode 100644 index 0000000..19848b0 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs @@ -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 +//! `` 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 { + 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 { + 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 { + 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> { + 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> { + 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) +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml b/spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml new file mode 100644 index 0000000..97afa95 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml @@ -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" diff --git a/spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json b/spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json new file mode 100644 index 0000000..b5f1ab5 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json @@ -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é" + ] + } + } + ] +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs b/spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs new file mode 100644 index 0000000..a27a0b4 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs @@ -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)" + } + ); + } +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs b/spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs new file mode 100644 index 0000000..3de019c --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs @@ -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")); + } +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/src/lib.rs b/spikes/editor-toolkit/round2-a11y-oracle/src/lib.rs new file mode 100644 index 0000000..acd6e29 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/src/lib.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, + pub prohibited_roles: Vec, + /// 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, + /// 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>, + /// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub visual_order_name_hex: Option, +} + +/// 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, +} + +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 { + 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 { + 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> { + let mut owner_of: BTreeMap = BTreeMap::new(); + let mut grouped: BTreeMap> = 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 { + let resolved = resolve_declared_chain(); + let mut loaded: Vec = 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 = 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 = 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 = round2_textkit::a11y::ACCEPTED_ROLE_TABLE + .iter() + .find(|(p, _)| *p == PLATFORM) + .unwrap() + .1 + .iter() + .map(|s| s.to_string()) + .collect(); + let expected_prohibited: Vec = 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::() + .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()]) + ); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/Cargo.toml b/spikes/editor-toolkit/round2-candidatekit/Cargo.toml new file mode 100644 index 0000000..900dd04 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/Cargo.toml @@ -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" } diff --git a/spikes/editor-toolkit/round2-candidatekit/src/inputs.rs b/spikes/editor-toolkit/round2-candidatekit/src/inputs.rs new file mode 100644 index 0000000..b471ac7 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/inputs.rs @@ -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 `.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 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, + pub regions: Vec, + 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, +} + +/// 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/.rgba`, +/// `round2-reference/output/.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. +/// - `.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. +/// - `.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 { + 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 = 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 = 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 { + 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: `.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::>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("smuggled_field"), "{err}"); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/src/lib.rs b/spikes/editor-toolkit/round2-candidatekit/src/lib.rs new file mode 100644 index 0000000..b18617d --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/lib.rs @@ -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, +}; diff --git a/spikes/editor-toolkit/round2-candidatekit/src/outcome.rs b/spikes/editor-toolkit/round2-candidatekit/src/outcome.rs new file mode 100644 index 0000000..faea637 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/outcome.rs @@ -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) -> Result { + 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) -> Result { + 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 for CheckOutcome { + type Error = String; + + fn try_from(wire: CheckOutcomeWire) -> Result { + 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(deserializer: D) -> Result + 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::(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::(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::(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::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown variant"), "{err}"); + assert!(err.contains("Passed"), "{err}"); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/src/report.rs b/spikes/editor-toolkit/round2-candidatekit/src/report.rs new file mode 100644 index 0000000..1410ff5 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/report.rs @@ -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, + pub in_band_max_abs_delta_luma: u8, + pub in_band_count_delta_gt_report_threshold: u64, + pub d4_regions: Vec, + pub d4_pass: bool, + pub d4_worst: Option, + /// [`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, + pub observed_name_bytes_hex: Option, + pub observed_role: Option, + /// One of `round2_textkit::a11y::PROHIBITED_OUTCOMES`, or `None` if no + /// prohibited outcome applies. + pub prohibited_outcome: Option, + 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) -> Result { + 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(deserializer: D) -> Result + 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) -> Result { + 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 for IntegrationOwnership { + type Error = String; + + fn try_from(wire: IntegrationOwnershipWire) -> Result { + match wire { + IntegrationOwnershipWire::Inherited { provider } => { + Ok(IntegrationOwnership::Inherited { provider }) + } + IntegrationOwnershipWire::CandidateOwned => Ok(IntegrationOwnership::CandidateOwned), + } + } +} + +impl<'de> Deserialize<'de> for IntegrationOwnership { + fn deserialize(deserializer: D) -> Result + 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, + /// 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, + /// Free-text bullets describing integration/wiring the candidate wrote + /// itself — glue code, not vendored or generated. + pub integration_wiring: Vec, + pub loc_by_part: Vec, +} + +/// 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, + + /// 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, + pub hittest_probe_results: Vec, + pub a11y_evidence: Vec, + pub cost: CostRecord, +} + +#[cfg(test)] +mod tests { + use super::*; + use round2_diff::GlyphRegion; + + fn solid(width: u32, height: u32, rgb: [u8; 3]) -> Vec { + 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::(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::(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::(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::(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::(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::(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::(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); + } + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/src/scoring.rs b/spikes/editor-toolkit/round2-candidatekit/src/scoring.rs new file mode 100644 index 0000000..0769b4f --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/scoring.rs @@ -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); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs b/spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs new file mode 100644 index 0000000..5897d33 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs @@ -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 { + 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 { + 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()]); +}