๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ review-headings.test.ts ยท 595 lines
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// The duplicate-heading defect (issues #111, #119) is the one heading problem the page
// agent cannot see. It is handed one page and no other, so a section title reprinted where
// the section continues looks exactly like a new section starting, and three <h2>Operation</h2>
// headings arrive one per call with nothing to compare them against. The defect only exists
// in the assembled document โ€” which the Reader Agent is given, but a chunk at a time:
// `runReader` sends the body in CHUNK_BUDGET windows, and a reprinted title is a full page
// of extracted HTML away from its twin, so at some offsets the pair straddles a cut and
// neither call sees both headings. So finding the pairs is done in code over the whole body
// (`sameWordedHeadingRuns`) and handed to the Reader, which is left with the part that needs
// judgement: which of the two cases a pair is, and which pages it is on.
//
// Nothing else in the pipeline reaches it either. axe (src/pipeline/lint.ts) reports a
// SKIPPED level and says nothing about two headings at the same level with the same words,
// since that is valid markup. So the rule is split across three prompts and each third is
// useless without the others: the page prompt handles what one page shows (pinned in
// test/page-prompt.test.ts), READER_SYSTEM detects the pair across pages and classifies it,
// EDITOR_SYSTEM applies the resolution the Reader named. This test holds the two halves that
// live in src/pipeline/review.ts, and holds them to each other: if the Reader reports a case
// the editor has no instruction for, the finding survives to `unresolved` and the document
// ships with the defect the user reported.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { READER_SYSTEM, EDITOR_SYSTEM, runReview } from "../src/pipeline/review.ts";
import { sameWordedHeadingNote, sameWordedHeadingRuns } from "../src/pipeline/headings.ts";
import { flatten } from "../src/pipeline/flatten.ts";
import type { PipelineContext } from "../src/pipeline/context.ts";
import type { Paths } from "../src/store/paths.ts";

// The prompts wrap for reading, so the clauses are matched on words rather than bytes โ€”
// reflowing a paragraph must not fail a test whose subject is what the paragraph says.
function normalize(s: string): string {
  return s.replace(/\s+/g, " ").trim();
}

const reader = normalize(READER_SYSTEM);
const editor = normalize(EDITOR_SYSTEM);

test("the Reader is told to find the duplicate-heading pair and say which case it is", () => {
  for (const [what, re] of [
    // #119: three same-level headings reading "Operation", which tells a reader navigating by
    // heading that the second section is the same subject as the first.
    ["adjacent same-level headings with the same words are a defect to report",
      /The same words announced twice in a row at the same level โ€” \[Heading 2\] Operation, then another \[Heading 2\] Operation/],
    // #111: the same defect assembled from per-page extractions, which is why the Reader is
    // told where it comes from โ€” it must not assume a duplicate means one page was extracted
    // twice.
    ["a title reprinted per page is named as the same defect arriving one page at a time",
      /a section title reprinted at the top of every page it continues on is that defect arriving one page at a time/],
    // The Copy Editor fetches page images by attribution, and this is the one issue where the
    // images ARE the evidence: only the source pages say whether the title was reprinted.
    ["the pages both headings are on are reported, since the editor resolves it from the images",
      /Report both, with the pages both headings are on/],
    // Two resolutions with opposite effects โ€” merge, or keep both and extend โ€” so the Reader
    // has to name which, or the editor is guessing.
    ["the report says which of the two cases it looks like",
      /say which of the two it looks like/],
    ["the one-section case is described as content moving under the first heading",
      /one section whose title repeats, where the second heading goes and what followed it belongs under the first/],
    ["the two-section case keeps the label and adds words from that section's own content",
      /each heading keeps the label and gains the words that tell it apart โ€” words already in that section's own content, never a phrase of your own/],
    // Over-correction guards. Without these the Reader reports every <h2> that shares a level
    // with another <h2>, and the editor spends a round merging sections that were never
    // ambiguous.
    ["headings that merely share a level are not reported",
      /Do not report two same-level headings that merely share a level/],
    ["identical headings with other sections between them are not reported",
      /identical headings with other sections in between/],
    // The prompt has to describe the computed section the code sends, or the Reader treats
    // a list of headings it cannot find in its own excerpt as noise.
    ["the computed list is announced, and announced as covering the whole document",
      /a section below lists them, computed from the WHOLE document rather than from the HTML you were given/],
    // "outside the HTML you were given" rather than "outside your excerpt": what the call was
    // handed is the whole body on a document that fits in one chunk, and the prompt now says
    // which of the two it is on the HTML section itself (`window N of M`, issue #188). A
    // sentence calling that section an excerpt unconditionally contradicts the label 40 lines
    // down and re-creates the ambiguity the label exists to remove.
    ["a heading the list names but the given HTML does not contain is still reported",
      /a heading it names may sit outside the HTML you were given, and is to be reported anyway/],
    ["entries are not argued with, and a pair the list missed is still worth reporting",
      /no entry is a false positive to be argued with, and finding a pair the list missed is still worth reporting/],
  ] as [string, RegExp][]) {
    assert.match(reader, re, `READER_SYSTEM no longer says: ${what}`);
  }
});

test("the Copy Editor is told how to resolve each case the Reader reports", () => {
  for (const [what, re] of [
    // The Reader can report a pair that is a page apart, because the reprinted title has that
    // page's content between the two headings. An editor told only about ADJACENT headings has
    // no instruction for the case #111 reported.
    ["the pair may be adjacent or a page apart, which is what a reprinted title looks like",
      /whether they sit next to each other or with one page's worth of content between them, which is what a title reprinted where its section continued looks like once the pages are joined/],
    ["the source images decide which case it is",
      /The source images say which way it goes/],
    ["the reprinted-title case drops the repeat and relevels what followed it",
      /drop the repeat and put what followed it under the first, at the level its content calls for/],
    ["two sections the document labels alike keep the label and gain distinguishing words",
      /two sections the document really does label alike keep the label and each gain the words that distinguish them/],
    // This prompt also says "do not invent content", and adding words to a heading is adding
    // words. The exception has to be stated and bounded, or the editor either ignores the
    // instruction or writes a subtitle of its own. There are now two such exceptions โ€” the other
    // is a `[not legible]` marker resolved from the attached page image โ€” and each names the
    // count, so a third cannot be added without contradicting one of them
    // (test/review-legibility.test.ts holds the other end).
    ["the added words are bounded to that section's own content, and counted against the texts it may add",
      /Those words come from that section's own content, which is one of the two texts you may add here \(the other is under the markers below, and there is no third\)/],
    ["a subtitle of the editor's own is forbidden", /never write a subtitle of your own/],
    ["sections that are merely named alike are not merged",
      /never merge two sections that are merely named alike/],
    // The Reader has a third answer โ€” it is told to say so where the excerpts do not tell
    // it which case a pair is, and with a 200-char page excerpt that happens for any pair
    // away from a page's top. Without an instruction for it the editor still has "resolve
    // every issue you can", and the resolution it can always reach is the destructive one.
    // This is also the state of the size-refusal retry, which carries no images at all.
    ["an undecidable pair is left alone rather than resolved on a guess",
      /where nothing you were given decides it โ€” the reviewer says it could not tell, or the pages those headings are on were not attached โ€” leave both headings exactly as they are/],
    ["the asymmetry is stated: a repeated label is recoverable, a merged section is not",
      /an issue left alone comes back next round or is reported as unresolved, while content you removed on a guess is gone from the document/],
  ] as [string, RegExp][]) {
    assert.match(editor, re, `EDITOR_SYSTEM no longer says: ${what}`);
  }
});

// The two prompts are one rule in two halves, and the halves are written by different
// hands at different times. What follows is the seam: every case the Reader is told to
// report has to be a case the editor is told to resolve. A Reader that reports what the
// editor cannot act on does not produce a wrong document โ€” it produces one where the issue
// comes back as `unresolved` round after round, spending review iterations on a finding
// that could never be fixed.
test("every answer the Reader can give is one the editor has an instruction for", () => {
  for (const [what, inReader, inEditor] of [
    ["the reprinted-title case: one section", /one section whose title repeats/, /is ONE heading/],
    ["the labelled-alike case: two sections", /two sections the document labels alike/, /really does label alike/],
    ["the words added come from the section's own content", /that section's own content/, /that section's own content/],
    ["the case where the Reader cannot tell which of the two it is",
      /where the excerpts do not tell you, say that instead of choosing/,
      /the reviewer says it could not tell.*leave both headings exactly as they are/],
  ] as [string, RegExp, RegExp][]) {
    assert.match(reader, inReader, `READER_SYSTEM stopped reporting ${what}`);
    assert.match(editor, inEditor, `EDITOR_SYSTEM stopped resolving ${what} โ€” the Reader still reports it`);
  }
});

// --- the finding half, in code -----------------------------------------------

const runs = (body: string) =>
  sameWordedHeadingRuns(body).map((r) => `h${r.level}:${r.text}:${r.count}`);

// Same, plus where the run starts in the outline โ€” the field that keeps two runs of the
// same words at the same level from rendering as one line.
const placed = (body: string) =>
  sameWordedHeadingRuns(body).map(
    (r) => `h${r.level}:${r.text}:${r.count} after ${r.after ? `h${r.after.level}:${r.after.text}` : "-"}`,
  );

// The rendered lines, which is what the Reader actually acts on. Two runs it cannot tell
// apart cost one of them a report, whatever the objects behind them look like.
const lines = (body: string) => (sameWordedHeadingNote(sameWordedHeadingRuns(body)) ?? "").split("\n");

test("two same-level headings with the same words and only their own content between them", () => {
  // #119 as reported, in miniature: the second [Heading 2] Operation tells a reader
  // navigating by heading that the same subject follows.
  assert.deepEqual(
    runs("<h2>Operation</h2><p>Fill the hopper.</p><h2>Operation</h2><p>Press start.</p>"),
    ["h2:Operation:2"],
  );
});

test("a subsection between them is their own content, not another section", () => {
  // This is what a reprinted title looks like once the pages are joined (#111): the
  // page's own subsections sit under the first heading, then the title comes again.
  assert.deepEqual(
    runs("<h2>Controls</h2><h3>Top</h3><p>a</p><h2>Controls</h2><h3>Rear</h3><p>b</p>"),
    ["h2:Controls:2"],
  );
});

test("a run of three is reported once, with its length", () => {
  // The user's report was three <h2>Operation</h2> headings. Two overlapping pairs
  // would have the Reader raise the same defect twice and the editor resolve it in two
  // rounds, out of a budget of a few.
  assert.deepEqual(runs("<h2>Op</h2><p>a</p><h2>Op</h2><p>b</p><h2>Op</h2><p>c</p>"), ["h2:Op:3"]);
});

test("another section in between is not the ambiguous case", () => {
  // Deliberate bound, and the guard READER_SYSTEM states: the intervening section tells
  // a reader the two headings are different places in the document. Widening this to
  // every same-worded heading in a manual would send the editor merging or renaming
  // sections that were never ambiguous.
  assert.deepEqual(runs("<h2>Op</h2><p>a</p><h2>Care</h2><p>b</p><h2>Op</h2><p>c</p>"), []);
});

test("headings at different levels are not a pair, however alike their words", () => {
  // <h1>Operation</h1> followed by <h2>Operation</h2> is a section and its first
  // subsection sharing a name โ€” read in order it is unambiguous, and it is also what
  // the page prompt's own "step one level down" rule produces.
  assert.deepEqual(runs("<h1>Operation</h1><h2>Operation</h2><p>a</p>"), []);
});

test("case and trailing punctuation do not make two headings different", () => {
  // A page that sets a running title in capitals and reprints it in title case is
  // reprinting it, and a colon is a typographic choice about the same words.
  assert.deepEqual(
    runs("<h2>OPERATION:</h2><p>a</p><h2>Operation</h2><p>b</p>"),
    ["h2:OPERATION::2"],
    "reported with the text as first printed, since that is what is matched against page excerpts",
  );
});

test("markup inside a heading is read as the words it announces", () => {
  assert.deepEqual(
    runs("<h2>Care <em>and</em> cleaning</h2><p>a</p><h2>Care and cleaning</h2><p>b</p>"),
    ["h2:Care and cleaning:2"],
  );
});

test("two empty headings are a different defect and are not reported as a pair", () => {
  // An <h2></h2> announces nothing; two of them are not two sections a reader confuses,
  // they are markup axe already reports (empty-heading). Reporting them here would put
  // an entry in the list the Reader is told is never a false positive.
  assert.deepEqual(runs("<h2></h2><p>a</p><h2>  </h2><p>b</p>"), []);
});

test("a body with no headings, and one with no repeats, produce no list at all", () => {
  assert.deepEqual(runs("<p>Just prose.</p>"), []);
  assert.deepEqual(runs("<h2>One</h2><p>a</p><h2>Two</h2><p>b</p>"), []);
  assert.equal(sameWordedHeadingNote([]), null, "the section is omitted rather than asserting an absence");
});

test("the list quotes the heading and says how many, and says when it is truncated", () => {
  const note = sameWordedHeadingNote([
    { level: 2, text: "Operation", count: 3, after: null, opening: "Fill the hopper." },
    { level: 3, text: "Cleaning", count: 2, after: { level: 2, text: "Care" }, opening: "" },
  ])!;
  assert.match(
    note,
    /1\. \[Heading 2\] "Operation" \(3 of them\), at the start of the document, opening "Fill the hopper\."/,
  );
  assert.match(
    note,
    /2\. \[Heading 3\] "Cleaning", the first of them after \[Heading 2\] "Care", with nothing under it$/m,
    "a plain pair needs no count, but still needs placing",
  );

  // A silent cap reads as "these are all of them" to whoever acts on the list.
  const many = Array.from({ length: 20 }, (_, i) => ({
    level: 2,
    text: `Section ${i}`,
    count: 2,
    after: null,
    opening: "",
  }));
  const capped = sameWordedHeadingNote(many)!;
  assert.match(capped, /and 8 more, not listed here/);
});

test("a body that cannot be parsed costs the list, not the review", () => {
  // The list is an aid to a rule the Reader has anyway. Returning [] here keeps a
  // pathological body from ending a review that would otherwise have run.
  assert.deepEqual(runs(""), []);
});

// --- the seam between the code and the prompt --------------------------------

// The list is computed over the whole body but the Reader is called per chunk, so two
// things have to be true at once and neither is visible from the prompt text: the section
// has to reach the Reader, and it has to reach it ONCE. Chunk calls are independent, so a
// list given to all of them yields the same finding two or three times โ€” carried to
// `unresolved` that many times if no editor round clears it.
async function readerPrompts(body: string): Promise<string[]> {
  const dir = mkdtempSync(join(tmpdir(), "iris-headings-"));
  try {
    const prompts: string[] = [];
    const ctx = {
      sessionId: "ses_test",
      images: [],
      maxReviewIterations: 0,
      extractionConcurrency: 4,
      recheckSampleSize: 1,
      paths: {
        agentsDir: join(dir, "agents"),
        tmpAgentsDir: () => join(dir, "tmp-agents"),
        agentMemory: () => join(dir, "memory", "page.json"),
      } as unknown as Paths,
      router: {
        complete: async (agent: string, _cap: string, messages: { content: string }[]) => {
          if (agent === "reader") prompts.push(messages.map((m) => m.content).join("\n"));
          return { text: JSON.stringify({ issues: [] }) };
        },
      },
      log: { event: () => {}, agentCall: () => {} },
    } as unknown as PipelineContext;
    await runReview(ctx, { body, lint: { ok: true, violations: [] } });
    return prompts;
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
}

test("the Reader is handed the computed list, once, however many chunks the body takes", async () => {
  // Two same-worded <h2>s a long way apart: 40k of filler between them puts them in
  // different CHUNK_BUDGET (24000) windows, which is the case that was previously
  // undetectable no matter what the prompt said.
  const filler = "<p>Fill the hopper and press start.</p>".repeat(1100);
  const prompts = await readerPrompts(`<h2>Operation</h2>${filler}<h2>Operation</h2><p>end</p>`);
  assert.ok(prompts.length > 1, "the body must actually span more than one chunk for this to prove anything");
  const withList = prompts.filter((p) => /Headings with the same words at the same level/.test(p));
  assert.equal(withList.length, 1, "the list belongs to exactly one call, or the same finding arrives twice");
  assert.match(withList[0], /\[Heading 2\] "Operation"/);
});

test("a clean document is not sent a section saying there is nothing", async () => {
  const prompts = await readerPrompts("<h1>Report</h1><h2>One</h2><p>a</p><h2>Two</h2><p>b</p>");
  assert.equal(prompts.length, 1);
  assert.doesNotMatch(prompts[0], /Headings with the same words/);
});

test("a pair nested inside an outer pair is in the list too", () => {
  // #111's own shape: the page reprints the running title AND the header of the
  // subsection that continues under it, so the <h3> pair sits inside the <h2> run.
  // Reporting the run by advancing the cursor to its last heading would jump the whole
  // interval between the two <h2>s and lose the inner pair.
  assert.deepEqual(
    runs("<h2>Op</h2><h3>X</h3><p>a</p><h3>X</h3><p>b</p><h2>Op</h2><h3>Y</h3><p>c</p><h3>Y</h3><p>d</p>"),
    ["h2:Op:2", "h3:X:2", "h3:Y:2"],
  );
});

test("a heading is a member of one run only", () => {
  // The dedupe the cursor jump was there for: three consecutive <h2>Op</h2> are one run
  // of three, not a run of three plus a run of two starting at the second.
  assert.deepEqual(runs("<h2>Op</h2><p>a</p><h2>Op</h2><p>b</p><h2>Op</h2><p>c</p>"), ["h2:Op:3"]);
});

test("an empty heading between the pair ends the run, as any other section would", () => {
  // It names nothing, but it opens a section โ€” so the two headings around it are no
  // longer the pair with nothing but their own content between them. Stated because the
  // opposite reading is tempting: an empty heading announces nothing to a reader.
  assert.deepEqual(runs("<h2>Op</h2><p>a</p><h2></h2><h2>Op</h2><p>b</p>"), []);
});

test("two runs of the same words at the same level are two distinguishable entries", () => {
  // The list the Reader is told never contains a false positive must not contain two
  // lines it cannot tell apart either: read as a restatement, the second pair goes
  // unreported. Each entry is placed by the heading it follows.
  const body =
    "<h2>Op</h2><p>a</p><h2>Op</h2><p>b</p>" +
    "<h2>Other</h2><p>c</p>" +
    "<h2>Op</h2><p>d</p><h2>Op</h2><p>e</p>";
  assert.deepEqual(placed(body), [
    "h2:Op:2 after -",
    "h2:Op:2 after h2:Other",
  ]);
  const note = sameWordedHeadingNote(sameWordedHeadingRuns(body))!;
  assert.equal(new Set(note.split("\n")).size, 2, "the two lines must differ, not just the runs behind them");
});

test("a run is placed by the heading before it whatever that heading's level", () => {
  // The preceding heading is the run's position in the outline, not its parent: after a
  // deeper subsection, that heading is the one the reader last passed.
  assert.deepEqual(
    placed("<h1>Manual</h1><h2>Care</h2><h3>Deep</h3><p>a</p><h2>Op</h2><p>b</p><h2>Op</h2><p>c</p>"),
    ["h2:Op:2 after h3:Deep"],
  );
});

test("two runs whose preceding headings are also alike are still two distinct lines", () => {
  // #111's shape doubled: the page reprints its running title AND the header of the
  // subsection continuing under it, twice over โ€” so both <h3> runs follow an <h2> with the
  // same words, and the heading before them cannot tell them apart. What differs is the
  // content under them, and past that, the numbering.
  const body =
    "<h2>Op</h2><h3>X</h3><p>Fill the hopper.</p><h3>X</h3><p>Press start.</p>" +
    "<h2>Op</h2><h3>X</h3><p>Empty the tray.</p><h3>X</h3><p>Wipe the plate.</p>";
  const out = lines(body);
  assert.equal(out.length, 3, `expected the outer pair and both inner pairs: ${out.join(" / ")}`);
  assert.equal(new Set(out).size, 3, `every line must be distinct: ${out.join(" / ")}`);
  assert.match(out[1], /opening "Fill the hopper\."/);
  assert.match(out[2], /opening "Empty the tray\."/);
});

test("even an outline that repeats exactly leaves the entries numbered apart", () => {
  // Two runs where the preceding heading AND the words underneath match โ€” a page
  // duplicated wholesale. Nothing observable distinguishes them, so the numbering is what
  // is left, and it is enough for the Reader to report two pairs rather than one.
  const body =
    "<h2>Op</h2><h3>X</h3><p>Same words.</p><h3>X</h3><p>Same words.</p>" +
    "<h2>Op</h2><h3>X</h3><p>Same words.</p><h3>X</h3><p>Same words.</p>";
  const out = lines(body);
  assert.equal(new Set(out).size, out.length, `every line must be distinct: ${out.join(" / ")}`);
  assert.match(out[1], /^2\. /);
  assert.match(out[2], /^3\. /);
});

test("the words quoted under a heading stop at the next heading and at its own section", () => {
  // The opening exists to tell two entries apart, so it must be the run's OWN content: a
  // heading with an empty section that borrowed the next section's words would read as
  // distinct when it is not, and as describing content it does not have.
  const empty = sameWordedHeadingRuns("<h2>Op</h2><h3>X</h3><h3>X</h3><p>Under the second.</p>");
  assert.equal(empty.find((r) => r.level === 3)!.opening, "", "the first X has nothing under it");
  const wrapped = sameWordedHeadingRuns(
    "<section><h2>Op</h2><p>Inside.</p></section><section><h2>Op</h2><p>Also inside.</p></section>",
  );
  assert.equal(wrapped.length, 1, "a heading wrapped in its own <section> is still found");
  assert.equal(wrapped[0].opening, "Inside.", "and its opening is its own section's words");
});

test("the opening keeps the word boundaries the markup put there", () => {
  // A heading followed by a spec table or a list is the ordinary shape of the manuals
  // these issues came from. Run together, "SpeedTimeLow2 min" matches no page excerpt and
  // reads as no sentence, so the entry is back to being told apart by its number alone.
  const table = sameWordedHeadingRuns(
    "<h2>Operation</h2><table><tr><th>Speed</th><th>Time</th></tr><tr><td>Low</td><td>2 min</td></tr></table>" +
      "<h2>Operation</h2><p>tail</p>",
  );
  assert.equal(table[0].opening, "Speed Time Low 2 min");

  const list = sameWordedHeadingRuns(
    "<h2>Operation</h2><ul><li>Fill the hopper</li><li>Press start</li></ul><h2>Operation</h2><p>t</p>",
  );
  assert.equal(list[0].opening, "Fill the hopper Press start");

  // A boundary inside a phrase costs nothing, because the spaces of a sentence are in its
  // own text nodes: the separator lands where a space already is, and collapses into it.
  const inline = sameWordedHeadingRuns(
    "<h2>Op</h2><p>Press <strong>start</strong> now.</p><h2>Op</h2><p>t</p>",
  );
  assert.equal(inline[0].opening, "Press start now.");

  // The cases a reader hears a break in and `textContent` does not: a line break, and two
  // adjacent inline elements. Both are how a page prints an address block or a spec pair.
  const broken = sameWordedHeadingRuns(
    "<h2>Op</h2><p>Acme Ltd<br>12 Mill Road<br>Leeds</p><h2>Op</h2><p>t</p>",
  );
  assert.equal(broken[0].opening, "Acme Ltd 12 Mill Road Leeds");

  const spans = sameWordedHeadingRuns(
    "<h2>Op</h2><p><span>Low</span><span>2 min</span></p><h2>Op</h2><p>t</p>",
  );
  assert.equal(spans[0].opening, "Low 2 min");
});

test("the opening quotes content and not a leaked stylesheet", () => {
  // SILENT is flatten's set and the question is the same one in both places โ€” is this text
  // content at all. A rule block read as the section's opening words matches no page
  // excerpt and tells the Reader the section says something it never says.
  const runs = sameWordedHeadingRuns(
    "<h2>Op</h2><style>.x{color:red;font-size:12px}</style><p>Turn the dial.</p><h2>Op</h2><p>t</p>",
  );
  assert.equal(runs[0].opening, "Turn the dial.");

  const nested = sameWordedHeadingRuns(
    "<h2>Op</h2><div><script>var x = 1;</script>Turn the dial.</div><h2>Op</h2><p>t</p>",
  );
  assert.equal(nested[0].opening, "Turn the dial.");
});

test("a section that opens with a symbol is not reported as having nothing under it", () => {
  // "with nothing under it" is an affirmative claim, so it has to be true. A scanned warning
  // page whose content is a pictogram and its alt text is an ordinary shape, and its words
  // are in an attribute rather than a text node (issue #111's manuals are full of them).
  const wrapped = sameWordedHeadingRuns(
    '<h2>Warning</h2><p><img alt="Do not immerse in water"></p><h2>Warning</h2><p>t</p>',
  );
  assert.equal(wrapped[0].opening, "Do not immerse in water");

  const bare = sameWordedHeadingRuns(
    '<h2>Warning</h2><img alt="Do not immerse in water"><h2>Warning</h2><p>t</p>',
  );
  assert.equal(bare[0].opening, "Do not immerse in water");

  // Decorative means unannounced, so an empty alt stays empty rather than falling through
  // to some other attribute.
  const decorative = sameWordedHeadingRuns('<h2>Op</h2><p><img alt=""></p><h2>Op</h2><p>t</p>');
  assert.equal(decorative[0].opening, "");

  // And an element whose subtree DID say something keeps its own words: a title attribute
  // is a fallback for silence, not an addition to speech.
  const titled = sameWordedHeadingRuns(
    '<h2>Op</h2><p><a title="Manual" href="#">Read this</a></p><h2>Op</h2><p>t</p>',
  );
  assert.equal(titled[0].opening, "Read this");
});

test("a tooltip on a container is not words the section opens with", () => {
  // The fallback exists because an attribute can hold what a reader hears. On a bare <div>
  // or <span> a `title` is a mouse tooltip and nothing hears it, so quoting it would put
  // words in the opening that appear nowhere in the page the Reader is matching against.
  const div = sameWordedHeadingRuns(
    '<h2>Op</h2><div title="tooltip only"></div><p>Real words.</p><h2>Op</h2><p>t</p>',
  );
  assert.equal(div[0].opening, "Real words.");
  const span = sameWordedHeadingRuns(
    '<h2>Op</h2><span title="hint"></span><p>Words.</p><h2>Op</h2><p>t</p>',
  );
  assert.equal(span[0].opening, "Words.");

  // A field, by contrast, takes its name from exactly there.
  const field = sameWordedHeadingRuns(
    '<h2>Op</h2><p><input aria-label="Serial number"></p><h2>Op</h2><p>t</p>',
  );
  assert.equal(field[0].opening, "Serial number");
});

test("a control announces its name and its subtree, and one name at that", () => {
  // A `title` is what a reader hears when there is no `aria-label`, and is not heard at all
  // when there is one, so reading both would put a word in the opening that the page never
  // says. flatten's `??` chain is the one being matched here, empty aria-label included.
  const both = sameWordedHeadingRuns(
    '<h2>Op</h2><p><input type="text" aria-label="Serial" title="Enter the serial"></p><h2>Op</h2><p>t</p>',
  );
  assert.equal(both[0].opening, "Serial");
  const emptied = sameWordedHeadingRuns(
    '<h2>Op</h2><p><input type="text" aria-label="" title="Tip"></p><h2>Op</h2><p>t</p>',
  );
  assert.equal(emptied[0].opening, "");

  // And a control is the one element whose attributes are read even though its subtree spoke:
  // a named <select> announces the name AND the options, which is how flatten reads it too.
  const select = sameWordedHeadingRuns(
    '<h2>Op</h2><p><select aria-label="Size"><option>Low</option><option>High</option></select></p>' +
      "<h2>Op</h2><p>t</p>",
  );
  assert.equal(select[0].opening, "Size Low High");
  for (const word of ["Size", "Low", "High"]) {
    assert.ok(
      flatten('<p><select aria-label="Size"><option>Low</option><option>High</option></select></p>').includes(word),
      `flatten announces ${word} too`,
    );
  }
});

test("a field labelled only by its placeholder or value is not reported as nothing", () => {
  // A placeholder used as a label is itself a defect the Reader is told to report, so a
  // section holding one is the last to assert is empty. flatten reads the same attributes
  // for the same reason, and the words โ€” not its [Field] marker โ€” are what must match.
  for (const [markup, words] of [
    ['<input type="text" placeholder="Serial number">', "Serial number"],
    ['<input type="submit" value="Start grinding">', "Start grinding"],
    ['<input type="text" placeholder="e.g. 12345" value="99">', "e.g. 12345 99"],
  ] as [string, string][]) {
    const runs = sameWordedHeadingRuns(`<h2>Op</h2><p>${markup}</p><h2>Op</h2><p>t</p>`);
    assert.equal(runs[0].opening, words, markup);
    assert.ok(flatten(`<p>${markup}</p>`).includes(words), `flatten agrees on the words: ${markup}`);
  }
});

test("an image with no alt at all is not reported as nothing", () => {
  // "with nothing under it" would be an assertion of emptiness about precisely the section
  // the pipeline has a finding about. A missing alt is not the same as alt="": the second
  // announces nothing on purpose, so it stays empty.
  const missing = sameWordedHeadingRuns('<h2>Op</h2><p><img src="x.png"></p><h2>Op</h2><p>t</p>');
  assert.equal(missing[0].opening, "[Image] [alt missing]");
  // And the wording is flatten's, so the Reader โ€” which sees both views โ€” is not given two
  // descriptions of one image. This assertion is what keeps the two in step.
  assert.match(flatten('<p><img src="x.png"></p>'), /\[Image\] \[alt missing\]/);

  const decorative = sameWordedHeadingRuns('<h2>Op</h2><p><img alt=""></p><h2>Op</h2><p>t</p>');
  assert.equal(decorative[0].opening, "");

  // With no alt, the name a reader hears is the aria-label or title, as flatten reads it too.
  const named = sameWordedHeadingRuns(
    '<h2>Op</h2><p><img src="x.png" title="Diagram"></p><h2>Op</h2><p>t</p>',
  );
  assert.equal(named[0].opening, "Diagram");
});

test("words the extractor left unwrapped are still the section's opening words", () => {
  // A paragraph that never got a <p> is a bare text node under the body, and walking only
  // element siblings skipped it โ€” reporting "nothing under it" about a section with content.
  const runs = sameWordedHeadingRuns("<h2>Op</h2>Loose text under it.<h2>Op</h2><p>t</p>");
  assert.equal(runs[0].opening, "Loose text under it.");
});

test("a twin wrapped in its own section does not lend its words to an empty one", () => {
  // The run finder flattens the tree, so these two headings are a pair; the second is not
  // a SIBLING of the first, so stopping only at sibling headings would quote the wrapped
  // twin's content for a heading that has none โ€” worse than a collision, since the Reader
  // is told these words locate the run.
  const runs = sameWordedHeadingRuns("<h3>Care</h3><section><h3>Care</h3><p>Wipe the plate.</p></section>");
  assert.equal(runs.length, 1, "the pair is still found across the wrapper");
  assert.equal(runs[0].opening, "", "the first Care has nothing under it");
});

test("a long opening is truncated rather than pasted into the prompt whole", () => {
  const long = sameWordedHeadingRuns(
    `<h2>Op</h2><p>${"word ".repeat(60)}</p><h2>Op</h2><p>t</p>`,
  );
  assert.ok(long[0].opening.length <= 81, `capped, got ${long[0].opening.length}`);
  assert.match(long[0].opening, /โ€ฆ$/);
});