1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { flatten } from "../src/pipeline/flatten.ts";
import { contentCoverage, MIN_CONTENT_COVERAGE } from "../src/pipeline/feedback.ts";
import { EDITOR_SYSTEM, READER_SYSTEM, listMarkerHalfEdit, listMarkers } from "../src/pipeline/review.ts";
// `flatten` has one invariant: it may reorganize text, but it may not LOSE any.
// Both of its consumers fail silently when it does.
//
// * The Reader reviews this view instead of the source images, so
// anything missing here cannot be reported as an issue โ the review loop has
// nothing to act on.
// * `contentCoverage` compares an agent's candidate output against an accepted
// fixture using these words. Text that never reaches the view is absent from
// BOTH sides, so a regression becomes unmeasurable and the gate scores it 1.0.
//
// The second is the dangerous one: the regression gate exists to stop an agent
// update from dropping content, and text `flatten` cannot see is exactly the
// content it cannot protect. That is why the assertions below are mostly about
// text survival rather than about exact formatting.
// Every word a screen reader would announce, derived independently of flatten.
//
// Text nodes are walked individually and joined with spaces: `body.textContent`
// concatenates without separators, which invents words like "failuresbody" and
// would make this baseline wrong rather than flatten wrong. Announced attribute
// values count as content too.
//
// ANNOUNCED is deliberately wider than the set of attributes `flatten` reads. When
// the two lists matched, this baseline shared the code's blind spot: a dropped
// `aria-label` could not fail any test here, because the baseline didn't expect it
// either. A baseline derived from what the code happens to look at is not
// independent of the code. It is derived from what a screen reader announces.
//
// `style`/`script` text is excluded for the same reason from the other direction:
// it is not announced, so treating it as expected content would require flatten to
// emit CSS.
const ANNOUNCED = ["alt", "placeholder", "value", "aria-label", "title"];
const NOT_ANNOUNCED = new Set(["STYLE", "SCRIPT", "TEMPLATE", "NOSCRIPT"]);
function announcedWords(html: string): Set<string> {
const dom = new JSDOM(`<!DOCTYPE html><body>${html}</body>`);
const doc = dom.window.document;
const parts: string[] = [];
const walk = (n: Node): void => {
if (n.nodeType === 3) {
parts.push(n.textContent ?? "");
return;
}
if (n.nodeType === 1 && NOT_ANNOUNCED.has((n as unknown as { tagName: string }).tagName)) return;
for (const c of Array.from(n.childNodes)) walk(c);
};
walk(doc.body);
for (const el of Array.from(doc.querySelectorAll(ANNOUNCED.map((a) => `[${a}]`).join(",")))) {
for (const a of ANNOUNCED) parts.push(el.getAttribute(a) ?? "");
}
dom.window.close();
return wordsOf(parts.join(" "));
}
const wordsOf = (s: string): Set<string> =>
new Set(
s
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 1),
);
// Role markers are bracketed and stripped before comparison, exactly as
// contentCoverage does it โ otherwise a marker would count as content.
const flattenedWords = (html: string): Set<string> => wordsOf(flatten(html).replace(/\[[^\]]*\]/g, " "));
function assertNoTextLost(html: string, label: string): void {
const expected = announcedWords(html);
const got = flattenedWords(html);
const missing = [...expected].filter((w) => !got.has(w));
assert.deepEqual(missing, [], `${label}: flatten dropped ${missing.length} word(s): ${missing.join(", ")}`);
}
const REPORT = `
<h1>Annual Accessibility Report</h1>
<p>Prepared by the <strong>Equalify</strong> team, <em>fiscal year 2026</em>.</p>
<h2>Findings</h2>
<ul>
<li>Contrast failures<ul><li>Body copy on tinted panels</li><li>Disabled button labels</li></ul></li>
<li>Missing form labels</li>
</ul>
<table>
<caption>Issues by severity</caption>
<thead><tr><th>Severity</th><th>Count</th><th>Owner</th></tr></thead>
<tbody>
<tr><td>Critical</td><td>12</td><td>Platform</td></tr>
<tr><td>Serious</td><td>34</td><td>Design</td></tr>
</tbody>
</table>
<blockquote><p>Remediation is scheduled for Q1.</p><footer>โ Programme office</footer></blockquote>
<form>
<label for="email">Work email</label>
<input id="email" type="email" placeholder="you@example.org">
<label for="notes">Notes</label>
<textarea id="notes">Existing draft text</textarea>
<label for="team">Team</label>
<select id="team"><option>Platform</option><option>Design</option></select>
</form>
<figure><img alt="Bar chart of issues by quarter"><figcaption>Quarterly trend</figcaption></figure>
<dl><dt>WCAG</dt><dd>Web Content Accessibility Guidelines</dd></dl>
<p>See the <a href="/appendix">appendix</a> for methodology.</p>`;
// --- the invariant ---
test("no announced text is lost, across the structures a real page contains", () => {
assertNoTextLost(REPORT, "full report");
for (const [label, html] of [
["table body", `<table><caption>Cap</caption><tr><th>Region</th></tr><tr><td>Northeast</td></tr></table>`],
["nested table", `<table><tr><td>Outer <table><tr><td>Inner cell</td></tr></table></td></tr></table>`],
["link inside heading", `<h3>Read the <a href="/x"><em>full</em> policy</a> now</h3>`],
["image inside link", `<a href="/home"><img alt="Equalify home"></a>`],
["image inside list item", `<li>Chart: <img alt="rising trend"></li>`],
["list inside blockquote", `<blockquote><p>Because:</p><ul><li>First</li></ul></blockquote>`],
["list inside a table cell", `<table><tr><td><ul><li>Alpha</li><li>Beta</li></ul></td></tr></table>`],
["label wrapping its field", `<label>Postcode <input value="E1 6AN"></label>`],
// Every field in the list above is a direct child of a block, which was the one
// path that read a field's attributes. These reach it through the inline path.
["input in a table cell", `<table><tr><th>Name</th><td><input value="Ada Lovelace"></td></tr></table>`],
["input under an inline wrapper", `<p>Name <span><input value="Ada Lovelace"></span></p>`],
["select in a table cell", `<table><tr><td><select><option>Platform</option></select></td></tr></table>`],
["colspan header", `<table><tr><th colspan="2">Fiscal year</th></tr><tr><td>A</td><td>B</td></tr></table>`],
["deeply nested inline", `<p>A <span>b <strong>c <em>d</em></strong></span> e</p>`],
["definition list", `<dl><dt>Term</dt><dd>Meaning</dd></dl>`],
["renumbered ordered list", `<ol start="2"><li>Alpha</li><li value="9">Beta</li></ol>`],
] as [string, string][]) {
assertNoTextLost(html, label);
}
});
test("a table's rows reach the flattened view, not just its caption", () => {
// The specific regression. `case "table"` used to emit the caption and return,
// so every row was invisible โ to the Reader and to the coverage gate.
const view = flatten(`
<table><caption>Revenue by region</caption>
<thead><tr><th>Region</th><th>Revenue</th></tr></thead>
<tbody><tr><td>Northeast</td><td>4,200,000</td></tr></tbody>
</table>`);
assert.match(view, /Revenue by region/);
for (const cell of ["Region", "Revenue", "Northeast", "4,200,000"]) {
assert.ok(view.includes(cell), `cell "${cell}" missing from:\n${view}`);
}
// Cells are announced per row, not merged into one run-on line.
assert.match(view, /\[Row\] Northeast \| 4,200,000/);
assert.match(view, /\[Header row\] Region \| Revenue/);
});
test("deleting a table's body is visible to the regression gate", () => {
// The reason this bug mattered. `contentCoverage` compares flattened words, so
// when flatten couldn't see rows, deleting every row of a table was a no-op to
// the gate โ it scored a content-destroying agent update as perfect.
const accepted = `<h2>Q3 Revenue</h2><table><caption>Revenue by region</caption>
<thead><tr><th>Region</th><th>Revenue</th></tr></thead>
<tbody><tr><td>Northeast</td><td>4200000</td></tr><tr><td>Midwest</td><td>3100000</td></tr></tbody></table>`;
const gutted = `<h2>Q3 Revenue</h2><table><caption>Revenue by region</caption></table>`;
const cov = contentCoverage(accepted, gutted);
assert.notEqual(cov, null, "the accepted text must be long enough to score, or the gate abstains");
assert.ok(
cov! < MIN_CONTENT_COVERAGE,
`coverage ${cov} should be below the ${MIN_CONTENT_COVERAGE} gate โ a gutted table must not pass`,
);
// And an unchanged document still scores perfectly, so the check above is not
// passing because coverage is broken in general.
assert.equal(contentCoverage(accepted, accepted), 1);
});
// --- announcement quality ---
//
// These are about the Reader's ability to spot a real accessibility problem. They
// assert behavior, not formatting, so they should survive reasonable rewording of
// the markers.
test("word boundaries survive nesting", () => {
// textContent concatenation used to produce "FruitApple" โ a word that is in
// neither the source nor the output, which pollutes coverage on both sides.
const view = flatten(`<ul><li>Fruit<ul><li>Apple</li></ul></li></ul>`);
assert.ok(!view.includes("FruitApple"), `words ran together:\n${view}`);
assert.match(view, /Fruit/);
assert.match(view, /Apple/);
});
test("reading order is preserved when a block interrupts inline text", () => {
// "Fruit" is announced before the nested list, not after it.
const view = flatten(`<ul><li>Fruit<ul><li>Apple</li></ul></li></ul>`);
assert.ok(view.indexOf("Fruit") < view.indexOf("Apple"), `out of order:\n${view}`);
const doc = flatten(REPORT).split("\n").join("|");
assert.ok(
doc.indexOf("Annual Accessibility Report") < doc.indexOf("Findings"),
"document order lost",
);
assert.ok(doc.indexOf("Findings") < doc.indexOf("Issues by severity"), "document order lost");
});
test("an image keeps its alt text even inside a link", () => {
// An <img> inside an <a> supplies the link's accessible name. Treating either
// as a leaf loses the other, and a missing alt is the single most common real
// finding โ the Reader has to be able to see it.
const view = flatten(`<a href="/home"><img alt="Equalify home"></a>`);
assert.match(view, /Link/);
assert.match(view, /Equalify home/);
assert.match(flatten(`<img src="x.png">`), /\[alt missing\]/, "a missing alt must be announced as missing");
});
test("headings keep their level", () => {
// Level is what makes a skipped-heading issue detectable at all.
for (const n of [1, 2, 3, 4, 5, 6]) {
assert.match(flatten(`<h${n}>Title</h${n}>`), new RegExp(`\\[Heading ${n}\\] Title`));
}
});
test("form fields announce their label, type and value", () => {
const view = flatten(
`<label for="e">Work email</label><input id="e" type="email" placeholder="you@example.org">`,
);
assert.match(view, /\[Label\] Work email/);
// The control's type is inside the marker: a screen reader announces it as the
// field's role, so it is an annotation rather than transcribed content.
assert.match(view, /\[Field input email\]/);
assert.match(view, /you@example\.org/, "an announced placeholder must survive");
// A select's options are content, not chrome.
const sel = flatten(`<select><option>Platform</option><option>Design</option></select>`);
assert.match(sel, /Platform/);
assert.match(sel, /Design/);
});
test("every role marker is bracketed", () => {
// contentCoverage strips `[...]` before comparing. An unbracketed annotation
// would be counted as a word the agent produced, diluting the ratio on both
// sides โ so the stripping and the markers are one contract.
const view = flatten(REPORT);
const stripped = view.replace(/\[[^\]]*\]/g, " ");
for (const noise of ["Heading", "List item", "Row", "Header row", "Field", "Label", "Caption", "Quote", "Term", "Definition", "Option", "Table", "Link", "Image"]) {
assert.ok(!stripped.includes(noise), `marker text "${noise}" leaked into content after stripping`);
}
// ...and the markers really are present before stripping, so the check above
// is not passing because nothing was emitted.
assert.match(view, /\[Heading 1\]/);
assert.match(view, /\[Row\]/);
});
test("nothing flatten adds itself survives the bracket strip", () => {
// The general form of the check above, and the one that matters: the version
// that only listed marker NAMES missed "(N rows, M columns)", "(empty)" and
// "(no caption)", whose words joined the compared sets and padded coverage.
//
// Every word in the output must trace back to the input. Anything left after
// stripping `[...]` that the source document does not contain is an annotation
// masquerading as content.
const cases = [
REPORT,
`<table><caption>Fees</caption><tr><th colspan="2">Both</th></tr><tr><td>A</td><td></td></tr></table>`,
`<table></table>`,
`<img src="x.png">`,
`<img src="x.png" alt="">`,
`<p>Plain paragraph</p>`,
`<select><option>Platform</option></select>`,
`<input type="email" placeholder="you@example.org" value="ada@example.org">`,
// A list ordinal is the newest annotation, and a number is the easiest kind to
// leak: `9` outside the brackets would be counted as a word the agent transcribed.
`<ol start="2"><li>Alpha</li><li value="9">Beta</li></ol>`,
];
for (const html of cases) {
const emitted = wordsOf(flatten(html).replace(/\[[^\]]*\]/g, " "));
const source = announcedWords(html);
const invented = [...emitted].filter((w) => !source.has(w));
assert.deepEqual(
invented,
[],
`flatten invented ${invented.length} word(s) outside brackets for ${html.slice(0, 60)}: ${invented.join(", ")}` +
`\n(they would be counted as content by contentCoverage)\n${flatten(html)}`,
);
}
});
test("the table summary does not pad a coverage comparison", () => {
// The concrete gate consequence of unbracketed annotations. `rows`, `columns`,
// `empty` and `caption` are reproduced free by any candidate that emits a table at
// all, so they were guaranteed hits on every table fixture โ enough to move a
// fixture that had lost a row from a true 0.833 to a reported 0.875, across the
// 0.85 gate. `MIN_COVERAGE_WORDS` is 8, so the shorter the fixture the more the
// padding dominates.
const accepted = `<table><caption>Membership fees by tier</caption>
<tr><th>Tier</th><th>Annual cost</th></tr>
<tr><td>Basic</td><td>Ninety</td></tr>
<tr><td>Standard</td><td>Fourteen</td></tr>
<tr><td>Premium</td><td>Twenty</td></tr></table>`;
const candidate = accepted.replace("<tr><td>Premium</td><td>Twenty</td></tr>", "");
const cov = contentCoverage(accepted, candidate);
assert.notEqual(cov, null, "the fixture must be long enough to score");
assert.ok(
cov! < MIN_CONTENT_COVERAGE,
`a fixture that lost a table row scored ${cov}, at or above the ${MIN_CONTENT_COVERAGE} gate`,
);
});
test("empty and degenerate input does not throw", () => {
for (const html of ["", " ", "<p></p>", "<table></table>", "<div><span></span></div>", "<ul></ul>", "<img>"]) {
assert.equal(typeof flatten(html), "string", `threw or returned non-string for ${JSON.stringify(html)}`);
}
assert.equal(flatten(""), "");
// An empty table still reports itself: a table with no rows is a finding, and
// silence would be indistinguishable from no table at all.
assert.match(flatten("<table></table>"), /\[Table\]/);
});
test("a table reports its shape, so a gutted one is visible without reading cells", () => {
// Row/column counts are what let the Reader (and a human reading the log) see
// "0 rows" on a table that should have data.
assert.match(flatten(`<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>`), /\[2 rows, 2 columns\]/);
assert.match(flatten(`<table><caption>Cap</caption></table>`), /\[0 rows, 0 columns\]/);
});
test("an empty cell is announced rather than silently collapsing the row", () => {
// Otherwise "A | | C" and "A | C" flatten identically, and a dropped cell โ a
// real extraction failure โ is invisible.
const view = flatten(`<table><tr><td>A</td><td></td><td>C</td></tr></table>`);
assert.match(view, /A \| \[empty\] \| C/);
});
test("a form field announces its value wherever it sits", () => {
// A field's text lives in its ATTRIBUTES, so any path that recurses into child
// nodes drops it โ `<input>` has no children at all. When only the block path
// handled fields, every field in a table cell (cells are announced through the
// inline path) and every field under an inline wrapper contributed nothing.
for (const [label, html] of [
["direct block child", `<form><input value="Ada Lovelace"></form>`],
["inside a table cell", `<table><tr><th>Name</th><td><input value="Ada Lovelace"></td></tr></table>`],
["under an inline wrapper", `<p>Name <span><input value="Ada Lovelace"></span></p>`],
["inside a link", `<a href="/x"><input value="Ada Lovelace"></a>`],
["textarea in a cell", `<table><tr><td><textarea>Ada Lovelace</textarea></td></tr></table>`],
["select in a cell", `<table><tr><td><select><option>Ada Lovelace</option></select></td></tr></table>`],
] as [string, string][]) {
const view = flatten(html);
assert.match(view, /\[Field (input|textarea|select)\]/, `${label}: no field marker in:\n${view}`);
assert.match(view, /Ada Lovelace/, `${label}: the field's text was dropped from:\n${view}`);
assertNoTextLost(html, `field ${label}`);
}
});
test("emptying every field of a form-as-table is visible to the regression gate", () => {
// The same failure this file exists to prevent, reached through the field path
// instead of the table path. EDITOR_SYSTEM names "the same content rendered as
// both a form and a table" as a case the pipeline produces, so this shape is not
// hypothetical.
const accepted = `<table><caption>Contact details</caption>
<tr><th>Full name</th><td><input value="Ada Lovelace"></td></tr>
<tr><th>Institution</th><td><input value="Cambridge University"></td></tr></table>`;
const gutted = accepted.replace(/value="[^"]*"/g, 'value=""');
const cov = contentCoverage(accepted, gutted);
assert.notEqual(cov, null, "the accepted text must be long enough to score");
assert.ok(cov! < MIN_CONTENT_COVERAGE, `every field value was emptied yet coverage was ${cov}`);
assert.equal(contentCoverage(accepted, accepted), 1);
});
test("a half-signed signature block keeps every transcribed value", () => {
// `agents/page.md` tells the page agent to render a signature block as one uniform
// form, with the fields that are already filled in on the source page as
// `<input readonly value="โฆ">` rather than as a <dd> (issue #67). That moves
// transcribed text out of the document's text nodes and into an attribute, which is
// the direction this file exists to police: a value the flattened view cannot see is
// absent from both sides of the gate, so emptying it would score 1.0.
const block = `<form>
<fieldset><legend>Contractor</legend>
<label for="c-sig">Signature</label>
<input id="c-sig" readonly value="Ada Lovelace" aria-describedby="c-mark">
<img id="c-mark" src="sig.png" alt="Handwritten signature reading Ada Lovelace">
<label for="c-date">Date</label>
<input id="c-date" readonly value="fourth of June">
</fieldset>
<fieldset><legend>Client</legend>
<label for="k-sig">Signature</label><input id="k-sig" aria-required="true">
<label for="k-date">Date</label><input id="k-date" aria-required="true">
</fieldset>
</form>`;
const view = flatten(block);
assertNoTextLost(block, "signature block");
// Both legends, so the two parties are distinguishable, and the signed party's
// values, which live only in an attribute.
for (const want of ["Contractor", "Client", "Ada Lovelace", "fourth of June"]) {
assert.ok(view.includes(want), `"${want}" was dropped from:\n${view}`);
}
// A field left blank for the reader still announces its label. Otherwise the
// Reader โ told that a field with nothing after its marker is a real defect โ
// cannot tell an unlabelled control from one waiting to be filled in.
assert.match(view, /\[Label\] Signature\n\[Field input\]$/m, `a blank field lost its label:\n${view}`);
// And emptying the signed party's values is visible to the regression gate, the
// same way it is for the form-as-table above.
const gutted = block.replace(/readonly value="[^"]*"/g, "readonly");
const cov = contentCoverage(block, gutted);
assert.notEqual(cov, null, "the block must be long enough to score");
assert.ok(cov! < 1, `a filled-in field was emptied yet coverage was ${cov}`);
});
test("a select keeps its field marker in the ordinary block position", () => {
// The marker used to fire only from the inline path, so it appeared when a select
// sat in a table cell and was missing in the common case โ leaving the Reader
// unable to tell a select from a bare run of [Option] lines, and so unable to see
// that the control has no accessible name.
for (const html of [
`<form><label for="t">Team</label><select id="t"><option>Platform</option></select></form>`,
`<p><select><option>Platform</option></select></p>`,
`<select><option>Platform</option></select>`,
]) {
const view = flatten(html);
assert.match(view, /\[Field select\]/, `no select marker in:\n${view}`);
assert.match(view, /Platform/);
}
});
test("a colspan cell does not make correct markup look broken", () => {
// The Reader is told a table that reports [0 rows] is a defect, and the Copy
// Editor may restructure table headers. Counting cells structurally reported a
// spanning header row as narrower than the table, which would have the editor
// rewrite an already-accessible table. Spanning headers are common in the scanned
// tabular documents Iris takes as input.
const view = flatten(`<table><caption>Budget</caption>
<tr><th colspan="3">Fiscal year 2026</th></tr>
<tr><th>Item</th><th>Q1</th><th>Q2</th></tr>
<tr><td>Training</td><td>10</td><td>12</td></tr></table>`);
assert.match(view, /\[3 rows, 3 columns\]/, `column count should measure columns, not cells:\n${view}`);
assert.match(view, /Fiscal year 2026 \[spans 3 columns\]/, `the span must be announced:\n${view}`);
// A malformed colspan must not corrupt the count.
assert.match(flatten(`<table><tr><td colspan="abc">A</td></tr></table>`), /\[1 rows, 1 columns\]/);
assert.match(flatten(`<table><tr><td colspan="-2">A</td></tr></table>`), /\[1 rows, 1 columns\]/);
});
test("a caption with block children does not run its words together", () => {
// The last `textContent` call in the file, and the same invented-word bug the rest
// of it was rewritten to remove. Worse than a plain drop: "feesapple" is in neither
// the accepted fixture nor the candidate, so it pollutes both compared word sets
// while "fees" and "apple" vanish from both.
const view = flatten(`<table><caption><p>Fees</p><p>Apple</p></caption><tr><td>A</td></tr></table>`);
assert.ok(!view.includes("FeesApple"), `caption words ran together:\n${view}`);
assert.match(view, /Fees/);
assert.match(view, /Apple/);
assertNoTextLost(`<table><caption><p>Fees</p><p>Apple</p></caption><tr><td>A</td></tr></table>`, "block caption");
});
test("an accessible name in an attribute is announced", () => {
// A field labelled only by `aria-label` is correct, axe-clean markup. Dropping the
// name hid real content from the gate AND made the Reader โ told a field with no
// announced name is a defect โ report a phantom issue on correct markup.
for (const [label, html, want] of [
["aria-label on a field", `<input type="text" aria-label="Work email" value="a@b.c">`, /Work email/],
["title on a link", `<a href="/x" title="Read more"></a>`, /Read more/],
["aria-label on a link", `<a href="/x"><span></span></a>`.replace("<a ", `<a aria-label="Skip to content" `), /Skip to content/],
["aria-label on an image with no alt", `<img src="x.png" aria-label="Bar chart">`, /Bar chart/],
["aria-label on a button", `<button aria-label="Close dialog"></button>`, /Close dialog/],
] as [string, string, RegExp][]) {
const view = flatten(html);
assert.match(view, want, `${label}: accessible name dropped from:\n${view}`);
assertNoTextLost(html, label);
}
// A visible name is not announced twice just because an attribute also exists.
const both = flatten(`<a href="/x" title="Home page">Home</a>`);
assert.equal(both.match(/Home/g)?.length, 1, `announced twice:\n${both}`);
});
test("a button or summary announces as a control, not as prose", () => {
// In the one view the Reader uses to judge control labelling, a button that
// flattens to bare text is indistinguishable from a paragraph โ and an icon-only
// button, the common accessible-name defect, flattened to nothing at all:
// invisible to review and unmeasurable by the gate.
assert.match(flatten(`<button type="submit">Send request</button>`), /\[Field button submit\] Send request/);
assert.match(flatten(`<details><summary>More</summary><p>Body</p></details>`), /\[Field summary\] More/);
const icon = flatten(`<button><img src="x.png"></button>`);
assert.match(icon, /\[Field button\]/, `an icon-only button vanished entirely:\n${icon}`);
});
test("a rowspan cell does not make correct markup look broken either", () => {
// Same phantom defect as colspan, one row later: a cell spanning rows leaves the
// next row with fewer cells than the table has columns.
const view = flatten(`<table><caption>Staff</caption>
<tr><th>Name</th><th>Role</th></tr>
<tr><td rowspan="2">Ada</td><td>Lead</td></tr>
<tr><td>Eng</td></tr></table>`);
assert.match(view, /Ada \[spans 2 rows\]/, `the row span must be announced:\n${view}`);
assert.match(flatten(`<table><tr><td rowspan="abc">A</td></tr></table>`), /^\[Table\][^\n]*\n\[Row\] A$/m);
});
test("style and script text is not treated as content", () => {
// CSS and JS are neither announced nor transcribed, so emitting them made them
// free hits in the coverage word sets โ an agent leaking style markup would have
// made the gate read as healthier.
const view = flatten(`<style>.a{color:red}</style><script>var x=1</script><p>Text</p>`);
assert.ok(!view.includes("color"), `stylesheet text leaked into the view:\n${view}`);
assert.ok(!view.includes("var x"), `script text leaked into the view:\n${view}`);
assert.match(view, /Text/);
});
test("a marker stays attached to its text when the text is in a block child", () => {
// `<li><p>x</p></li>` and `<label><div>x</div></label>` are ordinary shapes. A bare
// `[Heading 2]` or `[Label]` alone on a line reads to the Reader as an empty
// heading or an unlabelled control, and the prompt teaches it to treat empty
// structures as real problems.
assert.match(flatten(`<h2><div>Q3 Revenue</div></h2>`), /\[Heading 2\] Q3 Revenue/);
assert.match(flatten(`<li><p>First item</p></li>`), /\[List item\] First item/);
assert.match(flatten(`<label><div>Work email</div></label>`), /\[Label\] Work email/);
// Both markers survive when they nest.
assert.match(flatten(`<li><h3>Nested heading</h3></li>`), /\[List item\] \[Heading 3\] Nested heading/);
// And a marker still precedes content it cannot merge with, in reading order.
const t = flatten(`<li><table><tr><td>Cell</td></tr></table></li>`);
assert.ok(t.indexOf("[List item]") < t.indexOf("Cell"), `reading order lost:\n${t}`);
});
test("an ordered list's items are announced with their numbers", () => {
// The numbers are the whole point: an <ol> counts 1..n by itself whatever its items
// contain, so a list whose numbering was tidied โ the gap closed, the repeat dropped
// โ used to flatten identically to one that kept it. `[List item]` with no number is
// the one form of altered content the review loop could not see.
const plain = flatten(`<ol><li>Alpha</li><li>Beta</li></ol>`);
assert.match(plain, /\[List item 1\] Alpha/);
assert.match(plain, /\[List item 2\] Beta/);
// `start` carries a list that does not begin at 1, and `value` sets one item's number
// AND the count that follows it โ 1, 5, 6, as the HTML ordinal algorithm has it.
assert.match(flatten(`<ol start="5"><li>Fifth</li><li>Sixth</li></ol>`), /\[List item 5\] Fifth\n\[List item 6\] Sixth/);
const skipped = flatten(`<ol><li>One</li><li value="5">Five</li><li>Six</li></ol>`);
assert.match(skipped, /\[List item 1\] One\n\[List item 5\] Five\n\[List item 6\] Six/);
// A reversed list counts down from its own length, because that is what is
// announced; a countdown read out as 1, 2, 3 would be a wrong number, which is
// worse than no number.
assert.match(flatten(`<ol reversed><li>Second</li><li>First</li></ol>`), /\[List item 2\] Second\n\[List item 1\] First/);
assert.match(flatten(`<ol reversed start="10"><li>Ten</li><li>Nine</li></ol>`), /\[List item 10\] Ten\n\[List item 9\] Nine/);
// An unordered or definition list has no number to lose, so nothing is invented for
// it โ a number there would be one the document does not show.
const ul = flatten(`<ul><li>Alpha</li><li>Beta</li></ul>`);
assert.match(ul, /\[List item\] Alpha/);
assert.ok(!/\[List item \d/.test(ul), `an unordered list was numbered:\n${ul}`);
});
test("a list marked with letters or roman numerals is announced with the marker it renders", () => {
// #334. The count is a number in every case, but the marker a reader HEARS is that number
// rendered in the list's own style, and reading only the number announced `<ol type="a">` as
// 1, 2, 3 โ a marker the delivered document renders nowhere. 31 of the corpus's 3,591 parseable
// page replies carry `<ol type=โฆ>`, and the page agent is now asked for it by name, so a view
// that cannot see the attribute is a view that cannot check the rule.
assert.match(flatten(`<ol type="a"><li>Alpha</li><li>Beta</li></ol>`), /\[List item a\] Alpha\n\[List item b\] Beta/);
assert.match(flatten(`<ol type="A"><li>Alpha</li></ol>`), /\[List item A\] Alpha/);
assert.match(flatten(`<ol type="i"><li>One</li><li>Two</li><li>Three</li><li>Four</li></ol>`),
/\[List item i\] One\n\[List item ii\] Two\n\[List item iii\] Three\n\[List item iv\] Four/);
assert.match(flatten(`<ol type="I"><li>One</li></ol>`), /\[List item I\] One/);
// `type` is the list's, so it decides how EVERY count under it is rendered โ including one
// `value` sets. A browser marks this item "e", and announcing "5" would be the same defect
// pointing the other way: a marker no reader of that document hears.
assert.match(flatten(`<ol type="a"><li>First</li><li value="5">Fifth</li></ol>`),
/\[List item a\] First\n\[List item e\] Fifth/);
assert.match(flatten(`<ol type="i" start="4"><li>Fourth</li></ol>`), /\[List item iv\] Fourth/);
// Letters are bijective base-26, which is what CSS lower-alpha counts: z, then aa.
assert.match(flatten(`<ol type="a" start="26"><li>Z</li><li>AA</li></ol>`), /\[List item z\] Z\n\[List item aa\] AA/);
// A style that cannot represent the ordinal falls back to the decimal it was, as CSS does:
// neither letters nor roman numerals have a rendering for zero or a negative, and roman
// numerals stop at 3999. A `reversed` list counting past its start reaches the first case.
assert.match(flatten(`<ol type="a" reversed start="1"><li>One</li><li>Zero</li></ol>`),
/\[List item a\] One\n\[List item 0\] Zero/);
assert.match(flatten(`<ol type="i" start="4000"><li>Past</li></ol>`), /\[List item 4000\] Past/);
// An unknown type is ignored, the way the browser ignores it, rather than announced.
assert.match(flatten(`<ol type="x"><li>Alpha</li></ol>`), /\[List item 1\] Alpha/);
// `type` on a <ul> marks nothing here: an unordered list has no marker to lose, so the
// attribute must not turn one on.
const ul = flatten(`<ul type="a"><li>Alpha</li></ul>`);
assert.match(ul, /\[List item\] Alpha/);
assert.ok(!/\[List item \S/.test(ul), `an unordered list was marked:\n${ul}`);
});
test("a list item's number reaches it however the item is announced", () => {
// The marker travels down to a block child (`<li><p>x</p></li>`) and combines with
// that child's own marker, so the number has to survive both paths โ a `[List item]`
// that loses its number on the ordinary `<li><p>` shape would leave the gap open for
// most real markup.
assert.match(flatten(`<ol><li><p>First item</p></li><li><p>Second item</p></li></ol>`), /\[List item 1\] First item\n\[List item 2\] Second item/);
assert.match(flatten(`<ol start="3"><li><h3>Nested heading</h3></li></ol>`), /\[List item 3\] \[Heading 3\] Nested heading/);
const t = flatten(`<ol start="4"><li><table><tr><td>Cell</td></tr></table></li></ol>`);
assert.ok(t.indexOf("[List item 4]") < t.indexOf("Cell"), `reading order lost:\n${t}`);
// Each list counts its own items: a nested list restarts, and the outer one resumes
// where it left off rather than continuing from the child's count.
const nested = flatten(`<ol><li>Alpha<ol><li>Inner</li></ol></li><li>Beta</li></ol>`);
assert.match(nested, /\[List item 1\] Alpha\n\[List item 1\] Inner\n\[List item 2\] Beta/);
// Only `<li>` children advance the count, and only the ones this list owns.
assert.match(flatten(`<ol><p>Intro</p><li>Alpha</li></ol>`), /\[List item 1\] Alpha/);
// A number the browser would ignore leaves the count alone rather than emitting NaN.
assert.match(flatten(`<ol start="abc"><li value="x">Alpha</li><li>Beta</li></ol>`), /\[List item 1\] Alpha\n\[List item 2\] Beta/);
});
test("the number is inside the brackets, so it is visible to the Reader and not to coverage", () => {
// Both halves matter. The Reader has to see the number; `contentCoverage` must not,
// because an annotation outside the brackets is padding every candidate reproduces
// for free โ the same defect that once moved a gutted table from 0.833 to 0.875 and
// past the gate.
const faithful = `<h2>Parts list</h2><ol><li>Bean hopper</li><li value="5">Burr carrier ring</li><li>Grind adjuster knob</li></ol>`;
const tidied = `<h2>Parts list</h2><ol><li>Bean hopper</li><li>Burr carrier ring</li><li>Grind adjuster knob</li></ol>`;
assert.notEqual(flatten(faithful), flatten(tidied), "a renumbered list must not flatten identically to a faithful one");
// The words are the same on both sides, so the gate is unmoved: the numbering
// regression is the Reader's to catch, and this must not become a coverage failure
// that flags every list whose numbering merely differs.
assert.equal(contentCoverage(faithful, tidied), 1);
assert.equal(contentCoverage(tidied, faithful), 1);
// And no digit escapes into the compared word set.
const stripped = flatten(`<ol start="42"><li>Alpha</li></ol>`).replace(/\[[^\]]*\]/g, " ");
assert.ok(!/\d/.test(stripped), `a list ordinal leaked into content: ${stripped}`);
});
test("every marker the Reader prompt advertises is one flatten emits", () => {
// `[Option]` was documented and unreachable: options reach the inline path, where
// `option` is neither inline nor a field, so it recursed to bare text. A marker the
// prompt promises but the code never emits teaches the Reader to expect something
// that will not appear.
const view = flatten(`<select><option>Platform</option><option>Design</option></select>`);
assert.match(view, /\[Field select\] Platform, Design/);
assert.ok(!READER_SYSTEM.includes("[Option]"), "the prompt advertises a marker flatten never emits");
// The mirror of that failure: a marker the code emits and the prompt never names. An
// ordered list's items are numbered now, so the prompt has to say so โ otherwise the
// Reader is told everything in brackets is an annotation from a list that does not
// include this one.
assert.match(flatten(`<ol><li>Alpha</li></ol>`), /\[List item 1\]/);
assert.ok(READER_SYSTEM.includes("[List item N]"), "flatten emits a marker the prompt does not name");
// And the same mirror for #334's markers. A digit in that bracket is what the prompt used to
// promise exclusively โ "the number it is announced with" โ so a view that now emits a letter
// needs the prompt to have said a letter can appear, or the Reader reads `[List item a]` as a
// marker it was told is always a number and reports the document for it.
assert.match(flatten(`<ol type="a"><li>Alpha</li></ol>`), /\[List item a\]/);
assert.ok(READER_SYSTEM.includes("[List item a]"), "flatten emits a letter marker the prompt does not name");
assert.ok(/roman numerals from type="i"/.test(READER_SYSTEM), "the prompt does not name the roman markers flatten emits");
assert.match(flatten(`<ol type="i"><li>Alpha</li></ol>`), /\[List item i\]/);
// The prompt must not promise the number is the marker any more, since it is not.
assert.ok(
!/carries the number it is announced with/.test(READER_SYSTEM),
"the prompt still tells the Reader an ordered item's marker is a number",
);
// A report the Reader can act on has to say WHICH of the two copies goes, because both
// resolutions clear a double marker and one of them is wrong: deleting the list's `type` also
// clears `[List item a] (a)`, and leaves a list that prints 1, 2, 3 where the page printed
// letters โ a loss no gate here can see, since the marker is inside brackets. Pinned on the
// direction rather than the sentence: reword it freely, but it must still name the text.
assert.match(flatten(`<ol type="a"><li>(a) Estimating</li></ol>`), /\[List item a\] \(a\) Estimating/);
assert.ok(
/the copy that goes is the TEXT's/.test(READER_SYSTEM),
"the double-marker report does not say which of the two copies goes",
);
assert.ok(
!/worth reporting whichever of the two the page printed/.test(READER_SYSTEM),
"the double-marker report still leaves the two copies interchangeable",
);
// And the direction REVERSES on the shape the corpus actually has. A bare `<ol>` whose items
// transcribed their letters flattens to a digit beside a letter โ 7 replies, one distinct list,
// against 0 replies where a typed list's text repeats its own marker โ and there the letters are
// the document's only copy, so deleting them is the one repair that loses the page's markers.
assert.match(flatten(`<ol><li>(a) Estimating</li></ol>`), /\[List item 1\] \(a\) Estimating/);
assert.ok(
/the repair is the other way round/.test(READER_SYSTEM),
"the report treats a digit announced beside a printed letter as the same defect as a repeat",
);
// That branch is named by the SHAPE it means โ a digit-announced list printing letters โ and not by
// "they disagree in kind", which literally covers `[List item a] 12.` as well and so claimed the third
// case's own example. Its repair reads as nonsense there: a list already carrying `type="a"` is not
// missing the type that would announce letters.
assert.ok(
/Where the list announces DIGITS and its items\s+print letters or roman numerals/.test(READER_SYSTEM),
"the second marker branch is not named by the shape its repair is true of",
);
assert.ok(
!/Where they DISAGREE in kind/.test(READER_SYSTEM),
"the second marker branch is named by a kind test that also covers the third case",
);
// And the two branches are not complementary, so the prompt has to name the THIRD case outright.
// Announced "1" beside a printed "12." is the same kind and a different marker โ a statute's clause
// number under the list's own count โ and it falls outside both: it is not the same content twice and
// the list is not missing a `type`. Reading the second branch as everything the first is not is what
// made the log detector a kind test for two commits, so the case is stated rather than left to the
// prohibition that already covered it.
assert.match(flatten(`<ol><li>12. Payments to the state</li></ol>`), /\[List item 1\] 12\. Payments/);
assert.ok(
/a marker that is NEITHER of those/.test(READER_SYSTEM),
"the prompt states its two marker branches as if they were complementary",
);
// The instruction on that case is what it must not do โ drop either copy โ and NOT "leave everything
// alone", which is the wider thing the first version said. `EDITOR_SYSTEM` sends the offset shape to a
// report ("where the markers do not begin where the list's own count does โฆ report it instead"), and
// the Reader is asked twelve lines earlier for an announced marker that disagrees with the source
// page, so a blanket "change nothing and say nothing" contradicted both. The two shapes are split on
// whether the printed markers are one run from an offset โ a missing `start` โ or are not one run with
// the count at all, which is the document's own clause numbering.
assert.ok(
/NEVER ask for either copy to be dropped/.test(READER_SYSTEM),
"the third marker case does not forbid deleting the page's only copy of a marker",
);
assert.ok(
/missing the start that would announce those very markers/.test(READER_SYSTEM),
"the Reader is told to leave a list that is missing `start`, which the editor is told to report",
);
assert.ok(
!/leave the list and the text exactly as they are/.test(READER_SYSTEM),
"the third marker case still forbids the report the editor's own precondition asks for",
);
// The `start` report is only true where `start` can announce the printed markers, and it cannot when
// they are a different KIND from the list's own: `type` carries the kind and `start` only the count, so
// `start="12"` on an `<ol type="a">` announces `l.`, `m.`, `n.` โ a marker no page printed, which is
// the invention the same prompt forbids nine lines later. Stated for every list it was wrong on the
// second example the sentence itself gives, and the same-kind test is what makes the repair produce the
// markers the page showed.
assert.match(
flatten(`<ol type="a" start="12"><li>12. Payments to the state</li></ol>`),
/\[List item l\] 12\. Payments/,
);
assert.match(flatten(`<ol start="12"><li>12. Payments to the state</li></ol>`), /\[List item 12\] 12\. Payments/);
assert.match(flatten(`<ol type="a" start="3"><li>(c) Estimating</li></ol>`), /\[List item c\] \(c\) Estimating/);
assert.ok(
/the SAME KIND as the announced one and run consecutively/.test(READER_SYSTEM),
"the missing-`start` report is stated for lists whose printed markers no `start` can announce",
);
assert.ok(
/no start announces them/.test(READER_SYSTEM),
"the third case never says what to do where `start` cannot reach the printed markers",
);
assert.ok(
!/consecutive from wherever it starts/.test(READER_SYSTEM),
"the missing-`start` report is unscoped again and asks for `start` on a lettered list",
);
// And the reason clause has to cover every shape the branch does. "one marker and then a number" was
// true of the digit example and false of `[List item a] (c)`, which is two letters, and a reason stated
// one grain narrower than its rule is what cost this check three rounds further down.
assert.ok(
!/a reader hears one marker and then a number/.test(READER_SYSTEM),
"the third case justifies itself with a digits-only reason it also applies to letters",
);
// Options are still content, and are separated so they cannot run together.
assertNoTextLost(`<select><option>Platform</option><option>Design</option></select>`, "select options");
});
test("every attribute flatten reads a marker from is one the editor is told to carry", () => {
// #432's review, note 1. Moving a lettered list's letters out of the item text and into
// `type` moved them out of the one place EDITOR_SYSTEM protects: it returns whole replacement
// blocks, and the only attribute it names is `href`. A copy-edit round that rewrites a block
// for an unrelated issue can hand back a bare <ol>, and there is nothing left in the text to
// recover the letters from.
//
// An attribute belongs in this list because dropping it CHANGES the marker a reader hears โ
// which is what makes losing it a content loss rather than a tidy-up โ and `reversed` is here
// although the review named three, because the mechanism has four and a list stated one member
// short reads as complete.
//
// The pairs carry a paragraph of real prose because `contentCoverage` returns null below
// MIN_COVERAGE_WORDS distinct words, and a null would make the coverage rows below vacuous
// rather than a measurement of the blindness they are here to show.
const lead = `<p>Estimating the annual cost of intergovernmental grant programs</p>`;
const items = `<li>Direct federal outlays</li><li>Reimbursed state administration</li>`;
const marking: ReadonlyArray<readonly [string, string, string]> = [
["type", `${lead}<ol type="a">${items}</ol>`, `${lead}<ol>${items}</ol>`],
["start", `${lead}<ol start="7">${items}</ol>`, `${lead}<ol>${items}</ol>`],
["value", `${lead}<ol><li value="7">Direct federal outlays</li></ol>`, `${lead}<ol><li>Direct federal outlays</li></ol>`],
["reversed", `${lead}<ol reversed>${items}</ol>`, `${lead}<ol>${items}</ol>`],
];
for (const [attr, marked, bare] of marking) {
assert.notEqual(flatten(marked), flatten(bare), `dropping ${attr} leaves the announced marker unchanged`);
assert.ok(
new RegExp(`\\b${attr}\\b`).test(EDITOR_SYSTEM),
`flatten announces a marker from ${attr} and EDITOR_SYSTEM never names it`,
);
// And why the prompt has to carry it: the marker is inside brackets, so the gate that would
// otherwise notice content going missing reads the two documents as identical.
assert.equal(contentCoverage(marked, bare), 1);
assert.equal(contentCoverage(bare, marked), 1);
}
// The one conversion the editor IS licensed to make has to be atomic, because each half alone is
// a defect: the type without the text strip announces the letter and then reads it out, and the
// strip without the type deletes the only copy of the letters the page printed. Both failures are
// states this view can show, so both belong in the same test as the rule that forbids them.
assert.match(flatten(`<ol type="a"><li>(a) Estimating</li></ol>`), /\[List item a\] \(a\)/);
assert.match(flatten(`<ol><li>Estimating</li></ol>`), /\[List item 1\] Estimating/);
assert.ok(
/That is ONE change, not two/.test(EDITOR_SYSTEM),
"the editor's list conversion does not say that setting the type and stripping the text are one change",
);
});
test("half of the licensed list conversion is reported and the whole of it is not", () => {
// The prompt is the only thing standing between a licensed strip and a deleted marker, so the two
// halves get the check `droppedHrefs` gets: read off the same view a reader hears, because that is
// where `type="a"` and the item's own "(a)" are both visible at once.
const printed = `<ol><li>(a) Direct federal outlays</li><li>(b) Reimbursed state administration</li></ol>`;
const converted = `<ol type="a"><li>Direct federal outlays</li><li>Reimbursed state administration</li></ol>`;
const stripped = `<ol><li>Direct federal outlays</li><li>Reimbursed state administration</li></ol>`;
const doubled = `<ol type="a"><li>(a) Direct federal outlays</li><li>(b) Reimbursed state administration</li></ol>`;
// The whole conversion: the letters leave the text and the list announces them, so the two counts
// move together and nothing is reported.
assert.equal(listMarkerHalfEdit(printed, converted), null);
assert.equal(listMarkerHalfEdit(printed, printed), null);
// Each half, which is each of the two defects the prompt names.
assert.equal(listMarkerHalfEdit(printed, stripped), "text_markers_gone");
assert.equal(listMarkerHalfEdit(printed, doubled), "marker_announced_twice");
// The counts behind those verdicts, because a verdict read off a count nobody checked is a claim
// about arithmetic rather than about the document.
assert.deepEqual(listMarkers(printed), { items: 2, lettered: 0, printed: 2, printed_lettered: 2, doubled: 0 });
assert.deepEqual(listMarkers(converted), { items: 2, lettered: 2, printed: 0, printed_lettered: 0, doubled: 0 });
assert.deepEqual(listMarkers(stripped), { items: 2, lettered: 0, printed: 0, printed_lettered: 0, doubled: 0 });
assert.deepEqual(listMarkers(doubled), { items: 2, lettered: 2, printed: 2, printed_lettered: 2, doubled: 2 });
// A DIGIT leaving an item's text is the repair `READER_SYSTEM` asks for, not a loss: an <ol>
// announces 1, 2, 3 by itself, so the text's copy was the redundant one. This is the branch the
// Reader fires on first โ #334's `(1)` list, whose rule already existed on main โ and reading
// `printed` instead of `printed_lettered` labelled it as the loss.
const digitsPrinted = `<ol><li>(1) Direct federal outlays</li><li>(2) Reimbursed state administration</li></ol>`;
assert.equal(listMarkers(digitsPrinted).printed, 2);
assert.equal(listMarkers(digitsPrinted).printed_lettered, 0);
assert.equal(listMarkerHalfEdit(digitsPrinted, stripped), null);
// That `null` is also why the editor's conversion licence is NOT widened to the same-kind offset run
// the Reader now reports. If the editor were allowed to set `start="12"` on an `<ol>` whose items print
// 12., 13., the DESTRUCTIVE half of that change โ markers stripped with no `start` set, which deletes
// the document's only record of its numbering โ produces the same five counts as the whole change, so
// this cannot tell them apart. The lettered half of the same shape IS caught, and that asymmetry is a
// silence to close before the licence moves, not after.
// Both `after` documents here are `offsetRun`'s own items with the markers taken off, so the pair being
// compared is the two edits an editor could actually make and not a list that also reworded an item.
const offsetRun = `<ol><li>12. Payments to states</li><li>13. Reimbursed state administration</li></ol>`;
const offsetConverted = `<ol start="12"><li>Payments to states</li><li>Reimbursed state administration</li></ol>`;
const offsetStripped = `<ol><li>Payments to states</li><li>Reimbursed state administration</li></ol>`;
assert.deepEqual(listMarkers(offsetConverted), listMarkers(offsetStripped));
assert.equal(listMarkerHalfEdit(offsetRun, offsetConverted), null);
assert.equal(listMarkerHalfEdit(offsetRun, offsetStripped), null);
assert.equal(
listMarkerHalfEdit(
`<ol type="a"><li>(c) Estimating</li><li>(d) Admin</li></ol>`,
`<ol type="a"><li>Estimating</li><li>Admin</li></ol>`,
),
"text_markers_gone",
);
// A conversion that is partial in BOTH directions is the state the totals cannot see: the list
// gains its letters, the text loses SOME of its markers, and the item that kept its own is
// announced "b" and then reads "(b)" out. `doubled` is per item, so it sees exactly that item.
const half = `<ol type="a"><li>Direct federal outlays</li><li>(b) Reimbursed state administration</li></ol>`;
assert.equal(listMarkers(half).doubled, 1);
assert.equal(listMarkerHalfEdit(printed, half), "marker_announced_twice");
// A round that RESIZED a list is not read at all: a deleted item takes its printed marker with it,
// and removing content the document printed twice is what this loop is for. Both directions,
// because a list that grew moves the same two counts the other way.
assert.equal(listMarkerHalfEdit(printed, `<ol><li>(a) Direct federal outlays</li></ol>`), null);
assert.equal(listMarkerHalfEdit(converted, `${converted}${converted}`), null);
// And an unordered list has no marker to lose either way.
assert.equal(listMarkerHalfEdit(`<ul><li>(a) Alpha</li></ul>`, `<ul><li>Alpha</li></ul>`), null);
// What is and is not a printed marker, each case a false positive this had. "(see)" is three
// letters and no numeral. "cm." and "ml." are runs of roman LETTERS and not roman numbers. An
// initial โ "J. Smith chaired the committee" โ is a single letter closed by a full stop, and a
// round that recasts that sentence is ordinary work for this pass, so it must not log a lost
// marker. The same letter closed by a bracket is a marker.
assert.equal(listMarkers(`<ol><li>(see) Alpha</li></ol>`).printed, 0);
assert.equal(listMarkers(`<ol><li>(iii) Alpha</li></ol>`).printed, 1);
assert.equal(listMarkers(`<ol><li>cm. Alpha</li><li>ml. Beta</li></ol>`).printed, 0);
assert.equal(listMarkers(`<ol><li>J. Smith chaired the committee</li></ol>`).printed, 0);
assert.equal(listMarkers(`<ol><li>a) Alpha</li></ol>`).printed, 1);
assert.equal(listMarkers(`<ol><li>(a) Alpha</li></ol>`).printed, 1);
assert.equal(
listMarkerHalfEdit(
`<ol><li>J. Smith chaired the committee</li><li>Reimbursed state administration</li></ol>`,
`<ol><li>The committee was chaired by J. Smith</li><li>Reimbursed state administration</li></ol>`,
),
null,
);
// A bracketed abbreviation is the initial one bracket over, and the first narrowing let it through:
// an OPENING bracket satisfied nothing on its own, so "(e.g. the totals)" at the head of an item was
// a printed lettered marker and recasting the sentence logged the page's letters as deleted. A single
// letter now needs the CLOSER, which "(a." is not and "(a)" and "a)" are.
assert.equal(listMarkers(`<ol><li>(e.g. the totals) Alpha</li><li>(e.g. more) Beta</li></ol>`).printed, 0);
assert.equal(listMarkers(`<ol><li>(i.e. the totals) Alpha</li></ol>`).printed, 0);
assert.equal(listMarkers(`<ol><li>(a. Alpha</li></ol>`).printed, 0);
assert.equal(
listMarkerHalfEdit(
`<ol><li>(e.g. the totals) rose in 1998 across every state</li><li>Reimbursed state administration</li></ol>`,
`<ol><li>The totals rose, e.g. in 1998 across every state</li><li>Reimbursed state administration</li></ol>`,
),
null,
);
// `doubled` matches the announced marker's own VALUE against the printed token, and every weaker
// version of that reported something a reader does not hear twice. A lettered list whose item prints
// "12." is a statute's clause number under its own marker โ the reader hears "a" then "12", one
// marker and a number โ so restoring that number is not a doubling.
const clauseNumber = `<ol type="a"><li>12. Payments to the state</li><li>Reimbursed state administration</li></ol>`;
assert.equal(listMarkers(clauseNumber).doubled, 0);
assert.equal(listMarkers(clauseNumber).printed, 1);
assert.equal(listMarkerHalfEdit(`<ol type="a"><li>Payments to the state</li><li>Reimbursed state administration</li></ol>`, clauseNumber), null);
// And a bare <ol> whose item prints "(1)" IS the doubling, in the kind the corpus actually holds:
// #334's own list. Requiring the announced marker to be a letter missed it entirely.
const digitsDoubled = `<ol><li>(1) Direct federal outlays</li><li>(2) Reimbursed state administration</li></ol>`;
assert.equal(listMarkers(digitsDoubled).doubled, 2);
assert.equal(listMarkerHalfEdit(stripped, digitsDoubled), "marker_announced_twice");
// Where a DIGIT-announced list's items print letters โ announced "1", text reads "(a)" โ nothing is
// doubled: that list is missing the `type` that would announce its letters, and the Reader prompt says
// the text's copy must STAY until it has one. That is the prompt's second branch, which is named for
// this shape and not for "they disagree in kind" โ a test that also covers announced "a" beside a
// printed "12.", where a missing `type` is not the repair.
assert.equal(listMarkers(printed).doubled, 0);
// Matching on KIND rather than on value left two more of the same shape, one in each alphabet. A
// lettered list whose item prints "(i)" is a marker and a roman SUB-marker โ "(a) (i) Payments" โ
// and both are non-digits, so a kind test called it a doubling. A bare <ol> whose item prints "12."
// announces "1" and reads "12", both digits: the clause number again, on the side the kind test did
// not look at. Only the announced marker's own value separates them.
assert.equal(
listMarkerHalfEdit(
`<ol type="a"><li>Payments to states</li><li>Payments to tribes</li></ol>`,
`<ol type="a"><li>(i) Payments to states</li><li>(ii) Payments to tribes</li></ol>`,
),
null,
);
assert.equal(
listMarkerHalfEdit(
`<ol><li>Payments to states</li><li>Reimbursed state administration</li></ol>`,
`<ol><li>12. Payments to states</li><li>13. Reimbursed state administration</li></ol>`,
),
null,
);
// A marker is the SAME marker across case and however the list arrives at it, so all three of these
// are the doubling: a roman `type`, an uppercase text copy under a lowercase one, and a list whose
// announced letters come from `start` rather than from counting up from the first.
for (const [before, after] of [
[`<ol type="i"><li>Alpha item</li><li>Beta item</li></ol>`, `<ol type="i"><li>(i) Alpha item</li><li>(ii) Beta item</li></ol>`],
[`<ol type="a"><li>Alpha item</li><li>Beta item</li></ol>`, `<ol type="a"><li>(A) Alpha item</li><li>(B) Beta item</li></ol>`],
[`<ol type="a" start="3"><li>Gamma item</li><li>Delta item</li></ol>`, `<ol type="a" start="3"><li>(c) Gamma item</li><li>(d) Delta item</li></ol>`],
]) {
assert.equal(listMarkerHalfEdit(before, after), "marker_announced_twice", after);
}
// Roman precision, stated because it is asymmetric on purpose: "ii." cannot be an initial and counts
// with a full stop, "i." can be one and does not. The alphabet is i/v/x only, which caps a roman
// marker at xxxix โ admitting l, c, d and m is what made "cm." and "ml." matches in the first place.
assert.equal(listMarkers(`<ol><li>i. Alpha</li><li>ii. Beta</li><li>iii. Gamma</li></ol>`).printed, 2);
assert.equal(listMarkers(`<ol><li>(i) Alpha</li><li>(ii) Beta</li></ol>`).printed, 2);
assert.equal(listMarkers(`<ol><li>(xl) Alpha</li><li>(xli) Beta</li></ol>`).printed, 0);
// A lettered marker is ONE letter, so a list past its twenty-sixth item is invisible to BOTH branches
// and not only to the doubling one. Pinned with the ceiling above because both are the same trade: a
// wider class would have to tell a two-letter marker from any two-letter word at the head of an item.
assert.deepEqual(
listMarkers(`<ol type="a" start="27"><li>(aa) Alpha item</li><li>(ab) Beta item</li></ol>`),
{ items: 2, lettered: 2, printed: 0, printed_lettered: 0, doubled: 0 },
);
// A `reversed` list announces 3, 2, 1, and an item printing "(3)" under the first of them is the
// doubling like any other โ the announced marker is whatever `flatten` resolved, not the item's index.
assert.equal(
listMarkerHalfEdit(
`<ol reversed><li>Alpha item</li><li>Beta item</li><li>Gamma item</li></ol>`,
`<ol reversed><li>(3) Alpha item</li><li>(2) Beta item</li><li>(1) Gamma item</li></ol>`,
),
"marker_announced_twice",
);
// The other silence, pinned so it stays a stated limit and not a surprise: every count here is a
// BLOCK total, so one list's correct conversion pays for another's destruction. `lettered` risen and
// `printed_lettered` fallen is what a single correct conversion looks like, and the second list's
// letters are gone from the delivered document with nothing announcing them. `flatten` marks items and
// never the list they belong to, so splitting per list means a second renderer of the announced
// marker beside `markerStyle` โ the worse trade, and the block is the grain the rest of the file's
// loss accounting uses.
assert.equal(
listMarkerHalfEdit(
`<ol><li>(a) Direct federal outlays</li><li>(b) Reimbursed state administration</li></ol><ol><li>(a) Estimating a liability</li><li>(b) Filing the return</li></ol>`,
`<ol type="a"><li>Direct federal outlays</li><li>Reimbursed state administration</li></ol><ol><li>Estimating a liability</li><li>Filing the return</li></ol>`,
),
null,
);
});
test("a page too deep for the recursive walk keeps its text instead of throwing", () => {
// `block` and `inlineText` both recurse, so a pathologically nested page overflowed the
// stack and threw `RangeError` โ dropping ALL the text, the worst version of the failure
// this file exists to prevent, and failing the session from a helper the caller expects
// to always return. The depth is reachable: `anchors.ts` deliberately DELIVERS a page too
// deep to rewrite rather than dropping it, so it arrives in the body that `review.ts`
// flattens for the Reader and that `contentCoverage` flattens on both sides of the gate.
//
// Depths straddle where the recursive walk gives out (~5,000 here) so both the ordinary
// path and the fallback are covered by the same assertions. Every threshold moves with
// how much stack the caller already spent, which is why the assertion is "the words
// survived" and not "the fallback ran at depth N".
for (const depth of [600, 5000, 9000, 10000]) {
const content = `<p>hello</p><ul><li>apple</li><li>pear</li></ul><img alt="Bar chart"><input value="Ada">`;
const html = "<div>".repeat(depth) + content;
let view: string;
try {
view = flatten(html);
} catch (e) {
assert.fail(`depth ${depth}: flatten threw ${(e as Error).constructor.name}`);
}
// Structure may be lost at these depths; text may not.
const got = wordsOf(view.replace(/\[[^\]]*\]/g, " "));
for (const w of ["hello", "apple", "pear", "bar", "chart", "ada"]) {
assert.ok(got.has(w), `depth ${depth}: lost "${w}" from the view:\n${view.slice(0, 200)}`);
}
// Not doubled: the recursive attempt dies mid-document, and its partial lines have to
// be discarded rather than prepended to the pass that replaces them.
assert.equal(view.match(/apple/g)?.length, 1, `depth ${depth}: partial output was repeated`);
}
});
test("the coverage gate still measures dropped content at fallback depth", () => {
// The fallback would be worthless if it fixed the throw and broke the measurement:
// structure-free output that scores every candidate 1.0 is the silent-success failure
// in the header, just reached by a different route. A word missing from the candidate
// must still register as missing when the page is deep enough to take the fallback.
const deep = "<div>".repeat(6000);
const accepted = `${deep}<p>alpha bravo charlie delta echo foxtrot golf hotel india</p>`;
assert.equal(contentCoverage(accepted, accepted), 1, "identical deep documents must score 1.0");
// One word of nine missing: detected at all. It scores 0.889, which is ABOVE the 0.85 bar
// by design โ the gate tolerates small differences, and asserting otherwise here would be
// asserting a threshold this test does not own.
const one = contentCoverage(accepted, `${deep}<p>alpha bravo charlie delta echo foxtrot golf hotel</p>`);
assert.ok(one !== null && one < 1, `a dropped word went unmeasured at fallback depth: scored ${one}`);
// Enough missing to actually fail the gate, which is the property that matters: the
// fallback must not merely register a difference but let a real regression be blocked.
const many = contentCoverage(accepted, `${deep}<p>alpha bravo charlie delta</p>`);
assert.ok(
many !== null && many < MIN_CONTENT_COVERAGE,
`losing five words of nine must fail the ${MIN_CONTENT_COVERAGE} bar, but scored ${many}`,
);
});
test("a decorative image is distinguished from a missing alt", () => {
// alt="" is correct markup for a decorative image; a missing alt is a defect. The
// Reader is told to treat only the second as one, so they must not flatten alike.
assert.match(flatten(`<img src="x.png">`), /\[alt missing\]/);
assert.doesNotMatch(flatten(`<img src="x.png" alt="">`), /\[alt missing\]/);
assert.match(flatten(`<img src="x.png" alt="">`), /decorative/);
assert.match(flatten(`<img src="x.png" alt="Bar chart">`), /\[Image alt\] Bar chart/);
});