paperlint 14.0.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The content blocks (`situation`, `excerpt`, `references`, `more`, `table`, `repeat`) and the
3
+ * page's `labels`, on a fixture that is not about linters at all (a code review): the core's checks,
4
+ * both ways, and what a card shows, in the fake DOM. Phone layout and saving are in
5
+ * label-page-browser.test.ts; the linter preset is in label-page-presets.test.ts.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { test } from "vitest";
11
+ import { z } from "zod";
12
+ import {
13
+ build,
14
+ openLive,
15
+ run,
16
+ setup,
17
+ showPage,
18
+ } from "../../../test/label-page-kit.ts";
19
+
20
+ const Fixture = z
21
+ .object({
22
+ practice: z.array(
23
+ z.object({ expected: z.record(z.string(), z.string()) }).loose(),
24
+ ),
25
+ items: z.array(z.record(z.string(), z.unknown())),
26
+ })
27
+ .loose();
28
+ /** A code-review page: every word on its cards comes from this file, none from the generator. */
29
+ const FIXTURE = Fixture.parse(
30
+ JSON.parse(
31
+ readFileSync(
32
+ join(import.meta.dirname, "fixtures", "blocks-items.json"),
33
+ "utf8",
34
+ ),
35
+ ),
36
+ );
37
+ const page = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
38
+ ...FIXTURE,
39
+ ...over,
40
+ });
41
+ /** The fixture's items with `change` applied to the first one. */
42
+ const firstItem = (
43
+ change: (i: Record<string, unknown>) => Record<string, unknown>,
44
+ ) => page({ items: FIXTURE.items.map((i, n) => (n === 0 ? change(i) : i)) });
45
+ const PRACTICE_DONE = Object.fromEntries(
46
+ FIXTURE.practice.map((p, n) => [
47
+ `r1-P${String(n + 1)}`,
48
+ { answers: p.expected, source: "page" },
49
+ ]),
50
+ );
51
+ const [FIRST] = FIXTURE.items;
52
+ assert.ok(FIRST !== undefined);
53
+ const Excerpt = z
54
+ .object({ lines: z.array(z.record(z.string(), z.unknown())) })
55
+ .loose();
56
+ const firstExcerpt = Excerpt.parse(FIRST["excerpt"]);
57
+ const withMarkedLine = (text: string) => ({
58
+ ...firstExcerpt,
59
+ lines: firstExcerpt.lines.map((l) =>
60
+ l["mark"] === true ? { ...l, text } : l,
61
+ ),
62
+ });
63
+ const LEAK =
64
+ "the source id 'review-sql-1' appears in the visible `FIELD` of item review-sql-1. Take it out of the text (or use source ids that are not natural words).";
65
+
66
+ /** Pages the core refuses, each with its whole refusal. */
67
+ const REFUSED: readonly (readonly [string, Record<string, unknown>, string])[] =
68
+ [
69
+ [
70
+ "blocks-no-mark",
71
+ firstItem((i) => ({
72
+ ...i,
73
+ excerpt: {
74
+ ...firstExcerpt,
75
+ lines: firstExcerpt.lines.map((l) => ({ ...l, mark: false })),
76
+ },
77
+ })),
78
+ "items[0].excerpt.lines: no line is marked — mark the line the card is about (`mark: true`)",
79
+ ],
80
+ [
81
+ "blocks-line-no-excerpt",
82
+ firstItem((i) =>
83
+ Object.fromEntries(Object.entries(i).filter(([k]) => k !== "excerpt")),
84
+ ),
85
+ "items[0].situation.summary: `{line}` names the excerpt's marked line, and the card has no `excerpt` — add it, or write the summary without `{line}`",
86
+ ],
87
+ [
88
+ "blocks-source-in-reference",
89
+ firstItem((i) => ({
90
+ ...i,
91
+ references: {
92
+ summary: "s",
93
+ entries: [
94
+ { title: "t", fields: [{ text: "drawn for review-sql-1" }] },
95
+ ],
96
+ },
97
+ })),
98
+ LEAK.replace("FIELD", "references"),
99
+ ],
100
+ [
101
+ "blocks-source-after-newline",
102
+ firstItem((i) => ({
103
+ ...i,
104
+ table: {
105
+ summary: "s",
106
+ rows: [{ label: "l", value: "v", plain: "one\nreview-sql-1" }],
107
+ },
108
+ })),
109
+ LEAK.replace("FIELD", "table"),
110
+ ],
111
+ [
112
+ "blocks-source-after-tab",
113
+ firstItem((i) => ({ ...i, excerpt: withMarkedLine("\treview-sql-1") })),
114
+ LEAK.replace("FIELD", "excerpt"),
115
+ ],
116
+ [
117
+ "blocks-repeat-range",
118
+ firstItem((i) => ({ ...i, repeat: { group: "g", index: 3, of: 2 } })),
119
+ "items[0].repeat.index: 3 is not between 1 and 2",
120
+ ],
121
+ [
122
+ "blocks-mark-tone",
123
+ firstItem((i) => ({
124
+ ...i,
125
+ table: {
126
+ summary: "s",
127
+ rows: [
128
+ { label: "l", value: "v", mark: { tone: "maybe", text: "?" } },
129
+ ],
130
+ },
131
+ })),
132
+ "items[0].table.rows[0].mark.tone: 'maybe' must be yes or no",
133
+ ],
134
+ ];
135
+
136
+ test("🔴 the blocks refuse an excerpt with no marked line, `{line}` without an excerpt, a source id in any rendered string (a reference, after a newline, after a tab), a repeat out of range and an unknown mark tone", () => {
137
+ assert.deepEqual(
138
+ REFUSED.map(([name, cfg]) => {
139
+ const dir = setup(name, cfg);
140
+ return run(["build", join(dir, "items.json"), join(dir, "out")]);
141
+ }),
142
+ REFUSED.map(([, , message]) => ({
143
+ status: 1,
144
+ out: `REFUSED: ${message}\n`,
145
+ })),
146
+ );
147
+ });
148
+
149
+ test("a page of blocks builds with no preset; no source id and no repeat group reaches the page", () => {
150
+ const { page: html, key } = build(setup("blocks-fixture", page()));
151
+ assert.equal(Object.keys(key.ids).length, FIXTURE.items.length);
152
+ // Guards: a repeat's group is private like the source id.
153
+ assert.deepEqual(
154
+ ["review-sql-1", "guide-sql"].filter((s) => html.includes(s)),
155
+ [],
156
+ );
157
+ assert.ok(html.includes('"repeat": {"index": 1, "of": 2}'));
158
+ });
159
+
160
+ test("🔴 a repeat whose group is the item's own source id builds: the group never reaches the page, so it is no leak", () => {
161
+ const { page: html } = build(
162
+ setup(
163
+ "blocks-repeat-own-id",
164
+ firstItem((i) => ({
165
+ ...i,
166
+ repeat: { group: "review-sql-1", index: 1, of: 2 },
167
+ })),
168
+ ),
169
+ );
170
+ assert.ok(html.includes('"repeat": {"index": 1, "of": 2}'));
171
+ });
172
+
173
+ /** What the page shows, with the fake DOM's line breaks between elements taken out. */
174
+ const flat = async (
175
+ cfg: Record<string, unknown>,
176
+ name: string,
177
+ practice: Record<string, unknown> = PRACTICE_DONE,
178
+ ) => {
179
+ const { page: html, key } = build(setup(name, cfg));
180
+ return {
181
+ shown: (await showPage(html, {}, {}, practice)).replace(/\n/g, ""),
182
+ key,
183
+ };
184
+ };
185
+
186
+ test("🔴 `{line}` becomes «line N: » and the marked line's code, a backtick kept; a text without it is kept as written", async () => {
187
+ const { shown } = await flat(page(), "blocks-line");
188
+ assert.deepEqual(
189
+ [
190
+ 'Does «Every query is parameterised» cover line 12: rows = db.raw("SELECT * FROM sales WHERE m=" + month)?',
191
+ "Does «Every query is parameterised» cover line 30: q = `SELECT * FROM items WHERE name LIKE '${term}'`?",
192
+ "Does «Functions stay short» cover this function?",
193
+ ].filter((t) => !shown.includes(t)),
194
+ [],
195
+ );
196
+ assert.ok(!shown.includes("{line}"));
197
+ });
198
+
199
+ /** `text` with every given part present, in this order. */
200
+ const inOrder = (text: string, parts: readonly string[]): readonly string[] => {
201
+ const at = parts.map((p) => text.indexOf(p));
202
+ return parts.filter(
203
+ (p, n) => at[n] === -1 || (at[n - 1] ?? -1) > (at[n] ?? 0),
204
+ );
205
+ };
206
+
207
+ test("🔴 a card reads top to bottom: its place and body in the page's words, the repeat, the situation, the excerpt with its marked line labelled and its caption, the question and its answers, then the references, the list and the table, each folded", async () => {
208
+ const { page: html, key } = build(setup("blocks-face", page()));
209
+ const shown = await showPage(html, {}, {}, PRACTICE_DONE);
210
+ const id = Object.entries(key.ids).find(
211
+ ([, v]) => v.source_id === "review-sql-1",
212
+ )?.[0];
213
+ assert.ok(id !== undefined);
214
+ const from = shown.indexOf(`\nComment ${String(Number(id.slice(1)))} of 3`);
215
+ const rest = shown.slice(from + 1).search(/\nComment \d+ of 3/);
216
+ const next = rest === -1 ? -1 : from + 1 + rest;
217
+ const card = shown.slice(from, next === -1 ? undefined : next);
218
+ // Guards: every part in place; an empty list means nothing is missing or out of order.
219
+ assert.deepEqual(
220
+ inOrder(card, [
221
+ "Guideline",
222
+ "Every query is parameterised",
223
+ "Comment 1 of 2 on this guideline",
224
+ "Reviewer A asked for a change at",
225
+ "app/report.py",
226
+ "monthly(month)",
227
+ "▶",
228
+ "◀ the line in question",
229
+ "Reviewer A wrote: concatenated SQL.",
230
+ "Does «Every query is parameterised» cover",
231
+ "Unclear",
232
+ "What the guideline says",
233
+ "Security guide",
234
+ "SQL-1",
235
+ "Build every query with parameters",
236
+ "Why: A query built from strings",
237
+ "The examples below are the guide's own.",
238
+ "Not like this",
239
+ "Like this",
240
+ "3 more lines like this",
241
+ "app/export.py",
242
+ "and 1 more",
243
+ "What each reviewer wrote",
244
+ "Reviewer A asked for the change; reviewer B approved.",
245
+ "A only · Comment",
246
+ "use parameters",
247
+ "asks for a parameterised query",
248
+ "✓ about this line",
249
+ "B only · Approval",
250
+ "✗ says nothing about this line",
251
+ ]),
252
+ [],
253
+ );
254
+ // Guards: no word of the generator's own on a card.
255
+ assert.deepEqual(
256
+ ["linter", "Setting", "Rule docs", "◀ this line", "Card "].filter((w) =>
257
+ card.includes(w),
258
+ ),
259
+ [],
260
+ );
261
+ });
262
+
263
+ test("🔴 the page's labels: a practice button's example carries `practice_example`; without labels a card shows its id and the default repeat", async () => {
264
+ const { shown } = await flat(page(), "blocks-labels", {});
265
+ assert.ok(
266
+ shown.includes("Another case: «No string-built SQL» and an f-string query"),
267
+ );
268
+ const bare = Object.fromEntries(
269
+ Object.entries(page()).filter(([k]) => k !== "labels"),
270
+ );
271
+ const plain = await flat(bare, "blocks-no-labels");
272
+ assert.deepEqual(
273
+ [
274
+ /G00\d · \d of 3/.test(plain.shown),
275
+ plain.shown.includes("Card 1 of 2"),
276
+ plain.shown.includes("GuidelineEvery query is parameterised"),
277
+ plain.shown.includes("Another case"),
278
+ ],
279
+ [true, true, false, false],
280
+ );
281
+ });
282
+
283
+ test("🔴 once the practice is done, «How to answer» folds into a toggle; with every card answered the page says so and what to do next", async () => {
284
+ const { page: html, key } = build(setup("blocks-howto", page()));
285
+ const folded = async (practice: Record<string, unknown>) => {
286
+ const { app } = await openLive(html, { answers: {}, groups: {}, practice });
287
+ return app
288
+ .all()
289
+ .filter((n) => n.tag === "details")
290
+ .map((n) => n.children[0]?.textContent);
291
+ };
292
+ assert.ok(!(await folded({})).includes("📖 How to answer"));
293
+ assert.ok((await folded(PRACTICE_DONE)).includes("📖 How to answer"));
294
+ const all = Object.fromEntries(
295
+ Object.keys(key.ids).map((id) => [
296
+ id,
297
+ { answers: { applies: "yes" }, source: "page" },
298
+ ]),
299
+ );
300
+ const { app } = await openLive(html, {
301
+ answers: all,
302
+ groups: {},
303
+ practice: PRACTICE_DONE,
304
+ });
305
+ assert.match(
306
+ app.textContent,
307
+ /All 3 items are complete\. Tell the agent you are done; you can still change any answer\./,
308
+ );
309
+ });
310
+
311
+ test("🔴 `labels.line_part` names a unit row whose line holds several requirements", async () => {
312
+ const unit = {
313
+ id: "u1",
314
+ text: "Short functions; no globals.",
315
+ mark: "Short functions",
316
+ part: 1,
317
+ of: 2,
318
+ };
319
+ const cfg = firstItem((i) => ({ ...i, unit }));
320
+ const own = await flat(
321
+ { ...cfg, labels: { line: "Source", line_part: "{line} ({part}/{of})" } },
322
+ "blocks-line-part",
323
+ );
324
+ const plain = await flat(
325
+ Object.fromEntries(Object.entries(cfg).filter(([k]) => k !== "labels")),
326
+ "blocks-line-part-default",
327
+ );
328
+ assert.deepEqual(
329
+ [
330
+ own.shown.includes("Source (1/2)"),
331
+ plain.shown.includes("Line · part 1 of 2"),
332
+ ],
333
+ [true, true],
334
+ );
335
+ });
@@ -10,21 +10,30 @@ import {
10
10
  type Locator,
11
11
  type Page,
12
12
  } from "playwright-core";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
13
15
  import { afterAll, test } from "vitest";
14
16
  import { z } from "zod";
15
17
  import { chromiumPath } from "../../../test/chromium.ts";
16
18
  import {
17
19
  APPROVAL_PRACTICE,
20
+ PRACTICE,
21
+ QUESTIONS,
18
22
  approval,
19
23
  build,
24
+ config,
20
25
  setup,
21
26
  } from "../../../test/label-page-kit.ts";
22
27
 
23
28
  /** A store that keeps every write and echoes it back as a snapshot after `echoMs`. */
24
- const STORE = (practice: Record<string, unknown>, echoMs: number): string => `
29
+ const STORE = (
30
+ practice: Record<string, unknown>,
31
+ echoMs: number,
32
+ answers: Record<string, unknown> = {},
33
+ ): string => `
25
34
  window.__writes = [];
26
35
  (function () {
27
- var docs = { answers: {}, groups: {}, practice: ${JSON.stringify(practice)} };
36
+ var docs = { answers: ${JSON.stringify(answers)}, groups: {}, practice: ${JSON.stringify(practice)} };
28
37
  var subs = {};
29
38
  function snap(n) {
30
39
  (subs[n] || []).forEach(function (cb) {
@@ -175,6 +184,35 @@ test(
175
184
  BROWSER_TEST_MS,
176
185
  );
177
186
 
187
+ test(
188
+ "🔴 an answer that needs a reason is not saved while its reason is empty; another answer replaces it unsaved",
189
+ async () => {
190
+ await onApprovalPage("browser-refuse", 20, async (page) => {
191
+ await page.waitForTimeout(1200); // longer than the reason's pause: nothing may arrive
192
+ assert.deepEqual(await writes(page), []);
193
+ assert.match(
194
+ (await page.locator("article.item").first().textContent()) ?? "",
195
+ /Not saved yet/,
196
+ );
197
+ // Leaving the page with the box empty saves nothing either.
198
+ await page.evaluate(() => {
199
+ window.dispatchEvent(new Event("pagehide"));
200
+ });
201
+ await page.waitForTimeout(100);
202
+ assert.deepEqual(await writes(page), []);
203
+ await page.getByRole("button", { name: /Keep/ }).first().click();
204
+ await page.waitForTimeout(100);
205
+ assert.deepEqual(
206
+ await page.evaluate(
207
+ "window.__writes.map(function (w) { return w.answers; })",
208
+ ),
209
+ [{ keep: "keep" }],
210
+ );
211
+ });
212
+ },
213
+ BROWSER_TEST_MS,
214
+ );
215
+
178
216
  test(
179
217
  "a Keep needs no reason and writes none: the page stays quiet",
180
218
  async () => {
@@ -280,3 +318,291 @@ test(
280
318
  },
281
319
  BROWSER_TEST_MS,
282
320
  );
321
+
322
+ const WITNESS_FIXTURE = join(
323
+ import.meta.dirname,
324
+ "fixtures",
325
+ "linter-config-witness-items.json",
326
+ );
327
+ /** The generic witness page, every practice card answered as expected, at phone width. */
328
+ async function onWitnessPage(
329
+ name: string,
330
+ steps: (page: Page) => Promise<void>,
331
+ practiceDone = true,
332
+ ): Promise<void> {
333
+ const cfg = z
334
+ .object({
335
+ practice: z.array(
336
+ z.object({ expected: z.record(z.string(), z.string()) }).loose(),
337
+ ),
338
+ })
339
+ .loose()
340
+ .parse(JSON.parse(readFileSync(WITNESS_FIXTURE, "utf8")));
341
+ const done = Object.fromEntries(
342
+ cfg.practice.map((p, n) => [
343
+ `r1-P${String(n + 1)}`,
344
+ { answers: p.expected, source: "page" },
345
+ ]),
346
+ );
347
+ const { page: html } = build(setup(name, cfg));
348
+ const page = await (
349
+ await sharedBrowser()
350
+ ).newPage({ viewport: { width: 400, height: 900 } });
351
+ try {
352
+ await page.addInitScript(STORE(practiceDone ? done : {}, 20));
353
+ await page.goto("about:blank");
354
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
355
+ await steps(page);
356
+ } finally {
357
+ await page.context().close();
358
+ }
359
+ }
360
+
361
+ /** The code, the linter's message, the question, the rule docs' toggle: one read of the laid-out card,
362
+ * so a re-render between reads cannot mix two layouts. */
363
+ const LAYOUT_OF = ["pre.numbered", "p.said", ".ask", "details > summary"];
364
+ const Layout = z.array(z.object({ top: z.number(), bottom: z.number() }));
365
+ const layout = async (card: Locator) =>
366
+ Layout.parse(
367
+ await card.evaluate(
368
+ (e, sels) =>
369
+ sels.map((s) => {
370
+ const r = e.querySelector(s)?.getBoundingClientRect();
371
+ return { top: r?.top ?? NaN, bottom: r?.bottom ?? NaN };
372
+ }),
373
+ LAYOUT_OF,
374
+ ),
375
+ );
376
+
377
+ test(
378
+ "🔴 at phone width a witness card labels the caught line in its numbered code, asks its question right under that code, folds the rule docs and the settings, opens a long text on «more», and nothing pushes the page sideways",
379
+ async () => {
380
+ await onWitnessPage("browser-witness", async (page) => {
381
+ // Measure after the store's practice answers arrived: before them the practice cards sit above.
382
+ await page.locator("summary", { hasText: "How to answer" }).waitFor();
383
+ const card = page.locator("article.item", { hasText: "seed_db.py" });
384
+ // The marked line reads as marked without colour: a gutter marker and a label.
385
+ assert.equal(
386
+ await card.locator("pre.numbered .hit").textContent(),
387
+ '▶18print(f"seeded {len(rows)} rows")◀ this line',
388
+ );
389
+ // Guards: the rule docs, the files like it and the settings are folded.
390
+ assert.deepEqual(
391
+ [
392
+ await card.locator("details").count(),
393
+ await card.locator("details[open]").count(),
394
+ ],
395
+ [3, 0],
396
+ );
397
+ // The question sits right under the code it is about: only the linter's message between them,
398
+ // and the rule's docs start below the answers.
399
+ const docsSummary = card.locator("summary", {
400
+ hasText: "What the linter rule does (official docs)",
401
+ });
402
+ const box = await layout(card);
403
+ const [code, said, ask, docs] = box;
404
+ assert.ok(code && said && ask && docs, JSON.stringify(box));
405
+ assert.ok(
406
+ said.top >= code.bottom && ask.top >= said.bottom,
407
+ JSON.stringify(box),
408
+ );
409
+ assert.ok(ask.top - said.bottom < 40, JSON.stringify(box));
410
+ assert.ok(docs.top >= ask.bottom, JSON.stringify(box));
411
+ await docsSummary.click();
412
+ const why = card.locator(".clamped", { hasText: "Why:" });
413
+ const folded = (await why.boundingBox())?.height ?? 0;
414
+ await card.getByRole("button", { name: "more" }).first().click();
415
+ const opened = await card.locator("p", { hasText: "Why:" }).boundingBox();
416
+ assert.ok(
417
+ opened && opened.height > folded,
418
+ `${String(folded)} → ${String(opened?.height)}`,
419
+ );
420
+ assert.equal(
421
+ await page.evaluate(() => document.documentElement.scrollWidth),
422
+ 400,
423
+ );
424
+ });
425
+ },
426
+ BROWSER_TEST_MS,
427
+ );
428
+
429
+ test(
430
+ "🔴 a witness practice card's answer buttons say their example is about other code",
431
+ async () => {
432
+ await onWitnessPage(
433
+ "browser-witness-buttons",
434
+ async (page) => {
435
+ const practice = page.locator("article.item").first();
436
+ assert.deepEqual(
437
+ [
438
+ await practice.locator(".id").textContent(),
439
+ await practice.locator(".opts button").count(),
440
+ await practice.locator(".opts .ex").count(),
441
+ await practice.locator(".opts .ex").first().textContent(),
442
+ ],
443
+ [
444
+ "Practice P1",
445
+ 4,
446
+ 4,
447
+ "Example of this answer, about other code: «Use ===» and the line a == b: yes, the requirement forbids it",
448
+ ],
449
+ );
450
+ },
451
+ false,
452
+ );
453
+ },
454
+ BROWSER_TEST_MS,
455
+ );
456
+
457
+ test(
458
+ "🔴 under 'Next unanswered' the card just answered stays, with Change, until the next card is answered",
459
+ async () => {
460
+ await onWitnessPage("browser-witness-next", async (page) => {
461
+ await page.getByRole("button", { name: "Next unanswered" }).click();
462
+ const cards = page.locator("article.item");
463
+ assert.equal(await cards.count(), 3);
464
+ await cards.nth(0).getByRole("button", { name: "Yes" }).click();
465
+ await page.waitForTimeout(100);
466
+ assert.equal(await cards.count(), 3, "the answered card is still there");
467
+ assert.equal(
468
+ await cards.nth(0).getByRole("button", { name: "Change" }).count(),
469
+ 1,
470
+ );
471
+ await cards.nth(1).getByRole("button", { name: "Yes" }).click();
472
+ await page.waitForTimeout(100);
473
+ assert.equal(await cards.count(), 2, "the first one has gone");
474
+ });
475
+ },
476
+ BROWSER_TEST_MS,
477
+ );
478
+
479
+ test(
480
+ "🔴 on a page of two questions, an answer still waiting for its reason is in no write, even when the next question is answered",
481
+ async () => {
482
+ const [label, boundary] = QUESTIONS;
483
+ assert.ok(label !== undefined && boundary !== undefined);
484
+ const withWhy = {
485
+ ...label,
486
+ options: label.options.map((o) =>
487
+ o.value === "no" ? { ...o, ask_why: "Why not?" } : o,
488
+ ),
489
+ };
490
+ const { page: html } = build(
491
+ setup("browser-pending-two", config({ questions: [withWhy, boundary] })),
492
+ );
493
+ const done = Object.fromEntries(
494
+ PRACTICE.map((p, n) => [
495
+ `r1-P${String(n + 1)}`,
496
+ { answers: p.expected, source: "page" },
497
+ ]),
498
+ );
499
+ const page = await (await sharedBrowser()).newPage();
500
+ try {
501
+ await page.addInitScript(STORE(done, 20));
502
+ await page.goto("about:blank");
503
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
504
+ const card = page.locator("article.item").filter({ hasText: "G001" });
505
+ await card.getByRole("button", { name: "No", exact: true }).click();
506
+ await card.getByRole("button", { name: "Clear-cut" }).click();
507
+ await page.waitForTimeout(100);
508
+ // Guards: the reasonless «no» never reaches the store; the answer without a reason does.
509
+ assert.deepEqual(
510
+ (await writes(page)).map((w) => w["answers"]),
511
+ [{ boundary: "clear" }],
512
+ );
513
+ await card.locator("textarea").fill("the output is out of order");
514
+ await card.locator("textarea").blur();
515
+ await page.waitForTimeout(100);
516
+ assert.deepEqual((await writes(page)).at(-1), {
517
+ ...(await writes(page)).at(-1),
518
+ answers: { label: "no", boundary: "clear" },
519
+ reasons: { label: "the output is out of order" },
520
+ });
521
+ } finally {
522
+ await page.context().close();
523
+ }
524
+ },
525
+ BROWSER_TEST_MS,
526
+ );
527
+
528
+ test(
529
+ "🔴 a saved answer changed to one still waiting for its reason stays saved when another question is answered",
530
+ async () => {
531
+ const [label, boundary] = QUESTIONS;
532
+ assert.ok(label !== undefined && boundary !== undefined);
533
+ const withWhy = {
534
+ ...label,
535
+ options: label.options.map((o) =>
536
+ o.value === "no" ? { ...o, ask_why: "Why not?" } : o,
537
+ ),
538
+ };
539
+ const { page: html } = build(
540
+ setup(
541
+ "browser-pending-saved",
542
+ config({ questions: [withWhy, boundary] }),
543
+ ),
544
+ );
545
+ const done = Object.fromEntries(
546
+ PRACTICE.map((p, n) => [
547
+ `r1-P${String(n + 1)}`,
548
+ { answers: p.expected, source: "page" },
549
+ ]),
550
+ );
551
+ const page = await (await sharedBrowser()).newPage();
552
+ try {
553
+ await page.addInitScript(
554
+ STORE(done, 20, {
555
+ G001: {
556
+ answers: { label: "yes", boundary: "clear" },
557
+ reasons: {},
558
+ source: "page",
559
+ },
560
+ }),
561
+ );
562
+ await page.goto("about:blank");
563
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
564
+ const card = page.locator("article.item").filter({ hasText: "G001" });
565
+ await card.getByRole("button", { name: "Change" }).first().click();
566
+ await card.getByRole("button", { name: "No", exact: true }).click();
567
+ await card.getByRole("button", { name: "Change" }).last().click();
568
+ await card.getByRole("button", { name: "Boundary" }).click();
569
+ await page.waitForTimeout(100);
570
+ // Guards: the store keeps «yes» until the reason for «no» is typed; the new boundary is saved.
571
+ assert.deepEqual(
572
+ (await writes(page)).map((w) => w["answers"]),
573
+ [{ label: "yes", boundary: "boundary" }],
574
+ );
575
+ } finally {
576
+ await page.context().close();
577
+ }
578
+ },
579
+ BROWSER_TEST_MS,
580
+ );
581
+
582
+ test(
583
+ "🔴 a stored answer to a question the page no longer asks does not stop the next answer from saving",
584
+ async () => {
585
+ const { page: html } = build(setup("browser-stale-question", approval()));
586
+ const page = await (await sharedBrowser()).newPage();
587
+ try {
588
+ await page.addInitScript(
589
+ STORE(PRACTICE_DONE, 20, {
590
+ G001: { answers: { renamed: "x" }, source: "page" },
591
+ }),
592
+ );
593
+ await page.goto("about:blank");
594
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
595
+ const card = page.locator("article.item").filter({ hasText: "G001" });
596
+ await card.getByRole("button", { name: /Keep/ }).click();
597
+ await page.waitForTimeout(100);
598
+ // Guards: the answer is written, and the stored answer it does not know is kept as it was.
599
+ assert.deepEqual(
600
+ (await writes(page)).map((w) => w["answers"]),
601
+ [{ renamed: "x", keep: "keep" }],
602
+ );
603
+ } finally {
604
+ await page.context().close();
605
+ }
606
+ },
607
+ BROWSER_TEST_MS,
608
+ );