@mcptoolshop/accessibility-suite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/.github/workflows/ci.yml +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -0
  4. package/docs/prov-spec/.github/workflows/ci.yml +68 -0
  5. package/docs/prov-spec/CHANGELOG.md +69 -0
  6. package/docs/prov-spec/CODE_OF_CONDUCT.md +129 -0
  7. package/docs/prov-spec/CONFORMANCE_LEVELS.md +223 -0
  8. package/docs/prov-spec/CONTRIBUTING.md +145 -0
  9. package/docs/prov-spec/IMPLEMENTER_CHECKLIST.md +137 -0
  10. package/docs/prov-spec/LICENSE +21 -0
  11. package/docs/prov-spec/PRESS_RELEASE.md +74 -0
  12. package/docs/prov-spec/README.md +182 -0
  13. package/docs/prov-spec/SETUP.md +135 -0
  14. package/docs/prov-spec/WHY.md +86 -0
  15. package/docs/prov-spec/examples/artifact.example.json +14 -0
  16. package/docs/prov-spec/examples/artifact.ref.example.json +9 -0
  17. package/docs/prov-spec/examples/evidence.example.json +6 -0
  18. package/docs/prov-spec/examples/mcp.envelope.example.json +97 -0
  19. package/docs/prov-spec/examples/mcp.request.example.json +28 -0
  20. package/docs/prov-spec/examples/prov.record.example.json +35 -0
  21. package/docs/prov-spec/interop/PROOF_NODE_ENGINE.md +114 -0
  22. package/docs/prov-spec/spec/MCP_COMPATIBILITY.md +241 -0
  23. package/docs/prov-spec/spec/PROV_METHODS_CATALOG.md +142 -0
  24. package/docs/prov-spec/spec/PROV_METHODS_SPEC.md +397 -0
  25. package/docs/prov-spec/spec/methods.json +213 -0
  26. package/docs/prov-spec/spec/schemas/artifact.ref.schema.v0.1.json +58 -0
  27. package/docs/prov-spec/spec/schemas/artifact.schema.v0.1.json +61 -0
  28. package/docs/prov-spec/spec/schemas/assist.request.schema.v0.1.json +52 -0
  29. package/docs/prov-spec/spec/schemas/assist.response.schema.v0.1.json +70 -0
  30. package/docs/prov-spec/spec/schemas/cli.error.schema.v0.1.json +78 -0
  31. package/docs/prov-spec/spec/schemas/evidence.schema.v0.1.json +37 -0
  32. package/docs/prov-spec/spec/schemas/mcp.envelope.schema.v0.1.json +141 -0
  33. package/docs/prov-spec/spec/schemas/mcp.request.schema.v0.1.json +79 -0
  34. package/docs/prov-spec/spec/schemas/methods.schema.json +93 -0
  35. package/docs/prov-spec/spec/schemas/prov-capabilities.schema.json +122 -0
  36. package/docs/prov-spec/spec/schemas/prov.record.schema.v0.1.json +133 -0
  37. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/expected.json +4 -0
  38. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/input.json +1 -0
  39. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/double_wrapped.json +14 -0
  40. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/wrong_schema_version.json +11 -0
  41. package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/expected.json +24 -0
  42. package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/input.json +8 -0
  43. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/expected.json +7 -0
  44. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/input.json +1 -0
  45. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/non_hex_chars.json +16 -0
  46. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/uppercase_hex.json +16 -0
  47. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/wrong_length.json +16 -0
  48. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/hyphen_separator.json +8 -0
  49. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/reserved_namespace.json +8 -0
  50. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/starts_with_digit.json +8 -0
  51. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/uppercase.json +8 -0
  52. package/docs/prov-spec/spec/vectors/method_id_syntax/positive/valid_ids.json +18 -0
  53. package/docs/prov-spec/tools/python/prov_validator.py +428 -0
  54. package/examples/a11y-demo-site/.github/workflows/a11y-artifacts.yml +81 -0
  55. package/examples/a11y-demo-site/.github/workflows/a11y.yml +34 -0
  56. package/examples/a11y-demo-site/CODE_OF_CONDUCT.md +129 -0
  57. package/examples/a11y-demo-site/CONTRIBUTING.md +83 -0
  58. package/examples/a11y-demo-site/LICENSE +21 -0
  59. package/examples/a11y-demo-site/README.md +155 -0
  60. package/examples/a11y-demo-site/html/contact.html +15 -0
  61. package/examples/a11y-demo-site/html/index.html +20 -0
  62. package/examples/a11y-demo-site/scripts/a11y.sh +20 -0
  63. package/package.json +26 -0
  64. package/src/a11y-assist/.github/workflows/publish.yml +52 -0
  65. package/src/a11y-assist/.github/workflows/test.yml +30 -0
  66. package/src/a11y-assist/A11Y_ASSIST_TEST_COVERAGE_REQUIREMENTS.md +104 -0
  67. package/src/a11y-assist/CODE_OF_CONDUCT.md +129 -0
  68. package/src/a11y-assist/CONTRIBUTING.md +98 -0
  69. package/src/a11y-assist/ENGINE.md +363 -0
  70. package/src/a11y-assist/LICENSE +21 -0
  71. package/src/a11y-assist/PRESS_RELEASE.md +71 -0
  72. package/src/a11y-assist/QUICKSTART.md +101 -0
  73. package/src/a11y-assist/README.md +192 -0
  74. package/src/a11y-assist/RELEASE_NOTES.md +319 -0
  75. package/src/a11y-assist/a11y_assist/__init__.py +3 -0
  76. package/src/a11y-assist/a11y_assist/cli.py +599 -0
  77. package/src/a11y-assist/a11y_assist/from_cli_error.py +149 -0
  78. package/src/a11y-assist/a11y_assist/guard.py +444 -0
  79. package/src/a11y-assist/a11y_assist/ingest.py +407 -0
  80. package/src/a11y-assist/a11y_assist/methods.py +137 -0
  81. package/src/a11y-assist/a11y_assist/parse_raw.py +71 -0
  82. package/src/a11y-assist/a11y_assist/profiles/__init__.py +29 -0
  83. package/src/a11y-assist/a11y_assist/profiles/cognitive_load.py +245 -0
  84. package/src/a11y-assist/a11y_assist/profiles/cognitive_load_render.py +86 -0
  85. package/src/a11y-assist/a11y_assist/profiles/dyslexia.py +144 -0
  86. package/src/a11y-assist/a11y_assist/profiles/dyslexia_render.py +77 -0
  87. package/src/a11y-assist/a11y_assist/profiles/plain_language.py +119 -0
  88. package/src/a11y-assist/a11y_assist/profiles/plain_language_render.py +66 -0
  89. package/src/a11y-assist/a11y_assist/profiles/screen_reader.py +348 -0
  90. package/src/a11y-assist/a11y_assist/profiles/screen_reader_render.py +89 -0
  91. package/src/a11y-assist/a11y_assist/render.py +95 -0
  92. package/src/a11y-assist/a11y_assist/schemas/assist.request.schema.v0.1.json +52 -0
  93. package/src/a11y-assist/a11y_assist/schemas/assist.response.schema.v0.1.json +70 -0
  94. package/src/a11y-assist/a11y_assist/schemas/cli.error.schema.v0.1.json +78 -0
  95. package/src/a11y-assist/a11y_assist/storage.py +31 -0
  96. package/src/a11y-assist/pyproject.toml +60 -0
  97. package/src/a11y-assist/tests/__init__.py +1 -0
  98. package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_high.json +18 -0
  99. package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_medium.json +16 -0
  100. package/src/a11y-assist/tests/fixtures/base_inputs/raw_text_low.txt +3 -0
  101. package/src/a11y-assist/tests/fixtures/cli_error_good.json +9 -0
  102. package/src/a11y-assist/tests/fixtures/cli_error_missing_id.json +7 -0
  103. package/src/a11y-assist/tests/fixtures/cli_error_string_format.json +7 -0
  104. package/src/a11y-assist/tests/fixtures/expected/cognitive_load_high.txt +20 -0
  105. package/src/a11y-assist/tests/fixtures/expected/dyslexia_high.txt +20 -0
  106. package/src/a11y-assist/tests/fixtures/expected/lowvision_high.txt +18 -0
  107. package/src/a11y-assist/tests/fixtures/expected/plain_language_high.txt +14 -0
  108. package/src/a11y-assist/tests/fixtures/expected/screen_reader_high.txt +19 -0
  109. package/src/a11y-assist/tests/fixtures/golden_screen_reader_cli_error.txt +16 -0
  110. package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_no_id.txt +14 -0
  111. package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_with_id.txt +14 -0
  112. package/src/a11y-assist/tests/fixtures/raw_good.txt +11 -0
  113. package/src/a11y-assist/tests/fixtures/raw_no_id.txt +2 -0
  114. package/src/a11y-assist/tests/test_cognitive_load.py +469 -0
  115. package/src/a11y-assist/tests/test_dyslexia.py +337 -0
  116. package/src/a11y-assist/tests/test_explain.py +74 -0
  117. package/src/a11y-assist/tests/test_golden.py +127 -0
  118. package/src/a11y-assist/tests/test_guard.py +819 -0
  119. package/src/a11y-assist/tests/test_guard_integration.py +457 -0
  120. package/src/a11y-assist/tests/test_ingest.py +311 -0
  121. package/src/a11y-assist/tests/test_methods_metadata.py +236 -0
  122. package/src/a11y-assist/tests/test_plain_language.py +348 -0
  123. package/src/a11y-assist/tests/test_render.py +117 -0
  124. package/src/a11y-assist/tests/test_screen_reader.py +703 -0
  125. package/src/a11y-assist/tests/test_storage_last.py +61 -0
  126. package/src/a11y-assist/tests/test_triage.py +86 -0
  127. package/src/a11y-ci/.github/workflows/ci.yml +43 -0
  128. package/src/a11y-ci/.github/workflows/test.yml +30 -0
  129. package/src/a11y-ci/A11Y_CI_TEST_COVERAGE_REQUIREMENTS.md +94 -0
  130. package/src/a11y-ci/CODE_OF_CONDUCT.md +129 -0
  131. package/src/a11y-ci/CONTRIBUTING.md +142 -0
  132. package/src/a11y-ci/LICENSE +21 -0
  133. package/src/a11y-ci/README.md +105 -0
  134. package/src/a11y-ci/a11y_ci/__init__.py +3 -0
  135. package/src/a11y-ci/a11y_ci/allowlist.py +83 -0
  136. package/src/a11y-ci/a11y_ci/cli.py +145 -0
  137. package/src/a11y-ci/a11y_ci/gate.py +131 -0
  138. package/src/a11y-ci/a11y_ci/render.py +48 -0
  139. package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +24 -0
  140. package/src/a11y-ci/a11y_ci/scorecard.py +99 -0
  141. package/src/a11y-ci/npm/package.json +35 -0
  142. package/src/a11y-ci/pyproject.toml +64 -0
  143. package/src/a11y-ci/tests/__init__.py +1 -0
  144. package/src/a11y-ci/tests/fixtures/allowlist_expired.json +10 -0
  145. package/src/a11y-ci/tests/fixtures/allowlist_ok.json +10 -0
  146. package/src/a11y-ci/tests/fixtures/baseline_ok.json +7 -0
  147. package/src/a11y-ci/tests/fixtures/current_fail.json +6 -0
  148. package/src/a11y-ci/tests/fixtures/current_ok.json +6 -0
  149. package/src/a11y-ci/tests/fixtures/current_regresses.json +7 -0
  150. package/src/a11y-ci/tests/test_gate.py +134 -0
  151. package/src/a11y-evidence-engine/.github/workflows/ci.yml +53 -0
  152. package/src/a11y-evidence-engine/CODE_OF_CONDUCT.md +129 -0
  153. package/src/a11y-evidence-engine/CONTRIBUTING.md +128 -0
  154. package/src/a11y-evidence-engine/LICENSE +21 -0
  155. package/src/a11y-evidence-engine/README.md +71 -0
  156. package/src/a11y-evidence-engine/bin/a11y-engine.js +11 -0
  157. package/src/a11y-evidence-engine/fixtures/bad/button-no-name.html +30 -0
  158. package/src/a11y-evidence-engine/fixtures/bad/img-missing-alt.html +19 -0
  159. package/src/a11y-evidence-engine/fixtures/bad/input-missing-label.html +26 -0
  160. package/src/a11y-evidence-engine/fixtures/bad/missing-lang.html +11 -0
  161. package/src/a11y-evidence-engine/fixtures/good/index.html +29 -0
  162. package/src/a11y-evidence-engine/package-lock.json +109 -0
  163. package/src/a11y-evidence-engine/package.json +45 -0
  164. package/src/a11y-evidence-engine/src/cli.js +74 -0
  165. package/src/a11y-evidence-engine/src/evidence/canonicalize.js +52 -0
  166. package/src/a11y-evidence-engine/src/evidence/json_pointer.js +34 -0
  167. package/src/a11y-evidence-engine/src/evidence/prov_emit.js +153 -0
  168. package/src/a11y-evidence-engine/src/fswalk.js +56 -0
  169. package/src/a11y-evidence-engine/src/html_parse.js +117 -0
  170. package/src/a11y-evidence-engine/src/ids.js +53 -0
  171. package/src/a11y-evidence-engine/src/rules/document_missing_lang.js +50 -0
  172. package/src/a11y-evidence-engine/src/rules/form_control_missing_label.js +105 -0
  173. package/src/a11y-evidence-engine/src/rules/img_missing_alt.js +77 -0
  174. package/src/a11y-evidence-engine/src/rules/index.js +37 -0
  175. package/src/a11y-evidence-engine/src/rules/interactive_missing_name.js +129 -0
  176. package/src/a11y-evidence-engine/src/scan.js +128 -0
  177. package/src/a11y-evidence-engine/test/scan.test.js +149 -0
  178. package/src/a11y-evidence-engine/test/vectors.test.js +200 -0
  179. package/src/a11y-lint/.github/workflows/ci.yml +46 -0
  180. package/src/a11y-lint/.github/workflows/test.yml +34 -0
  181. package/src/a11y-lint/CODE_OF_CONDUCT.md +129 -0
  182. package/src/a11y-lint/CONTRIBUTING.md +70 -0
  183. package/src/a11y-lint/GOVERNANCE.md +57 -0
  184. package/src/a11y-lint/LICENSE +21 -0
  185. package/src/a11y-lint/PRESS_RELEASE.md +50 -0
  186. package/src/a11y-lint/README.md +276 -0
  187. package/src/a11y-lint/RELEASE_NOTES.md +57 -0
  188. package/src/a11y-lint/RELEASING.md +57 -0
  189. package/src/a11y-lint/a11y_lint/__init__.py +64 -0
  190. package/src/a11y-lint/a11y_lint/cli.py +319 -0
  191. package/src/a11y-lint/a11y_lint/errors.py +252 -0
  192. package/src/a11y-lint/a11y_lint/render.py +293 -0
  193. package/src/a11y-lint/a11y_lint/report_md.py +289 -0
  194. package/src/a11y-lint/a11y_lint/scan_cli_text.py +434 -0
  195. package/src/a11y-lint/a11y_lint/schemas/cli.error.schema.v0.1.json +83 -0
  196. package/src/a11y-lint/a11y_lint/scorecard.py +244 -0
  197. package/src/a11y-lint/a11y_lint/validate.py +225 -0
  198. package/src/a11y-lint/pyproject.toml +75 -0
  199. package/src/a11y-lint/tests/__init__.py +1 -0
  200. package/src/a11y-lint/tests/test_cli.py +200 -0
  201. package/src/a11y-lint/tests/test_errors.py +188 -0
  202. package/src/a11y-lint/tests/test_render.py +202 -0
  203. package/src/a11y-lint/tests/test_report_md.py +188 -0
  204. package/src/a11y-lint/tests/test_scan_cli_text.py +290 -0
  205. package/src/a11y-lint/tests/test_scorecard.py +195 -0
  206. package/src/a11y-lint/tests/test_validate.py +257 -0
  207. package/src/a11y-mcp-tools/.github/workflows/ci.yml +53 -0
  208. package/src/a11y-mcp-tools/CODE_OF_CONDUCT.md +129 -0
  209. package/src/a11y-mcp-tools/CONTRIBUTING.md +136 -0
  210. package/src/a11y-mcp-tools/LICENSE +21 -0
  211. package/src/a11y-mcp-tools/PROV_METHODS_CATALOG.md +104 -0
  212. package/src/a11y-mcp-tools/README.md +168 -0
  213. package/src/a11y-mcp-tools/bin/cli.js +452 -0
  214. package/src/a11y-mcp-tools/bin/server.js +244 -0
  215. package/src/a11y-mcp-tools/fixtures/requests/a11y.diagnose.ok.json +27 -0
  216. package/src/a11y-mcp-tools/fixtures/requests/a11y.evidence.ok.json +25 -0
  217. package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.ok.json +139 -0
  218. package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.provenance_fail.json +13 -0
  219. package/src/a11y-mcp-tools/fixtures/responses/a11y.evidence.ok.json +88 -0
  220. package/src/a11y-mcp-tools/package-lock.json +189 -0
  221. package/src/a11y-mcp-tools/package.json +49 -0
  222. package/src/a11y-mcp-tools/src/envelope.js +197 -0
  223. package/src/a11y-mcp-tools/src/index.js +9 -0
  224. package/src/a11y-mcp-tools/src/schemas/artifact.js +85 -0
  225. package/src/a11y-mcp-tools/src/schemas/diagnosis.schema.v0.1.json +137 -0
  226. package/src/a11y-mcp-tools/src/schemas/envelope.schema.v0.1.json +108 -0
  227. package/src/a11y-mcp-tools/src/schemas/evidence.bundle.schema.v0.1.json +129 -0
  228. package/src/a11y-mcp-tools/src/schemas/evidence.js +97 -0
  229. package/src/a11y-mcp-tools/src/schemas/index.js +11 -0
  230. package/src/a11y-mcp-tools/src/schemas/provenance.js +140 -0
  231. package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.request.schema.v0.1.json +77 -0
  232. package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.response.schema.v0.1.json +50 -0
  233. package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.request.schema.v0.1.json +120 -0
  234. package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.response.schema.v0.1.json +50 -0
  235. package/src/a11y-mcp-tools/src/tools/diagnose.js +597 -0
  236. package/src/a11y-mcp-tools/src/tools/evidence.js +481 -0
  237. package/src/a11y-mcp-tools/src/tools/index.js +10 -0
  238. package/src/a11y-mcp-tools/test/contract.test.mjs +154 -0
  239. package/src/a11y-mcp-tools/test/diagnose.test.js +485 -0
  240. package/src/a11y-mcp-tools/test/evidence.test.js +183 -0
  241. package/src/a11y-mcp-tools/test/schema.test.js +327 -0
@@ -0,0 +1,703 @@
1
+ """Tests for screen-reader profile transformation and rendering.
2
+
3
+ These tests enforce the invariants:
4
+ 1. No invented facts - only rephrases existing content
5
+ 2. No invented commands - SAFE commands must be verbatim from input
6
+ 3. SAFE-only remains absolute
7
+ 4. Additive behavior - doesn't rewrite original output
8
+ 5. Deterministic - no randomness, no network calls
9
+ 6. No meaning in punctuation/formatting alone
10
+ 7. No "visual navigation" references
11
+ 8. No parentheticals as meaning carriers
12
+ """
13
+
14
+ import pytest
15
+
16
+ from a11y_assist.profiles.screen_reader import (
17
+ MAX_NOTE_LENGTH,
18
+ MAX_STEP_LENGTH,
19
+ MAX_STEPS_DEFAULT,
20
+ MAX_STEPS_LOW,
21
+ _cap_length,
22
+ _expand_abbreviations,
23
+ _one_sentence,
24
+ _remove_parentheticals,
25
+ _remove_visual_references,
26
+ _replace_symbols,
27
+ _strip_boilerplate,
28
+ apply_screen_reader,
29
+ generate_summary,
30
+ normalize_safest_step,
31
+ normalize_step,
32
+ reduce_notes,
33
+ reduce_plan,
34
+ select_safe_command,
35
+ )
36
+ from a11y_assist.profiles.screen_reader_render import render_screen_reader
37
+ from a11y_assist.render import AssistResult
38
+
39
+
40
+ class TestProfileInvariants:
41
+ """A) Profile invariants - anchored_id, no new commands, no invented IDs."""
42
+
43
+ def test_anchored_id_preserved(self):
44
+ """Output anchored_id is identical to input anchored_id."""
45
+ result = AssistResult(
46
+ anchored_id="PAY.EXPORT.SFTP.AUTH",
47
+ confidence="High",
48
+ safest_next_step="Check credentials.",
49
+ plan=["Step 1"],
50
+ next_safe_commands=["cmd --dry-run"],
51
+ notes=[],
52
+ )
53
+ transformed = apply_screen_reader(result)
54
+ assert transformed.anchored_id == result.anchored_id
55
+
56
+ def test_none_anchored_id_preserved(self):
57
+ """None anchored_id stays None."""
58
+ result = AssistResult(
59
+ anchored_id=None,
60
+ confidence="Low",
61
+ safest_next_step="Try again.",
62
+ plan=["Step 1"],
63
+ next_safe_commands=[],
64
+ notes=[],
65
+ )
66
+ transformed = apply_screen_reader(result)
67
+ assert transformed.anchored_id is None
68
+
69
+ def test_no_new_commands_added(self):
70
+ """No new commands appear that weren't in input."""
71
+ result = AssistResult(
72
+ anchored_id="TEST.ID",
73
+ confidence="High",
74
+ safest_next_step="Do something.",
75
+ plan=["Step 1"],
76
+ next_safe_commands=["original-cmd --dry-run"],
77
+ notes=[],
78
+ )
79
+ transformed = apply_screen_reader(result)
80
+ # At most 1 command
81
+ assert len(transformed.next_safe_commands) <= 1
82
+ # Any command must derive from original (minus $ prefix)
83
+ if transformed.next_safe_commands:
84
+ cmd = transformed.next_safe_commands[0]
85
+ # Should match original or be original without $ prefix
86
+ assert cmd in result.next_safe_commands or f"$ {cmd}" in result.next_safe_commands
87
+
88
+ def test_no_invented_ids(self):
89
+ """No new IDs are invented."""
90
+ result = AssistResult(
91
+ anchored_id="ORIG.ID",
92
+ confidence="High",
93
+ safest_next_step="Check.",
94
+ plan=["Step 1"],
95
+ next_safe_commands=[],
96
+ notes=[],
97
+ )
98
+ transformed = apply_screen_reader(result)
99
+ # anchored_id must be exactly the same
100
+ assert transformed.anchored_id == "ORIG.ID"
101
+
102
+ def test_low_confidence_no_commands(self):
103
+ """Low confidence results in no command section."""
104
+ result = AssistResult(
105
+ anchored_id=None,
106
+ confidence="Low",
107
+ safest_next_step="Try again.",
108
+ plan=["Step 1"],
109
+ next_safe_commands=["cmd --dry-run", "cmd --validate"],
110
+ notes=[],
111
+ )
112
+ transformed = apply_screen_reader(result)
113
+ assert transformed.next_safe_commands == []
114
+
115
+
116
+ class TestAudioSpecificConstraints:
117
+ """B) Audio-specific constraints - no parentheticals, no visual refs, Step N: format."""
118
+
119
+ def test_no_parentheticals_in_output(self):
120
+ """Output contains no ( or ) or [ or ]."""
121
+ result = AssistResult(
122
+ anchored_id="TEST.ID",
123
+ confidence="High",
124
+ safest_next_step="Check the config (usually in /etc).",
125
+ plan=[
126
+ "First check [see docs]",
127
+ "Then run (optional)",
128
+ "Finally verify [important]",
129
+ ],
130
+ next_safe_commands=["cmd --dry-run"],
131
+ notes=["Note with (parenthetical) content"],
132
+ )
133
+ transformed = apply_screen_reader(result)
134
+ output = render_screen_reader(transformed)
135
+
136
+ # Check no parenthetical characters
137
+ assert "(" not in output, f"Found ( in output: {output}"
138
+ assert ")" not in output, f"Found ) in output: {output}"
139
+ assert "[" not in output, f"Found [ in output: {output}"
140
+ assert "]" not in output, f"Found ] in output: {output}"
141
+
142
+ def test_no_visual_navigation_references(self):
143
+ """Output contains no 'see above/below/left/right/arrow'."""
144
+ result = AssistResult(
145
+ anchored_id="TEST.ID",
146
+ confidence="High",
147
+ safest_next_step="See above for details.",
148
+ plan=[
149
+ "Check the error above",
150
+ "Look at the output below",
151
+ "Click the left arrow",
152
+ "Move right to continue",
153
+ ],
154
+ next_safe_commands=[],
155
+ notes=["See above for more info"],
156
+ )
157
+ transformed = apply_screen_reader(result)
158
+ output = render_screen_reader(transformed).lower()
159
+
160
+ assert "see above" not in output
161
+ assert "see below" not in output
162
+ assert "above" not in output or "above" in output.split("step")[-1] # Allow in step numbers context
163
+ assert "below" not in output
164
+ assert "left" not in output
165
+ assert "right" not in output
166
+ assert "arrow" not in output
167
+
168
+ def test_steps_use_step_n_format(self):
169
+ """Each step starts with Step N:."""
170
+ result = AssistResult(
171
+ anchored_id="TEST.ID",
172
+ confidence="High",
173
+ safest_next_step="Do something.",
174
+ plan=["First step", "Second step", "Third step"],
175
+ next_safe_commands=[],
176
+ notes=[],
177
+ )
178
+ transformed = apply_screen_reader(result)
179
+ output = render_screen_reader(transformed)
180
+
181
+ assert "Step 1:" in output
182
+ assert "Step 2:" in output
183
+ assert "Step 3:" in output
184
+
185
+ def test_steps_end_with_period(self):
186
+ """Each step ends with a period."""
187
+ result = AssistResult(
188
+ anchored_id="TEST.ID",
189
+ confidence="High",
190
+ safest_next_step="Do something.",
191
+ plan=["First step", "Second step no period"],
192
+ next_safe_commands=[],
193
+ notes=[],
194
+ )
195
+ transformed = apply_screen_reader(result)
196
+
197
+ for step in transformed.plan:
198
+ assert step.endswith("."), f"Step does not end with period: {step}"
199
+
200
+ def test_steps_length_capped(self):
201
+ """Steps are <= MAX_STEP_LENGTH chars (including ellipsis if truncated)."""
202
+ long_step = "A" * 200
203
+ result = AssistResult(
204
+ anchored_id="TEST.ID",
205
+ confidence="High",
206
+ safest_next_step="Do something.",
207
+ plan=[long_step],
208
+ next_safe_commands=[],
209
+ notes=[],
210
+ )
211
+ transformed = apply_screen_reader(result)
212
+
213
+ for step in transformed.plan:
214
+ assert len(step) <= MAX_STEP_LENGTH + 1, f"Step too long: {len(step)}"
215
+
216
+
217
+ class TestDeterminism:
218
+ """C) Determinism tests - same input always produces same output."""
219
+
220
+ def test_same_input_same_output(self):
221
+ """Same input always produces same output."""
222
+ result = AssistResult(
223
+ anchored_id="DET.TEST",
224
+ confidence="Medium",
225
+ safest_next_step="Check the logs (verbose mode).",
226
+ plan=[
227
+ "First check the config and restart",
228
+ "Then verify the connection",
229
+ "Finally update the cache",
230
+ "Extra step that may get dropped",
231
+ "Another extra step",
232
+ ],
233
+ next_safe_commands=["tool --dry-run", "tool --validate"],
234
+ notes=["Note with (parenthetical)", "Another note"],
235
+ )
236
+ # Run transformation multiple times
237
+ outputs = [apply_screen_reader(result) for _ in range(10)]
238
+ # All outputs should be identical
239
+ first = outputs[0]
240
+ for output in outputs[1:]:
241
+ assert output == first
242
+
243
+ def test_render_deterministic(self):
244
+ """Rendering is deterministic."""
245
+ result = AssistResult(
246
+ anchored_id="DET.RENDER",
247
+ confidence="High",
248
+ safest_next_step="Do something.",
249
+ plan=["Step 1", "Step 2"],
250
+ next_safe_commands=["cmd --dry-run"],
251
+ notes=["A note"],
252
+ )
253
+ outputs = [render_screen_reader(result) for _ in range(10)]
254
+ first = outputs[0]
255
+ for output in outputs[1:]:
256
+ assert output == first
257
+
258
+
259
+ class TestNormalizationHelpers:
260
+ """Tests for individual normalization functions."""
261
+
262
+ def test_strip_boilerplate_run(self):
263
+ """'Run:' prefix is stripped."""
264
+ assert _strip_boilerplate("Run: mytool") == "mytool"
265
+
266
+ def test_strip_boilerplate_rerun(self):
267
+ """'Re-run:' prefix is stripped."""
268
+ assert _strip_boilerplate("Re-run: command") == "command"
269
+
270
+ def test_strip_boilerplate_dollar_sign(self):
271
+ """'$ ' prefix is stripped."""
272
+ assert _strip_boilerplate("$ ls -la") == "ls -la"
273
+
274
+ def test_strip_boilerplate_next(self):
275
+ """'Next:' prefix is stripped."""
276
+ assert _strip_boilerplate("Next: do this") == "do this"
277
+
278
+ def test_remove_parentheticals_round(self):
279
+ """Round parentheses content is removed."""
280
+ assert _remove_parentheticals("Do thing (optional)") == "Do thing"
281
+
282
+ def test_remove_parentheticals_square(self):
283
+ """Square bracket content is removed."""
284
+ assert _remove_parentheticals("Run [see docs]") == "Run"
285
+
286
+ def test_remove_visual_references_see_above(self):
287
+ """'see above' is removed."""
288
+ result = _remove_visual_references("Check see above for info")
289
+ assert "above" not in result.lower()
290
+
291
+ def test_remove_visual_references_below(self):
292
+ """'below' is removed."""
293
+ result = _remove_visual_references("Look at the output below")
294
+ assert "below" not in result.lower()
295
+
296
+ def test_remove_visual_references_arrow(self):
297
+ """'arrow' is removed."""
298
+ result = _remove_visual_references("Click the arrow")
299
+ assert "arrow" not in result.lower()
300
+
301
+ def test_expand_abbreviations_cli(self):
302
+ """CLI expands to 'command line'."""
303
+ assert "command line" in _expand_abbreviations("Use the CLI tool")
304
+
305
+ def test_expand_abbreviations_id(self):
306
+ """ID expands to 'I D'."""
307
+ assert "I D" in _expand_abbreviations("Check the ID")
308
+
309
+ def test_expand_abbreviations_json(self):
310
+ """JSON expands to 'J S O N'."""
311
+ assert "J S O N" in _expand_abbreviations("Parse JSON")
312
+
313
+ def test_expand_abbreviations_sftp(self):
314
+ """SFTP expands to 'S F T P'."""
315
+ assert "S F T P" in _expand_abbreviations("Upload via SFTP")
316
+
317
+ def test_replace_symbols_arrow(self):
318
+ """-> replaces with 'to'."""
319
+ assert " to " in _replace_symbols("A -> B")
320
+
321
+ def test_replace_symbols_fat_arrow(self):
322
+ """=> replaces with 'to'."""
323
+ assert " to " in _replace_symbols("A => B")
324
+
325
+ def test_replace_symbols_ampersand(self):
326
+ """& replaces with 'and'."""
327
+ assert " and " in _replace_symbols("A & B")
328
+
329
+ def test_one_sentence_semicolon(self):
330
+ """Semicolon splits and keeps first."""
331
+ result = _one_sentence("First part; second part")
332
+ assert "second" not in result
333
+
334
+ def test_cap_length_adds_ellipsis(self):
335
+ """Long strings are capped with ellipsis."""
336
+ result = _cap_length("A" * 200, 100)
337
+ assert len(result) == 100
338
+ assert result.endswith("…")
339
+
340
+
341
+ class TestStepNormalization:
342
+ """Tests for full step normalization."""
343
+
344
+ def test_normalize_step_complete(self):
345
+ """Full normalization pipeline works."""
346
+ step = "Run: Check the CLI (see docs) and verify -> continue"
347
+ result = normalize_step(step)
348
+ # Should not have parenthetical
349
+ assert "(" not in result
350
+ assert ")" not in result
351
+ # Should have expanded CLI
352
+ assert "command line" in result
353
+ # Should have replaced arrow
354
+ assert "->" not in result
355
+ # Should end with period
356
+ assert result.endswith(".")
357
+
358
+
359
+ class TestPlanReduction:
360
+ """Tests for plan reduction."""
361
+
362
+ def test_high_confidence_max_5_steps(self):
363
+ """High confidence allows up to 5 steps."""
364
+ plan = ["A", "B", "C", "D", "E", "F"]
365
+ reduced = reduce_plan(plan, "High")
366
+ assert len(reduced) == MAX_STEPS_DEFAULT
367
+
368
+ def test_low_confidence_max_3_steps(self):
369
+ """Low confidence reduces to max 3 steps."""
370
+ plan = ["A", "B", "C", "D", "E"]
371
+ reduced = reduce_plan(plan, "Low")
372
+ assert len(reduced) == MAX_STEPS_LOW
373
+
374
+ def test_empty_plan_gets_fallback(self):
375
+ """Empty plan gets a fallback step."""
376
+ reduced = reduce_plan([], "High")
377
+ assert len(reduced) == 1
378
+ assert "Follow" in reduced[0]
379
+
380
+
381
+ class TestSafeCommandSelection:
382
+ """Tests for SAFE command selection."""
383
+
384
+ def test_returns_none_for_low_confidence(self):
385
+ """Low confidence returns None."""
386
+ assert select_safe_command(["cmd"], "Low") is None
387
+
388
+ def test_returns_none_for_empty_list(self):
389
+ """Empty list returns None."""
390
+ assert select_safe_command([], "High") is None
391
+
392
+ def test_returns_first_command_for_high(self):
393
+ """High confidence returns first command."""
394
+ result = select_safe_command(["cmd1", "cmd2"], "High")
395
+ assert result == "cmd1"
396
+
397
+ def test_strips_dollar_prefix(self):
398
+ """$ prefix is stripped from command."""
399
+ result = select_safe_command(["$ cmd --dry-run"], "High")
400
+ assert result == "cmd --dry-run"
401
+ assert not result.startswith("$")
402
+
403
+
404
+ class TestNotesReduction:
405
+ """Tests for notes reduction."""
406
+
407
+ def test_notes_limited_to_three(self):
408
+ """Notes are limited to 3 max."""
409
+ notes = ["Note 1", "Note 2", "Note 3", "Note 4"]
410
+ reduced = reduce_notes(notes)
411
+ assert len(reduced) == 3
412
+
413
+ def test_notes_capped_at_length(self):
414
+ """Notes are capped at MAX_NOTE_LENGTH."""
415
+ notes = ["A" * 200]
416
+ reduced = reduce_notes(notes)
417
+ assert len(reduced[0]) <= MAX_NOTE_LENGTH
418
+
419
+
420
+ class TestRenderScreenReader:
421
+ """Tests for screen-reader renderer."""
422
+
423
+ def test_render_includes_profile_header(self):
424
+ """Output includes screen reader profile header."""
425
+ result = AssistResult(
426
+ anchored_id="TEST.ID",
427
+ confidence="High",
428
+ safest_next_step="Do the thing.",
429
+ plan=["Step 1"],
430
+ next_safe_commands=[],
431
+ notes=[],
432
+ )
433
+ output = render_screen_reader(result)
434
+ assert "ASSIST. Profile: Screen reader." in output
435
+
436
+ def test_render_includes_anchored_id_spelled(self):
437
+ """Anchored ID uses 'I D' spelling."""
438
+ result = AssistResult(
439
+ anchored_id="TEST.ID",
440
+ confidence="High",
441
+ safest_next_step="Do the thing.",
442
+ plan=["Step 1"],
443
+ next_safe_commands=[],
444
+ notes=[],
445
+ )
446
+ output = render_screen_reader(result)
447
+ assert "Anchored I D:" in output
448
+
449
+ def test_render_none_id_says_none(self):
450
+ """None anchored ID says 'none'."""
451
+ result = AssistResult(
452
+ anchored_id=None,
453
+ confidence="Low",
454
+ safest_next_step="Try again.",
455
+ plan=["Step 1"],
456
+ next_safe_commands=[],
457
+ notes=[],
458
+ )
459
+ output = render_screen_reader(result)
460
+ assert "Anchored I D: none." in output
461
+
462
+ def test_render_includes_summary(self):
463
+ """Output includes Summary section."""
464
+ result = AssistResult(
465
+ anchored_id="TEST.ID",
466
+ confidence="High",
467
+ safest_next_step="Do the thing.",
468
+ plan=["Step 1"],
469
+ next_safe_commands=[],
470
+ notes=[],
471
+ )
472
+ output = render_screen_reader(result)
473
+ assert "Summary:" in output
474
+
475
+ def test_render_includes_safest_next_step(self):
476
+ """Output includes Safest next step."""
477
+ result = AssistResult(
478
+ anchored_id="TEST.ID",
479
+ confidence="High",
480
+ safest_next_step="Check config first.",
481
+ plan=["Step 1"],
482
+ next_safe_commands=[],
483
+ notes=[],
484
+ )
485
+ output = render_screen_reader(result)
486
+ assert "Safest next step:" in output
487
+
488
+ def test_render_includes_steps_section(self):
489
+ """Output includes Steps section."""
490
+ result = AssistResult(
491
+ anchored_id="TEST.ID",
492
+ confidence="High",
493
+ safest_next_step="Do the thing.",
494
+ plan=["First step", "Second step"],
495
+ next_safe_commands=[],
496
+ notes=[],
497
+ )
498
+ output = render_screen_reader(result)
499
+ assert "Steps:" in output
500
+
501
+ def test_render_includes_safe_command(self):
502
+ """Output includes next safe command when present."""
503
+ result = AssistResult(
504
+ anchored_id="TEST.ID",
505
+ confidence="High",
506
+ safest_next_step="Do the thing.",
507
+ plan=["Step 1"],
508
+ next_safe_commands=["tool --dry-run"],
509
+ notes=[],
510
+ )
511
+ output = render_screen_reader(result)
512
+ assert "Next safe command:" in output
513
+ assert "tool --dry-run" in output
514
+
515
+ def test_render_no_safe_command_section_when_empty(self):
516
+ """No safe command section when no commands."""
517
+ result = AssistResult(
518
+ anchored_id="TEST.ID",
519
+ confidence="High",
520
+ safest_next_step="Do the thing.",
521
+ plan=["Step 1"],
522
+ next_safe_commands=[],
523
+ notes=[],
524
+ )
525
+ output = render_screen_reader(result)
526
+ assert "Next safe command:" not in output
527
+
528
+ def test_render_single_note_uses_note(self):
529
+ """Single note uses 'Note:' not 'Notes:'."""
530
+ result = AssistResult(
531
+ anchored_id="TEST.ID",
532
+ confidence="High",
533
+ safest_next_step="Do the thing.",
534
+ plan=["Step 1"],
535
+ next_safe_commands=[],
536
+ notes=["One note."],
537
+ )
538
+ output = render_screen_reader(result)
539
+ assert "Note:" in output
540
+ # Check it's not "Notes:"
541
+ assert output.count("Notes:") == 0
542
+
543
+ def test_render_multiple_notes_uses_notes(self):
544
+ """Multiple notes use 'Notes:'."""
545
+ result = AssistResult(
546
+ anchored_id="TEST.ID",
547
+ confidence="High",
548
+ safest_next_step="Do the thing.",
549
+ plan=["Step 1"],
550
+ next_safe_commands=[],
551
+ notes=["Note one.", "Note two."],
552
+ )
553
+ output = render_screen_reader(result)
554
+ assert "Notes:" in output
555
+
556
+
557
+ class TestGenerateSummary:
558
+ """Tests for summary generation."""
559
+
560
+ def test_summary_from_original_title(self):
561
+ """Summary extracts from 'Original title:' note."""
562
+ result = AssistResult(
563
+ anchored_id="TEST.ID",
564
+ confidence="High",
565
+ safest_next_step="Do the thing.",
566
+ plan=["Step 1"],
567
+ next_safe_commands=[],
568
+ notes=["Original title: Payment export failed"],
569
+ )
570
+ summary = generate_summary(result)
571
+ assert "Payment export failed" in summary
572
+
573
+ def test_summary_high_confidence_fallback(self):
574
+ """High confidence without title gets generic summary."""
575
+ result = AssistResult(
576
+ anchored_id="TEST.ID",
577
+ confidence="High",
578
+ safest_next_step="Do the thing.",
579
+ plan=["Step 1"],
580
+ next_safe_commands=[],
581
+ notes=[],
582
+ )
583
+ summary = generate_summary(result)
584
+ assert "structured error" in summary.lower()
585
+
586
+ def test_summary_low_confidence_fallback(self):
587
+ """Low confidence gets appropriate summary."""
588
+ result = AssistResult(
589
+ anchored_id=None,
590
+ confidence="Low",
591
+ safest_next_step="Do the thing.",
592
+ plan=["Step 1"],
593
+ next_safe_commands=[],
594
+ notes=[],
595
+ )
596
+ summary = generate_summary(result)
597
+ assert "identifier" in summary.lower() or "error" in summary.lower()
598
+
599
+
600
+ class TestGoldenOutput:
601
+ """C) Golden tests - exact output committed to repo, run in CI."""
602
+
603
+ def test_golden_cli_error_invariants(self):
604
+ """cli.error.v0.1 JSON produces consistent screen-reader output."""
605
+ # Test invariants without exact string matching (which varies by normalization)
606
+ from a11y_assist.from_cli_error import assist_from_cli_error, load_cli_error
607
+ import os
608
+
609
+ fixture_path = os.path.join(
610
+ os.path.dirname(__file__), "fixtures", "cli_error_good.json"
611
+ )
612
+ obj = load_cli_error(fixture_path)
613
+ result = assist_from_cli_error(obj)
614
+ transformed = apply_screen_reader(result)
615
+ output = render_screen_reader(transformed)
616
+
617
+ # Invariants
618
+ assert "ASSIST. Profile: Screen reader." in output
619
+ assert "Anchored I D: PAY.EXPORT.SFTP.AUTH." in output
620
+ assert "Confidence: High." in output
621
+ assert "Summary:" in output
622
+ assert "Safest next step:" in output
623
+ assert "Steps:" in output
624
+ assert "Step 1:" in output
625
+ # No parentheticals
626
+ assert "(" not in output
627
+ assert ")" not in output
628
+
629
+ def test_golden_raw_with_id_invariants(self):
630
+ """Raw text with ID produces consistent screen-reader output."""
631
+ from a11y_assist.parse_raw import parse_raw
632
+ import os
633
+
634
+ fixture_path = os.path.join(
635
+ os.path.dirname(__file__), "fixtures", "raw_good.txt"
636
+ )
637
+ with open(fixture_path) as f:
638
+ text = f.read()
639
+
640
+ err_id, status, blocks = parse_raw(text)
641
+ plan = blocks.get("Fix:", [])
642
+ result = AssistResult(
643
+ anchored_id=err_id,
644
+ confidence="Medium",
645
+ safest_next_step="Follow the tool's Fix steps.",
646
+ plan=plan,
647
+ next_safe_commands=[line for line in plan if "--dry-run" in line][:3],
648
+ notes=[],
649
+ )
650
+ transformed = apply_screen_reader(result)
651
+ output = render_screen_reader(transformed)
652
+
653
+ # Invariants
654
+ assert "ASSIST. Profile: Screen reader." in output
655
+ assert "Anchored I D: PAY.EXPORT.SFTP.AUTH." in output
656
+ assert "Confidence: Medium." in output
657
+ assert "Step 1:" in output
658
+ # No parentheticals
659
+ assert "(" not in output
660
+ assert ")" not in output
661
+
662
+ def test_golden_raw_no_id_invariants(self):
663
+ """Raw text without ID produces consistent screen-reader output."""
664
+ from a11y_assist.parse_raw import parse_raw
665
+ import os
666
+
667
+ fixture_path = os.path.join(
668
+ os.path.dirname(__file__), "fixtures", "raw_no_id.txt"
669
+ )
670
+ with open(fixture_path) as f:
671
+ text = f.read()
672
+
673
+ err_id, status, blocks = parse_raw(text)
674
+ result = AssistResult(
675
+ anchored_id=err_id,
676
+ confidence="Low",
677
+ safest_next_step="Follow the tool's Fix steps.",
678
+ plan=[
679
+ "Re-run the command with increased verbosity/logging.",
680
+ "Update the tool to emit (ID: ...) and What/Why/Fix blocks.",
681
+ "If this is your tool, adopt cli.error.v0.1 JSON output.",
682
+ ],
683
+ next_safe_commands=[],
684
+ notes=["No (ID: ...) found."],
685
+ )
686
+ transformed = apply_screen_reader(result)
687
+ output = render_screen_reader(transformed)
688
+
689
+ # Invariants
690
+ assert "ASSIST. Profile: Screen reader." in output
691
+ assert "Anchored I D: none." in output
692
+ assert "Confidence: Low." in output
693
+ # Low confidence = max 3 steps
694
+ assert "Step 1:" in output
695
+ assert "Step 2:" in output
696
+ assert "Step 3:" in output
697
+ # No Step 4 for low confidence
698
+ assert "Step 4:" not in output
699
+ # No SAFE command section for low confidence
700
+ assert "Next safe command:" not in output
701
+ # No parentheticals in output
702
+ assert "(" not in output
703
+ assert ")" not in output