paperlint 14.1.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.
@@ -146,6 +146,7 @@ type Card = {
146
146
  type Item = Card & {
147
147
  readonly source_id: string;
148
148
  readonly group: string;
149
+ readonly answerers?: readonly string[];
149
150
  };
150
151
  type Practice = Card & {
151
152
  readonly expected: Readonly<Record<string, string>>;
@@ -159,6 +160,8 @@ interface Config {
159
160
  readonly seed: number;
160
161
  readonly groups: Readonly<Record<string, string>>;
161
162
  readonly labeller_knows?: readonly string[];
163
+ readonly answerers?: readonly string[];
164
+ readonly min_seconds?: number;
162
165
  readonly familiarity?: boolean;
163
166
  readonly familiarity_question?: string;
164
167
  readonly questions: readonly Question[];
@@ -171,6 +174,8 @@ interface KeyEntry {
171
174
  readonly source_id: string;
172
175
  readonly group: string;
173
176
  readonly on_page: boolean;
177
+ /** Who the item is shown to, when not every answerer of the page. */
178
+ readonly answerers?: readonly string[];
174
179
  }
175
180
  /** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
176
181
  type Described = ReadonlyMap<string, RuleText>;
@@ -183,21 +188,6 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
183
188
  };
184
189
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
185
190
  declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
186
- title: string;
187
- intro: string;
188
- labels: {
189
- [x: string]: string;
190
- };
191
- groups: {
192
- id: string;
193
- label: string;
194
- }[];
195
- familiarity_question: string;
196
- familiarity: {
197
- value: string;
198
- label: string;
199
- }[];
200
- allowed: JsonObject;
201
191
  questions: readonly Question[];
202
192
  practice: {
203
193
  round: number;
@@ -219,9 +209,11 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
219
209
  })[];
220
210
  };
221
211
  items: ({
212
+ answerers?: readonly string[];
222
213
  rules?: never;
223
214
  id: string;
224
215
  } | {
216
+ answerers?: readonly string[];
225
217
  rules: {
226
218
  description?: string;
227
219
  family?: string;
@@ -230,6 +222,23 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
230
222
  }[];
231
223
  id: string;
232
224
  })[];
225
+ answerers?: readonly string[];
226
+ min_seconds?: number;
227
+ title: string;
228
+ intro: string;
229
+ labels: {
230
+ [x: string]: string;
231
+ };
232
+ groups: {
233
+ id: string;
234
+ label: string;
235
+ }[];
236
+ familiarity_question: string;
237
+ familiarity: {
238
+ value: string;
239
+ label: string;
240
+ }[];
241
+ allowed: JsonObject;
233
242
  };
234
243
  export type Payload = ReturnType<typeof payloadOf>;
235
244
  /**
@@ -239,21 +248,6 @@ export type Payload = ReturnType<typeof payloadOf>;
239
248
  */
240
249
  export declare function prepareBuild(configPath: string, outDir: string): {
241
250
  payload: {
242
- title: string;
243
- intro: string;
244
- labels: {
245
- [x: string]: string;
246
- };
247
- groups: {
248
- id: string;
249
- label: string;
250
- }[];
251
- familiarity_question: string;
252
- familiarity: {
253
- value: string;
254
- label: string;
255
- }[];
256
- allowed: JsonObject;
257
251
  questions: readonly Question[];
258
252
  practice: {
259
253
  round: number;
@@ -275,9 +269,11 @@ export declare function prepareBuild(configPath: string, outDir: string): {
275
269
  })[];
276
270
  };
277
271
  items: ({
272
+ answerers?: readonly string[];
278
273
  rules?: never;
279
274
  id: string;
280
275
  } | {
276
+ answerers?: readonly string[];
281
277
  rules: {
282
278
  description?: string;
283
279
  family?: string;
@@ -286,6 +282,23 @@ export declare function prepareBuild(configPath: string, outDir: string): {
286
282
  }[];
287
283
  id: string;
288
284
  })[];
285
+ answerers?: readonly string[];
286
+ min_seconds?: number;
287
+ title: string;
288
+ intro: string;
289
+ labels: {
290
+ [x: string]: string;
291
+ };
292
+ groups: {
293
+ id: string;
294
+ label: string;
295
+ }[];
296
+ familiarity_question: string;
297
+ familiarity: {
298
+ value: string;
299
+ label: string;
300
+ }[];
301
+ allowed: JsonObject;
289
302
  };
290
303
  dropped: number;
291
304
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "14.1.0",
3
+ "version": "14.2.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:64e09e4a829be441 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:dce0979d7f187ae7 compiled from skills/build-label-page/SKILL.md.spec.ts -->
8
8
 
9
9
  # build-label-page — a page the person labels on a phone, and the labels back in the repo
10
10
 
@@ -142,6 +142,20 @@ building. In one line each:
142
142
  same pass, and add the line "labels → `data/<study>-labels-<date>.json`" to the paper's
143
143
  `NOTES.md` for the numbers that use them.
144
144
 
145
+ ## Two answerers, and a time on screen
146
+
147
+ Two optional top-level keys of `items.json`, both left out by default:
148
+
149
+ - `"answerers": ["author", "second"]` — two or more people answer the same published page, each
150
+ under their own name (picked on the page) and without seeing the other's answers; an item's own
151
+ `answerers` shows it to that subset only. The store's ids become `<answerer>~<id>`, and the
152
+ labels file keeps each answerer's answers under `by_answerer`.
153
+ - `"min_seconds": 30` — a card's answer saves only after the card has been on screen that long;
154
+ the seconds at its first save are stored and exported as `seconds`.
155
+
156
+ Details and the export's shape: [`references/closed-choice-generator.md`](references/closed-choice-generator.md),
157
+ "Two answerers, and a minimum time on screen".
158
+
145
159
  ## When the page misbehaves
146
160
 
147
161
  Look at what the store holds (`ArtifactData` `list`) before changing anything: the page may be fine
@@ -153,6 +153,20 @@ building. In one line each:
153
153
  same pass, and add the line "labels → \`data/<study>-labels-<date>.json\`" to the paper's
154
154
  \`NOTES.md\` for the numbers that use them.
155
155
 
156
+ ## Two answerers, and a time on screen
157
+
158
+ Two optional top-level keys of \`items.json\`, both left out by default:
159
+
160
+ - \`"answerers": ["author", "second"]\` — two or more people answer the same published page, each
161
+ under their own name (picked on the page) and without seeing the other's answers; an item's own
162
+ \`answerers\` shows it to that subset only. The store's ids become \`<answerer>~<id>\`, and the
163
+ labels file keeps each answerer's answers under \`by_answerer\`.
164
+ - \`"min_seconds": 30\` — a card's answer saves only after the card has been on screen that long;
165
+ the seconds at its first save are stored and exported as \`seconds\`.
166
+
167
+ Details and the export's shape: [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md),
168
+ "Two answerers, and a minimum time on screen".
169
+
156
170
  ## When the page misbehaves
157
171
 
158
172
  Look at what the store holds (\`ArtifactData\` \`list\`) before changing anything: the page may be fine
@@ -246,6 +246,48 @@ author cannot read it is an exclusion made after the draw and goes unrecorded. T
246
246
  units whose fate is obvious. Each dropped unit carries its reason in the labels file. The author's words, with the date, then go into
247
247
  `author_check` of the plan; a dropped unit is replaced by the next one in the draw.
248
248
 
249
+ ## Two answerers, and a minimum time on screen
250
+
251
+ Both keys are optional and top-level. Left out, the page, its store documents and the export are
252
+ exactly as above.
253
+
254
+ - **`answerers`**: two or more names (letters, digits, `-` and `_`), for a study where two people
255
+ answer the same published page, each under their own name and without seeing the other's
256
+ answers. The page first asks "Who is answering?"; the choice is kept in the browser's
257
+ localStorage when it has one (otherwise it asks again on every visit), and "Switch" beside
258
+ "Answering as …" asks again. Every document the page writes is named `<answerer>~<id>`
259
+ (`second~G004`, `second~r1-P2`, `second~python`): `~` is outside the name pattern, so no answerer,
260
+ item, group or practice id can hold it. The page reads only the current answerer's documents, so
261
+ their progress, done-state and practice are their own. An item may carry its own `answerers`, a
262
+ subset of the page's: it is shown only to those. A name an item lists that the page does not, a
263
+ name twice, or an item's list on a page without one stops the build.
264
+ - **`min_seconds`**: an item's answer saves only after its card has been on screen that long. The
265
+ cards are a list, so "on screen" is measured by an `IntersectionObserver`: half the card in view,
266
+ or half the viewport filled by a card taller than that, while the tab is visible. A locked card
267
+ shows "You can answer in N s" under its disabled buttons. The answer document gains `seconds`:
268
+ how long the card had been on screen at its first save, kept on every later save. The practice
269
+ cards are not held.
270
+
271
+ `key.json` carries `answerers`, each item's own subset (`ids.<id>.answerers`, only where it has one)
272
+ and `min_seconds`. With `answerers`, `export` refuses a document id without `<answerer>~`, an
273
+ answerer the key does not list, and an answer to an item not shown to its answerer, as it refuses
274
+ an unknown item. The labels file keeps each answerer's answers apart:
275
+
276
+ ```text
277
+ { "answerers": ["author", "second"],
278
+ "items": [{ "id": "G001", "source_id": "case-1000", "group": "python",
279
+ "answerers": ["author", "second"],
280
+ "by_answerer": { "author": { "answers": { "label": "yes" }, "complete": true,
281
+ "seconds": 41, "…": "the fields of an item above" },
282
+ "second": { "…": "…" } } }],
283
+ "by_answerer": { "author": { "groups": {}, "complete_items": 1, "total_items": 1, "…": "…" },
284
+ "second": { "…": "…" } } }
285
+ ```
286
+
287
+ An answerer with no document for an item is still listed, with every answer `null`. Each
288
+ answerer's practice is scored into its own file, `<study>-practice-r<round>-<answerer>-<date>.json`.
289
+ `seconds` appears only when the page was built with `min_seconds`.
290
+
249
291
  ## The export
250
292
 
251
293
  `export` reads the store's three collections (`answers`, `groups`, `practice`) from `dump.json`, in
@@ -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
+ });