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.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +43 -30
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +15 -1
- package/skills/build-label-page/SKILL.md.spec.ts +14 -0
- package/skills/build-label-page/references/closed-choice-generator.md +42 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +309 -0
- package/skills/build-label-page/scripts/label-page-core.js +206 -58
- package/skills/build-label-page/scripts/label-page-core.test.ts +24 -9
- package/skills/build-label-page/scripts/label-page-core.ts +333 -77
- 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 +205 -18
|
@@ -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
|
@@ -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:
|
|
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
|
+
});
|