paperlint 14.0.0 → 14.2.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,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
+ });