paperlint 14.1.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 +70 -32
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +19 -3
- package/skills/build-label-page/SKILL.md.spec.ts +18 -2
- package/skills/build-label-page/references/closed-choice-generator.md +86 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -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 +761 -10
- package/skills/build-label-page/scripts/label-page-core.js +311 -67
- package/skills/build-label-page/scripts/label-page-core.test.ts +49 -10
- package/skills/build-label-page/scripts/label-page-core.ts +511 -87
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +488 -62
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two people answer the same page (`answerers`), and a card must be on screen a while before its
|
|
3
|
+
* answer saves (`min_seconds`): the build's checks, the key, the dump check and the export. The page
|
|
4
|
+
* itself is label-page-browser.test.ts. Each check both ways: quiet on a good input, refusing a bad one.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import * as R from "remeda";
|
|
10
|
+
import { test } from "vitest";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import {
|
|
13
|
+
DONE_PRACTICE,
|
|
14
|
+
ITEMS,
|
|
15
|
+
config,
|
|
16
|
+
exportAt,
|
|
17
|
+
labelsFile,
|
|
18
|
+
run,
|
|
19
|
+
setup,
|
|
20
|
+
} from "../../../test/label-page-kit.ts";
|
|
21
|
+
|
|
22
|
+
/** Four items: three shown to both answerers, the fourth to `author` only. */
|
|
23
|
+
const FOUR = [
|
|
24
|
+
...ITEMS.slice(0, 3),
|
|
25
|
+
R.merge(ITEMS[3], { answerers: ["author"] }),
|
|
26
|
+
];
|
|
27
|
+
const TWO = (over: Record<string, unknown> = {}) =>
|
|
28
|
+
config({ answerers: ["author", "second"], items: FOUR, ...over });
|
|
29
|
+
|
|
30
|
+
const RawKey = z
|
|
31
|
+
.object({
|
|
32
|
+
answerers: z.array(z.string()).optional(),
|
|
33
|
+
min_seconds: z.number().optional(),
|
|
34
|
+
ids: z.record(
|
|
35
|
+
z.string(),
|
|
36
|
+
z
|
|
37
|
+
.object({ source_id: z.string(), answerers: z.unknown().optional() })
|
|
38
|
+
.loose(),
|
|
39
|
+
),
|
|
40
|
+
})
|
|
41
|
+
.loose();
|
|
42
|
+
const keyOf = (dir: string) =>
|
|
43
|
+
RawKey.parse(JSON.parse(readFileSync(join(dir, "out", "key.json"), "utf8")));
|
|
44
|
+
const buildAt = (dir: string) =>
|
|
45
|
+
run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
46
|
+
/** The neutral id of the item drawn from `case-<n>`. */
|
|
47
|
+
const nidOf = (dir: string, n: number): string => {
|
|
48
|
+
const hit = R.entries(keyOf(dir).ids).find(
|
|
49
|
+
([, v]) => v.source_id === `case-${String(1000 + n)}`,
|
|
50
|
+
);
|
|
51
|
+
assert.ok(hit !== undefined);
|
|
52
|
+
return hit[0];
|
|
53
|
+
};
|
|
54
|
+
const dumpAt = (dir: string, dump: unknown): void => {
|
|
55
|
+
writeFileSync(join(dir, "dump.json"), JSON.stringify(dump));
|
|
56
|
+
};
|
|
57
|
+
const prefixed = (who: string, docs: Record<string, unknown>) =>
|
|
58
|
+
R.mapKeys(docs, (k) => `${who}~${k}`);
|
|
59
|
+
|
|
60
|
+
test("🔴 build refuses answerers that two people could not tell apart, and an item's subset naming someone unknown", () => {
|
|
61
|
+
const cases: readonly (readonly [string, Record<string, unknown>, string])[] =
|
|
62
|
+
[
|
|
63
|
+
[
|
|
64
|
+
"one",
|
|
65
|
+
{ answerers: ["author"] },
|
|
66
|
+
"answerers: needs 2 or more entries, has 1",
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
"twice",
|
|
70
|
+
{ answerers: ["author", "second", "author"] },
|
|
71
|
+
"answerers[2]: 'author' is repeated",
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
"separator",
|
|
75
|
+
{ answerers: ["author", "a~b"] },
|
|
76
|
+
"answerers[1]: 'a~b' must be letters",
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
"unknown",
|
|
80
|
+
{
|
|
81
|
+
answerers: ["author", "second"],
|
|
82
|
+
items: [{ ...ITEMS[0], answerers: ["third"] }],
|
|
83
|
+
},
|
|
84
|
+
"items[0].answerers[0]: 'third' is not in `answerers` (author, second)",
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
"item-twice",
|
|
88
|
+
{
|
|
89
|
+
answerers: ["author", "second"],
|
|
90
|
+
items: [{ ...ITEMS[0], answerers: ["second", "second"] }],
|
|
91
|
+
},
|
|
92
|
+
"items[0].answerers[1]: 'second' is repeated",
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
"no-page-list",
|
|
96
|
+
{ items: [{ ...ITEMS[0], answerers: ["author"] }] },
|
|
97
|
+
"items[0].answerers: the page has no `answerers` — add the page's list, or take this out",
|
|
98
|
+
],
|
|
99
|
+
["zero", { min_seconds: 0 }, "min_seconds: 0 — must be 1 or more"],
|
|
100
|
+
];
|
|
101
|
+
for (const [name, over, message] of cases) {
|
|
102
|
+
const r = buildAt(setup(`answerers-refuse-${name}`, config(over)));
|
|
103
|
+
assert.notEqual(r.status, 0, name);
|
|
104
|
+
assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("🔴 the key carries the answerers, each item's own subset and min_seconds; the page gets them", () => {
|
|
109
|
+
const dir = setup("answerers-key", TWO({ min_seconds: 30 }));
|
|
110
|
+
const r = buildAt(dir);
|
|
111
|
+
assert.equal(r.status, 0, r.out);
|
|
112
|
+
const key = keyOf(dir);
|
|
113
|
+
assert.deepEqual(key.answerers, ["author", "second"]);
|
|
114
|
+
assert.equal(key.min_seconds, 30);
|
|
115
|
+
assert.deepEqual(key.ids[nidOf(dir, 3)]?.answerers, ["author"]);
|
|
116
|
+
assert.equal(key.ids[nidOf(dir, 0)]?.answerers, undefined);
|
|
117
|
+
const page = readFileSync(join(dir, "out", "page.html"), "utf8");
|
|
118
|
+
assert.match(page, /"answerers": \["author", "second"\]/);
|
|
119
|
+
assert.match(page, /"min_seconds": 30/);
|
|
120
|
+
assert.match(page, /"answerers": \["author"\]/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** What the export writes for one stored document of the page's own: no reason, saved now. */
|
|
124
|
+
const exported = (
|
|
125
|
+
answers: Record<string, string | null>,
|
|
126
|
+
more: Record<string, unknown> = {},
|
|
127
|
+
) =>
|
|
128
|
+
R.merge(
|
|
129
|
+
{
|
|
130
|
+
answers,
|
|
131
|
+
invalid_answers: {},
|
|
132
|
+
reasons: {},
|
|
133
|
+
unexplained: [],
|
|
134
|
+
complete: Object.values(answers).every((v) => v !== null),
|
|
135
|
+
source: "page",
|
|
136
|
+
saved_at: null,
|
|
137
|
+
},
|
|
138
|
+
more,
|
|
139
|
+
);
|
|
140
|
+
const YES = { label: "yes", boundary: "clear" };
|
|
141
|
+
const NONE = { label: null, boundary: null };
|
|
142
|
+
const noFamiliarity = { familiarity: null, invalid_familiarity: null };
|
|
143
|
+
|
|
144
|
+
test("🔴 without answerers or min_seconds, the key, the page data and the export are what they were", () => {
|
|
145
|
+
const dir = setup("answerers-none", config({ items: ITEMS.slice(0, 2) }));
|
|
146
|
+
assert.equal(buildAt(dir).status, 0);
|
|
147
|
+
const rawKey = readFileSync(join(dir, "out", "key.json"), "utf8");
|
|
148
|
+
assert.doesNotMatch(rawKey, /answerers|min_seconds/);
|
|
149
|
+
const page = readFileSync(join(dir, "out", "page.html"), "utf8");
|
|
150
|
+
assert.doesNotMatch(page, /"answerers"|"min_seconds"/);
|
|
151
|
+
const [a, b] = [nidOf(dir, 0), nidOf(dir, 1)];
|
|
152
|
+
const saved = "2026-01-02T10:00:00Z";
|
|
153
|
+
dumpAt(dir, {
|
|
154
|
+
answers: { [a]: { answers: YES, savedAt: saved, source: "page" } },
|
|
155
|
+
groups: { rust: { familiarity: "well", source: "page" } },
|
|
156
|
+
practice: DONE_PRACTICE,
|
|
157
|
+
});
|
|
158
|
+
const r = exportAt(dir);
|
|
159
|
+
assert.equal(r.status, 0, r.out);
|
|
160
|
+
// The labels list the items in id order.
|
|
161
|
+
const items = R.sortBy(
|
|
162
|
+
[
|
|
163
|
+
[
|
|
164
|
+
a,
|
|
165
|
+
exported(YES, {
|
|
166
|
+
id: a,
|
|
167
|
+
source_id: "case-1000",
|
|
168
|
+
group: "rust",
|
|
169
|
+
saved_at: saved,
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
b,
|
|
174
|
+
exported(NONE, {
|
|
175
|
+
id: b,
|
|
176
|
+
source_id: "case-1001",
|
|
177
|
+
group: "python",
|
|
178
|
+
source: null,
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
] as const,
|
|
182
|
+
([id]) => id,
|
|
183
|
+
).map(([, item]) => item);
|
|
184
|
+
assert.deepEqual(JSON.parse(labelsFile(dir)), {
|
|
185
|
+
study: "demo-gold",
|
|
186
|
+
exported: "2026-01-02",
|
|
187
|
+
questions: ["label", "boundary"],
|
|
188
|
+
items,
|
|
189
|
+
// Object keys compare in any order: deepEqual reads them as a set.
|
|
190
|
+
groups: {
|
|
191
|
+
rust: R.merge(noFamiliarity, {
|
|
192
|
+
familiarity: "well",
|
|
193
|
+
source: "page",
|
|
194
|
+
saved_at: null,
|
|
195
|
+
}),
|
|
196
|
+
python: R.merge(noFamiliarity, { source: null, saved_at: null }),
|
|
197
|
+
},
|
|
198
|
+
complete_items: 1,
|
|
199
|
+
invalid_items: 0,
|
|
200
|
+
invalid_groups: 0,
|
|
201
|
+
total_items: 2,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** The four-item page answered by both: author all but one, second one of three; then exported. */
|
|
206
|
+
function exportTwo() {
|
|
207
|
+
const dir = setup("answerers-export", TWO({ min_seconds: 30 }));
|
|
208
|
+
assert.equal(buildAt(dir).status, 0);
|
|
209
|
+
const [a, d] = [nidOf(dir, 0), nidOf(dir, 3)];
|
|
210
|
+
const no = { label: "no", boundary: "clear" };
|
|
211
|
+
dumpAt(dir, {
|
|
212
|
+
answers: {
|
|
213
|
+
[`author~${a}`]: { answers: YES, seconds: 41, source: "page" },
|
|
214
|
+
[`second~${a}`]: { answers: no, seconds: 33, source: "page" },
|
|
215
|
+
[`author~${d}`]: { answers: YES, seconds: 30, source: "page" },
|
|
216
|
+
},
|
|
217
|
+
groups: { "author~rust": { familiarity: "well", source: "page" } },
|
|
218
|
+
practice: {
|
|
219
|
+
...prefixed("author", DONE_PRACTICE),
|
|
220
|
+
"second~r1-P1": { answers: YES, source: "page" },
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const r = exportAt(dir);
|
|
224
|
+
assert.equal(r.status, 0, r.out);
|
|
225
|
+
const file = z
|
|
226
|
+
.object({
|
|
227
|
+
answerers: z.array(z.string()),
|
|
228
|
+
items: z.array(
|
|
229
|
+
z
|
|
230
|
+
.object({
|
|
231
|
+
id: z.string(),
|
|
232
|
+
by_answerer: z.record(z.string(), z.unknown()),
|
|
233
|
+
})
|
|
234
|
+
.loose(),
|
|
235
|
+
),
|
|
236
|
+
by_answerer: z.record(
|
|
237
|
+
z.string(),
|
|
238
|
+
z.object({
|
|
239
|
+
complete_items: z.number(),
|
|
240
|
+
total_items: z.number(),
|
|
241
|
+
groups: z.record(z.string(), z.unknown()),
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
})
|
|
245
|
+
.loose()
|
|
246
|
+
.parse(JSON.parse(labelsFile(dir)));
|
|
247
|
+
return { dir, out: r.out, file, a, d, no };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
test("🔴 export keeps each answerer's answers to an item apart, with the seconds on screen", () => {
|
|
251
|
+
const { file, a, d, no } = exportTwo();
|
|
252
|
+
assert.deepEqual(file.answerers, ["author", "second"]);
|
|
253
|
+
assert.deepEqual(
|
|
254
|
+
file.items.find((i) => i.id === a),
|
|
255
|
+
{
|
|
256
|
+
id: a,
|
|
257
|
+
source_id: "case-1000",
|
|
258
|
+
group: "rust",
|
|
259
|
+
answerers: ["author", "second"],
|
|
260
|
+
by_answerer: {
|
|
261
|
+
author: exported(YES, { seconds: 41 }),
|
|
262
|
+
second: exported(no, { seconds: 33 }),
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
Object.keys(file.items.find((i) => i.id === d)?.by_answerer ?? { none: 1 }),
|
|
268
|
+
["author"],
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("🔴 export counts, reports and scores the practice per answerer", () => {
|
|
273
|
+
const { dir, out, file } = exportTwo();
|
|
274
|
+
assert.deepEqual(
|
|
275
|
+
R.mapValues(file.by_answerer, (c) => [
|
|
276
|
+
c.complete_items,
|
|
277
|
+
c.total_items,
|
|
278
|
+
R.keys(c.groups).sort(),
|
|
279
|
+
]),
|
|
280
|
+
{
|
|
281
|
+
author: [2, 4, ["go", "python", "rust"]],
|
|
282
|
+
second: [1, 3, ["go", "python", "rust"]],
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
assert.match(
|
|
286
|
+
out,
|
|
287
|
+
/author: 2 of 4 items complete; second: 1 of 3 items complete/,
|
|
288
|
+
);
|
|
289
|
+
assert.match(
|
|
290
|
+
out,
|
|
291
|
+
new RegExp(`incomplete second~${nidOf(dir, 1)}: missing label, boundary`),
|
|
292
|
+
);
|
|
293
|
+
assert.match(out, /group second~rust: no familiarity answer/);
|
|
294
|
+
// Each answerer's practice is scored on its own, into its own file.
|
|
295
|
+
assert.match(out, /practice round 1 \(author\): 3 of 3 matched/);
|
|
296
|
+
assert.match(
|
|
297
|
+
out,
|
|
298
|
+
/practice round 1 \(second\): 1 of 3 matched; missed P2, P3/,
|
|
299
|
+
);
|
|
300
|
+
const second = z
|
|
301
|
+
.object({ answerer: z.string(), matched: z.number() })
|
|
302
|
+
.loose()
|
|
303
|
+
.parse(
|
|
304
|
+
JSON.parse(
|
|
305
|
+
readFileSync(
|
|
306
|
+
join(dir, "data", "demo-gold-practice-r1-second-2026-01-02.json"),
|
|
307
|
+
"utf8",
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
assert.deepEqual([second.answerer, second.matched], ["second", 1]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("🔴 export refuses a document of no answerer, of an unknown one, or of an item not shown to its answerer", () => {
|
|
315
|
+
const dir = setup("answerers-refuse-dump", TWO({ min_seconds: 30 }));
|
|
316
|
+
assert.equal(buildAt(dir).status, 0);
|
|
317
|
+
const [a, d] = [nidOf(dir, 0), nidOf(dir, 3)];
|
|
318
|
+
const doc = { answers: { label: "yes" }, source: "page" };
|
|
319
|
+
const cases: readonly (readonly [string, unknown, string])[] = [
|
|
320
|
+
[
|
|
321
|
+
"plain",
|
|
322
|
+
{ answers: { [a]: doc } },
|
|
323
|
+
`answers/${a}: this page has answerers (author, second), so a document's id is <answerer>~<id>`,
|
|
324
|
+
],
|
|
325
|
+
[
|
|
326
|
+
"stranger",
|
|
327
|
+
{ answers: { [`third~${a}`]: doc } },
|
|
328
|
+
`answers/third~${a}: 'third' is not an answerer of this page (author, second)`,
|
|
329
|
+
],
|
|
330
|
+
[
|
|
331
|
+
"not-shown",
|
|
332
|
+
{ answers: { [`second~${d}`]: doc } },
|
|
333
|
+
`answers/second~${d}: the key does not know this answer for second`,
|
|
334
|
+
],
|
|
335
|
+
[
|
|
336
|
+
"group-plain",
|
|
337
|
+
{ groups: { rust: { familiarity: "well", source: "page" } } },
|
|
338
|
+
"groups/rust: this page has answerers",
|
|
339
|
+
],
|
|
340
|
+
[
|
|
341
|
+
"practice-unknown",
|
|
342
|
+
{ practice: { "second~r1-P9": { answers: {}, source: "page" } } },
|
|
343
|
+
"practice/second~r1-P9: the key does not know this practice item",
|
|
344
|
+
],
|
|
345
|
+
[
|
|
346
|
+
"seconds",
|
|
347
|
+
{
|
|
348
|
+
answers: {
|
|
349
|
+
[`author~${a}`]: R.merge(doc, { seconds: "long" }),
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
`answers/author~${a}/seconds: 'long' — must be a whole number of seconds or null`,
|
|
353
|
+
],
|
|
354
|
+
];
|
|
355
|
+
for (const [name, dump, message] of cases) {
|
|
356
|
+
dumpAt(dir, dump);
|
|
357
|
+
const r = exportAt(dir);
|
|
358
|
+
assert.notEqual(r.status, 0, name);
|
|
359
|
+
assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("🔴 without answerers, a prefixed document id is a document the key does not know", () => {
|
|
364
|
+
const dir = setup("answerers-none-prefixed", config());
|
|
365
|
+
assert.equal(buildAt(dir).status, 0);
|
|
366
|
+
const a = nidOf(dir, 0);
|
|
367
|
+
dumpAt(dir, {
|
|
368
|
+
answers: { [`author~${a}`]: { answers: {}, source: "page" } },
|
|
369
|
+
});
|
|
370
|
+
const r = exportAt(dir);
|
|
371
|
+
assert.notEqual(r.status, 0);
|
|
372
|
+
assert.match(r.out, new RegExp(`answers/author~${a}: the key does not know`));
|
|
373
|
+
// Nor does a page built without min_seconds store seconds.
|
|
374
|
+
dumpAt(dir, {
|
|
375
|
+
answers: { [a]: { answers: {}, seconds: 3, source: "page" } },
|
|
376
|
+
});
|
|
377
|
+
assert.match(
|
|
378
|
+
exportAt(dir).out,
|
|
379
|
+
new RegExp(`answers/${a}/seconds: not a field of a stored answer`),
|
|
380
|
+
);
|
|
381
|
+
});
|
|
@@ -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
|
+
});
|