paperlint 14.2.0 → 14.3.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.
- package/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +27 -2
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +5 -3
- package/skills/build-label-page/SKILL.md.spec.ts +4 -2
- package/skills/build-label-page/references/closed-choice-generator.md +44 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +452 -10
- package/skills/build-label-page/scripts/label-page-core.js +105 -9
- package/skills/build-label-page/scripts/label-page-core.test.ts +25 -1
- package/skills/build-label-page/scripts/label-page-core.ts +178 -10
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/page-template.html +292 -53
|
@@ -8,8 +8,10 @@ import assert from "node:assert/strict";
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { test } from "vitest";
|
|
11
|
+
import * as R from "remeda";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import {
|
|
14
|
+
type FakeNode,
|
|
13
15
|
build,
|
|
14
16
|
openLive,
|
|
15
17
|
run,
|
|
@@ -204,7 +206,7 @@ const inOrder = (text: string, parts: readonly string[]): readonly string[] => {
|
|
|
204
206
|
);
|
|
205
207
|
};
|
|
206
208
|
|
|
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
|
|
209
|
+
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 in place", async () => {
|
|
208
210
|
const { page: html, key } = build(setup("blocks-face", page()));
|
|
209
211
|
const shown = await showPage(html, {}, {}, PRACTICE_DONE);
|
|
210
212
|
const id = Object.entries(key.ids).find(
|
|
@@ -333,3 +335,277 @@ test("🔴 `labels.line_part` names a unit row whose line holds several requirem
|
|
|
333
335
|
[true, true],
|
|
334
336
|
);
|
|
335
337
|
});
|
|
338
|
+
|
|
339
|
+
// ── the approved card (#392): quote, terms with notes, answer sub-lines, the annotated excerpt ──
|
|
340
|
+
const V7 = z
|
|
341
|
+
.object({ items: z.array(z.record(z.string(), z.unknown())) })
|
|
342
|
+
.loose()
|
|
343
|
+
.parse(
|
|
344
|
+
JSON.parse(
|
|
345
|
+
readFileSync(
|
|
346
|
+
join(import.meta.dirname, "fixtures", "v7-cards-items.json"),
|
|
347
|
+
"utf8",
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
/** The v7 fixture without its time on screen, its first card changed by `change`. */
|
|
352
|
+
const v7 = (
|
|
353
|
+
change: (
|
|
354
|
+
i: Record<string, unknown>,
|
|
355
|
+
) => Record<string, unknown> = R.identity(),
|
|
356
|
+
): Record<string, unknown> => ({
|
|
357
|
+
...R.omit(V7, ["min_seconds"]),
|
|
358
|
+
items: V7.items.map((i, n) => (n === 0 ? change(i) : i)),
|
|
359
|
+
});
|
|
360
|
+
const V7_DONE = Object.fromEntries(
|
|
361
|
+
["forbid", "allow", "elsewhere"].map((v, n) => [
|
|
362
|
+
`r1-P${String(n + 1)}`,
|
|
363
|
+
{ answers: { forbid: v }, source: "page" },
|
|
364
|
+
]),
|
|
365
|
+
);
|
|
366
|
+
const V7_REFUSED: readonly (readonly [
|
|
367
|
+
string,
|
|
368
|
+
Record<string, unknown>,
|
|
369
|
+
string,
|
|
370
|
+
])[] = [
|
|
371
|
+
[
|
|
372
|
+
"v7-term-not-shown",
|
|
373
|
+
v7((i) => ({
|
|
374
|
+
...i,
|
|
375
|
+
terms: [
|
|
376
|
+
{
|
|
377
|
+
term: "NumPy",
|
|
378
|
+
note: [{ quote: "q", url: "https://e.x/", url_label: "u" }],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
})),
|
|
382
|
+
"items[0].terms[0].term: 'NumPy' is not in the card's annotation, quote or quote source — a note opens from words the card shows",
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
"v7-term-across-backticks",
|
|
386
|
+
v7((i) => ({
|
|
387
|
+
...i,
|
|
388
|
+
terms: [
|
|
389
|
+
{
|
|
390
|
+
term: "rule D413",
|
|
391
|
+
note: [{ quote: "q", url: "https://e.x/", url_label: "u" }],
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
})),
|
|
395
|
+
"items[0].terms[0].term: 'rule D413' is not in the card's annotation, quote or quote source — a note opens from words the card shows",
|
|
396
|
+
],
|
|
397
|
+
[
|
|
398
|
+
"v7-terms-overlap",
|
|
399
|
+
v7((i) => ({
|
|
400
|
+
...i,
|
|
401
|
+
terms: [
|
|
402
|
+
{
|
|
403
|
+
term: "Ruff",
|
|
404
|
+
note: [{ quote: "q", url: "https://e.x/", url_label: "u" }],
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
term: "Ruff rule",
|
|
408
|
+
note: [{ quote: "q", url: "https://e.x/", url_label: "u" }],
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
})),
|
|
412
|
+
"items[0].terms[0].term: 'Ruff' is inside items[0].terms[1].term 'Ruff rule' — the page draws one of two overlapping terms, so the other note never opens",
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
"v7-terms-twice",
|
|
416
|
+
v7((i) => ({
|
|
417
|
+
...i,
|
|
418
|
+
terms: ["Ruff", "Ruff"].map((term) => ({
|
|
419
|
+
term,
|
|
420
|
+
note: [{ quote: "q", url: "https://e.x/", url_label: "u" }],
|
|
421
|
+
})),
|
|
422
|
+
})),
|
|
423
|
+
"items[0].terms[0].term: 'Ruff' is inside items[0].terms[1].term 'Ruff' — the page draws one of two overlapping terms, so the other note never opens",
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
"v7-note-without-words",
|
|
427
|
+
v7((i) => ({
|
|
428
|
+
...i,
|
|
429
|
+
terms: [
|
|
430
|
+
{
|
|
431
|
+
term: "Ruff",
|
|
432
|
+
note: [{ says: "a linter", url: "https://e.x/", url_label: "u" }],
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
})),
|
|
436
|
+
"items[0].terms[0].note[0]: has neither `quote` nor `code` — a note shows its source's own words",
|
|
437
|
+
],
|
|
438
|
+
[
|
|
439
|
+
"v7-note-unknown-question",
|
|
440
|
+
v7((i) => ({ ...i, answer_notes: { applies: { forbid: "x" } } })),
|
|
441
|
+
"items[0].answer_notes.applies: not a question of this page (forbid)",
|
|
442
|
+
],
|
|
443
|
+
[
|
|
444
|
+
"v7-note-unknown-answer",
|
|
445
|
+
v7((i) => ({ ...i, answer_notes: { forbid: { maybe: "x" } } })),
|
|
446
|
+
"items[0].answer_notes.forbid.maybe: not one of forbid, allow, unsettled, elsewhere, cannot",
|
|
447
|
+
],
|
|
448
|
+
[
|
|
449
|
+
"v7-no-words",
|
|
450
|
+
v7((i) => R.omit(i, ["quote"])),
|
|
451
|
+
"items[0].body: is required and missing — a card shows its `body`, or its words as a `quote`",
|
|
452
|
+
],
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
test("🔴 the card text refuses a term the card does not show (or that spans a backtick), a note entry without a quote or a snippet, an answer note on an unknown question or answer, and a card with neither body nor quote", () => {
|
|
456
|
+
assert.deepEqual(
|
|
457
|
+
V7_REFUSED.map(([name, cfg]) => {
|
|
458
|
+
const dir = setup(name, cfg);
|
|
459
|
+
return run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
460
|
+
}),
|
|
461
|
+
V7_REFUSED.map(([, , message]) => ({
|
|
462
|
+
status: 1,
|
|
463
|
+
out: `REFUSED: ${message}\n`,
|
|
464
|
+
})),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
/** The v7 fixture's first card, as the fixture writes it. */
|
|
469
|
+
const V7_CARD = z
|
|
470
|
+
.object({
|
|
471
|
+
source_id: z.string(),
|
|
472
|
+
quote: z.object({ text: z.string(), source: z.string() }),
|
|
473
|
+
excerpt: z.object({
|
|
474
|
+
file: z.string(),
|
|
475
|
+
lang: z.string(),
|
|
476
|
+
annotation: z.string(),
|
|
477
|
+
lines: z.array(
|
|
478
|
+
z.object({
|
|
479
|
+
n: z.number(),
|
|
480
|
+
mark: z.boolean(),
|
|
481
|
+
context: z.boolean().optional(),
|
|
482
|
+
gutter: z.string().optional(),
|
|
483
|
+
}),
|
|
484
|
+
),
|
|
485
|
+
}),
|
|
486
|
+
answer_notes: z.object({ forbid: z.record(z.string(), z.string()) }),
|
|
487
|
+
})
|
|
488
|
+
.loose()
|
|
489
|
+
.parse(V7.items[0]);
|
|
490
|
+
const V7_QUESTION = z
|
|
491
|
+
.object({
|
|
492
|
+
questions: z.tuple([
|
|
493
|
+
z.object({
|
|
494
|
+
id: z.string(),
|
|
495
|
+
short: z.string(),
|
|
496
|
+
options: z.array(z.object({ value: z.string() })),
|
|
497
|
+
}),
|
|
498
|
+
]),
|
|
499
|
+
})
|
|
500
|
+
.loose()
|
|
501
|
+
.parse(V7).questions[0];
|
|
502
|
+
/** A fixture text as the page shows it: its `backticks` are code spans, not characters. */
|
|
503
|
+
const unticked = (text: string): string => text.split("`").join("");
|
|
504
|
+
/** What a node shows, with the fake DOM's line breaks between elements taken out. */
|
|
505
|
+
const shownBy = (n: FakeNode): string => n.textContent.replace(/\n/g, "");
|
|
506
|
+
/** The kinds of the card's parts, in the order they appear. */
|
|
507
|
+
const KINDS = [
|
|
508
|
+
"excerpt-path",
|
|
509
|
+
"code-line",
|
|
510
|
+
"gap",
|
|
511
|
+
"annotation",
|
|
512
|
+
"quote",
|
|
513
|
+
"quote-source",
|
|
514
|
+
"question",
|
|
515
|
+
"answer",
|
|
516
|
+
"answer-sub",
|
|
517
|
+
"status",
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
/** What the card shows, read from the page: every part's values, its text, and the order of the parts. */
|
|
521
|
+
function shownFacts(card: FakeNode) {
|
|
522
|
+
const of = (kind: string) => card.getByTestId(kind);
|
|
523
|
+
const texts = (kind: string) => of(kind).map(shownBy);
|
|
524
|
+
return {
|
|
525
|
+
path: of("excerpt-path").map((n) => n.meta()),
|
|
526
|
+
lines: of("code-line").map((n) => n.meta()),
|
|
527
|
+
gaps: of("gap").map((n) => n.meta()),
|
|
528
|
+
annotation: [of("annotation").map((n) => n.meta()), texts("annotation")],
|
|
529
|
+
quote: texts("quote"),
|
|
530
|
+
source: texts("quote-source"),
|
|
531
|
+
answers: of("answer").map((n) => n.meta()),
|
|
532
|
+
subs: of("answer-sub").map((n) => [n.meta(), shownBy(n)]),
|
|
533
|
+
status: [of("status").map((n) => n.meta()), texts("status")],
|
|
534
|
+
order: card
|
|
535
|
+
.all()
|
|
536
|
+
.map((n) => n.getAttribute("data-test") ?? "")
|
|
537
|
+
.filter((k) => KINDS.includes(k)),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** The same facts, as the fixture states them. */
|
|
541
|
+
function fixtureFacts() {
|
|
542
|
+
const lines = V7_CARD.excerpt.lines;
|
|
543
|
+
const notes = V7_CARD.answer_notes.forbid;
|
|
544
|
+
const noted = V7_QUESTION.options.filter((o) => o.value in notes);
|
|
545
|
+
return {
|
|
546
|
+
path: [{ file: V7_CARD.excerpt.file, lang: V7_CARD.excerpt.lang }],
|
|
547
|
+
lines: lines.map((l) => ({
|
|
548
|
+
line: l.n,
|
|
549
|
+
marked: l.mark,
|
|
550
|
+
gutter: l.gutter ?? "",
|
|
551
|
+
context: l.context ?? false,
|
|
552
|
+
})),
|
|
553
|
+
gaps: R.zip(lines, lines.slice(1))
|
|
554
|
+
.filter(([a, b]) => b.n > a.n + 1)
|
|
555
|
+
.map(([a, b]) => ({ from: a.n + 1, to: b.n - 1 })),
|
|
556
|
+
annotation: [
|
|
557
|
+
[{ line: lines.find((l) => l.mark)?.n }],
|
|
558
|
+
[unticked(V7_CARD.excerpt.annotation)],
|
|
559
|
+
],
|
|
560
|
+
quote: [unticked(V7_CARD.quote.text)],
|
|
561
|
+
source: [unticked(V7_CARD.quote.source)],
|
|
562
|
+
answers: V7_QUESTION.options.map((o) => ({
|
|
563
|
+
question: V7_QUESTION.id,
|
|
564
|
+
value: o.value,
|
|
565
|
+
})),
|
|
566
|
+
subs: noted.map((o) => [
|
|
567
|
+
{ question: V7_QUESTION.id, value: o.value },
|
|
568
|
+
unticked(notes[o.value] ?? ""),
|
|
569
|
+
]),
|
|
570
|
+
status: [
|
|
571
|
+
[{ state: "missing", missing: [V7_QUESTION.id] }],
|
|
572
|
+
[`Still needs: ${V7_QUESTION.short}`],
|
|
573
|
+
],
|
|
574
|
+
order: [
|
|
575
|
+
"excerpt-path",
|
|
576
|
+
...lines.flatMap((l, n) => [
|
|
577
|
+
...(l.n > (lines[n - 1]?.n ?? l.n) + 1 ? ["gap"] : []),
|
|
578
|
+
"code-line",
|
|
579
|
+
...(l.mark ? ["annotation"] : []),
|
|
580
|
+
]),
|
|
581
|
+
"quote",
|
|
582
|
+
"quote-source",
|
|
583
|
+
"question",
|
|
584
|
+
...V7_QUESTION.options.flatMap((o) =>
|
|
585
|
+
o.value in notes ? ["answer", "answer-sub"] : ["answer"],
|
|
586
|
+
),
|
|
587
|
+
"status",
|
|
588
|
+
],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
test("🔴 an approved card reads top to bottom: its path, the code with a gap row for what it leaves out, the marked line and the note under it, the quote, its source, the question, each answer with its sub-line, then what it still needs", async () => {
|
|
593
|
+
const { page: html, key } = build(setup("v7-face", v7()));
|
|
594
|
+
const { app } = await openLive(html, { practice: V7_DONE });
|
|
595
|
+
const id = Object.entries(key.ids).find(
|
|
596
|
+
([, v]) => v.source_id === V7_CARD.source_id,
|
|
597
|
+
)?.[0];
|
|
598
|
+
const card = app
|
|
599
|
+
.getByTestId("item")
|
|
600
|
+
.find((n) => z.object({ id: z.string() }).parse(n.meta()).id === id);
|
|
601
|
+
assert.ok(id !== undefined && card !== undefined, "the card is drawn");
|
|
602
|
+
assert.deepEqual(shownFacts(card), fixtureFacts());
|
|
603
|
+
// Guards: the question's id is not what the line prints; no generator word names a section.
|
|
604
|
+
assert.notEqual(V7_QUESTION.short, `${V7_QUESTION.id}?`);
|
|
605
|
+
assert.deepEqual(
|
|
606
|
+
["◀ this line", "Requirement", "REQUIREMENT", "CODE"].filter((w) =>
|
|
607
|
+
shownBy(card).includes(w),
|
|
608
|
+
),
|
|
609
|
+
[],
|
|
610
|
+
);
|
|
611
|
+
});
|