paperlint 13.4.0 → 13.5.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 +12 -1
- package/package.json +1 -1
- package/skills/build-label-page/references/closed-choice-generator.md +9 -1
- package/skills/build-label-page/scripts/label-page-core.js +69 -9
- package/skills/build-label-page/scripts/label-page-core.test.ts +141 -1
- package/skills/build-label-page/scripts/label-page-core.ts +114 -7
- package/skills/build-label-page/scripts/label-page-html.test.ts +38 -0
- package/skills/build-label-page/scripts/label-page.js +5 -1
- package/skills/build-label-page/scripts/label-page.ts +5 -1
- package/skills/build-label-page/scripts/page-template.html +5 -0
|
@@ -12,6 +12,7 @@ type Json = null | boolean | number | string | readonly Json[] | {
|
|
|
12
12
|
type JsonObject = {
|
|
13
13
|
readonly [key: string]: Json;
|
|
14
14
|
};
|
|
15
|
+
declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
|
|
15
16
|
type SchemaNode = {
|
|
16
17
|
kind: "string";
|
|
17
18
|
required: boolean;
|
|
@@ -53,9 +54,19 @@ interface Question {
|
|
|
53
54
|
readonly short?: string;
|
|
54
55
|
readonly options: readonly Option[];
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
interface Unit {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly text: string;
|
|
60
|
+
readonly part: number;
|
|
61
|
+
readonly of: number;
|
|
62
|
+
}
|
|
63
|
+
type Item = {
|
|
64
|
+
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
65
|
+
} & {
|
|
57
66
|
readonly source_id: string;
|
|
58
67
|
readonly group: string;
|
|
68
|
+
readonly body: string;
|
|
69
|
+
readonly unit?: Unit;
|
|
59
70
|
};
|
|
60
71
|
interface Practice {
|
|
61
72
|
readonly title?: string;
|
package/package.json
CHANGED
|
@@ -37,8 +37,16 @@ length.
|
|
|
37
37
|
- **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
|
|
38
38
|
the earlier round are kept but not counted.
|
|
39
39
|
- **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
|
|
40
|
-
`code`, `context`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
40
|
+
`code`, `context`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
41
41
|
is taken out of the item, not added to the list to make a build pass.
|
|
42
|
+
- **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
|
|
43
|
+
("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
|
|
44
|
+
own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
|
|
45
|
+
whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
|
|
46
|
+
The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
|
|
47
|
+
split across groups), naming the item. `id` names one occurrence of the line (a file and line
|
|
48
|
+
number, say) and is never shown: two files holding the same words are two lines, each judged in
|
|
49
|
+
full. Splitting the line is the study's job, before the page.
|
|
42
50
|
- **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
|
|
43
51
|
next one; a different seed is refused once a key exists.
|
|
44
52
|
- **`ask_why`** on an option: a prompt. That answer is incomplete, on the page and in the export,
|
|
@@ -89,7 +89,14 @@ const readJson = (path) => {
|
|
|
89
89
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
90
90
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
|
|
91
91
|
const PRIVATE_FIELDS = ["source_id"];
|
|
92
|
-
|
|
92
|
+
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
93
|
+
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
94
|
+
const UNIT_FIELD = "unit";
|
|
95
|
+
const ALLOWED_FIELDS = [
|
|
96
|
+
...PAGE_FIELDS,
|
|
97
|
+
UNIT_FIELD,
|
|
98
|
+
...PRIVATE_FIELDS,
|
|
99
|
+
];
|
|
93
100
|
const FAMILIARITY = [
|
|
94
101
|
{ value: "well", label: "Well" },
|
|
95
102
|
{ value: "somewhat", label: "Somewhat" },
|
|
@@ -140,10 +147,13 @@ const mapping = (values, keyPattern, min = 0) => ({
|
|
|
140
147
|
key_pattern: keyPattern,
|
|
141
148
|
values,
|
|
142
149
|
});
|
|
143
|
-
const obj = (fields, closed = true) => ({ kind: "object", required
|
|
150
|
+
const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
|
|
144
151
|
const ITEM_SCHEMA = obj({
|
|
145
152
|
source_id: string(),
|
|
146
153
|
...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
|
|
154
|
+
[UNIT_FIELD]: obj(
|
|
155
|
+
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
156
|
+
{ id: string(), text: string(), part: integer(), of: integer() }, true, false),
|
|
147
157
|
}, false);
|
|
148
158
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
149
159
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
@@ -356,7 +366,41 @@ function checkConfig(cfg) {
|
|
|
356
366
|
}
|
|
357
367
|
/** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
|
|
358
368
|
const practiceOf = (cfg) => cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
|
|
359
|
-
|
|
369
|
+
/** The items of each line, in items.json order; a line's list is never empty. */
|
|
370
|
+
const linesOf = (cfg) => cfg.items.reduce((byLine, item, n) => {
|
|
371
|
+
if (item.unit === undefined)
|
|
372
|
+
return byLine;
|
|
373
|
+
const part = { item, unit: item.unit, n };
|
|
374
|
+
const seen = byLine.get(item.unit.id);
|
|
375
|
+
return byLine.set(item.unit.id, seen ? [seen[0], ...seen.slice(1), part] : [part]);
|
|
376
|
+
}, new Map());
|
|
377
|
+
/** One part against the first part of its line: same count, in range, not repeated, same group. */
|
|
378
|
+
function checkPart({ item, unit, n }, parts) {
|
|
379
|
+
const [first] = parts;
|
|
380
|
+
const at = `items[${String(n)}]`;
|
|
381
|
+
if (unit.text !== first.unit.text)
|
|
382
|
+
fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
|
|
383
|
+
if (unit.of !== first.unit.of)
|
|
384
|
+
fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
|
|
385
|
+
if (unit.part < 1 || unit.part > unit.of)
|
|
386
|
+
fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
|
|
387
|
+
const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
|
|
388
|
+
if (earlier !== undefined)
|
|
389
|
+
fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
|
|
390
|
+
if (item.group !== first.item.group)
|
|
391
|
+
fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
|
|
392
|
+
}
|
|
393
|
+
function checkUnits(cfg) {
|
|
394
|
+
for (const [line, parts] of linesOf(cfg)) {
|
|
395
|
+
parts.forEach((p) => {
|
|
396
|
+
checkPart(p, parts);
|
|
397
|
+
});
|
|
398
|
+
const [first] = parts;
|
|
399
|
+
const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
|
|
400
|
+
if (missing !== undefined)
|
|
401
|
+
fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
360
404
|
function checkItemFields(cfg) {
|
|
361
405
|
const sids = [];
|
|
362
406
|
cfg.items.forEach((item, n) => {
|
|
@@ -373,6 +417,11 @@ function checkItemFields(cfg) {
|
|
|
373
417
|
fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
|
|
374
418
|
});
|
|
375
419
|
}
|
|
420
|
+
/** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
|
|
421
|
+
const visibleTexts = (item) => [
|
|
422
|
+
...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
|
|
423
|
+
[UNIT_FIELD, item.unit?.text ?? ""],
|
|
424
|
+
];
|
|
376
425
|
const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
377
426
|
/**
|
|
378
427
|
* A source id typed into a visible field gives the source away as well as a field would. Short ids
|
|
@@ -385,7 +434,7 @@ function checkNoSourceShown(cfg) {
|
|
|
385
434
|
for (const item of cfg.items) {
|
|
386
435
|
for (const sid of long) {
|
|
387
436
|
const word = new RegExp(`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`, "u");
|
|
388
|
-
const field =
|
|
437
|
+
const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
|
|
389
438
|
if (field !== undefined) {
|
|
390
439
|
fail(`the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`);
|
|
391
440
|
}
|
|
@@ -426,11 +475,21 @@ function assignIds(items, seed, keyPath) {
|
|
|
426
475
|
]);
|
|
427
476
|
return { ids, entries };
|
|
428
477
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}))
|
|
478
|
+
const shownFields = (item) => ({
|
|
479
|
+
...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
|
|
480
|
+
const v = item[f];
|
|
481
|
+
return v === undefined ? [] : [[f, v]];
|
|
482
|
+
})),
|
|
483
|
+
...(item.unit === undefined
|
|
484
|
+
? {}
|
|
485
|
+
: {
|
|
486
|
+
[UNIT_FIELD]: {
|
|
487
|
+
text: item.unit.text,
|
|
488
|
+
part: item.unit.part,
|
|
489
|
+
of: item.unit.of,
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
});
|
|
434
493
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
435
494
|
function itemsOnPage(cfg, keyPath) {
|
|
436
495
|
const knows = cfg.labeller_knows;
|
|
@@ -524,6 +583,7 @@ export function prepareBuild(configPath, outDir) {
|
|
|
524
583
|
assertConfig(cfg);
|
|
525
584
|
checkConfig(cfg);
|
|
526
585
|
checkItemFields(cfg);
|
|
586
|
+
checkUnits(cfg);
|
|
527
587
|
checkNoSourceShown(cfg);
|
|
528
588
|
mkdirSync(outDir, { recursive: true });
|
|
529
589
|
const keyPath = join(outDir, "key.json");
|
|
@@ -81,7 +81,15 @@ const FULL: DocObject = (() => {
|
|
|
81
81
|
familiarity: true,
|
|
82
82
|
familiarity_question: "How well do you know {group}?",
|
|
83
83
|
items: ITEMS.map((i, n) =>
|
|
84
|
-
n === 0
|
|
84
|
+
n === 0
|
|
85
|
+
? {
|
|
86
|
+
...i,
|
|
87
|
+
title: "T",
|
|
88
|
+
code: "c = 1",
|
|
89
|
+
context: "ctx",
|
|
90
|
+
unit: { id: "L0", text: "One line.", part: 1, of: 1 },
|
|
91
|
+
}
|
|
92
|
+
: i,
|
|
85
93
|
),
|
|
86
94
|
}),
|
|
87
95
|
);
|
|
@@ -1167,6 +1175,138 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
|
|
|
1167
1175
|
assert.match(r.out, /practice round 1: 3 of 3 matched/);
|
|
1168
1176
|
});
|
|
1169
1177
|
|
|
1178
|
+
/** Items 0 and 3 (both Rust) as the two requirements of one line. */
|
|
1179
|
+
const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
1180
|
+
const withUnit = (
|
|
1181
|
+
parts: readonly (readonly [n: number, unit: Record<string, unknown>])[],
|
|
1182
|
+
): Record<string, unknown> =>
|
|
1183
|
+
config({
|
|
1184
|
+
items: ITEMS.map((item, n) => {
|
|
1185
|
+
const u = parts.find(([at]) => at === n);
|
|
1186
|
+
return u === undefined ? item : { ...item, unit: u[1] };
|
|
1187
|
+
}),
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
/** A line whose parts do not add up, and the refusal that names it. */
|
|
1191
|
+
const UNIT_REFUSALS: readonly (readonly [
|
|
1192
|
+
string,
|
|
1193
|
+
Record<string, unknown>,
|
|
1194
|
+
string,
|
|
1195
|
+
])[] = [
|
|
1196
|
+
[
|
|
1197
|
+
"unit-missing-part",
|
|
1198
|
+
withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
|
|
1199
|
+
`items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
|
|
1200
|
+
],
|
|
1201
|
+
[
|
|
1202
|
+
"unit-part-twice",
|
|
1203
|
+
withUnit([
|
|
1204
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1205
|
+
[3, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1206
|
+
]),
|
|
1207
|
+
"items[3].unit.part: 1 repeats items[0].unit.part",
|
|
1208
|
+
],
|
|
1209
|
+
[
|
|
1210
|
+
"unit-of-differs",
|
|
1211
|
+
withUnit([
|
|
1212
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1213
|
+
[3, { id: "L1", text: LINE, part: 2, of: 3 }],
|
|
1214
|
+
]),
|
|
1215
|
+
"items[3].unit.of: 3, but items[0].unit.of says 2 for the same line",
|
|
1216
|
+
],
|
|
1217
|
+
[
|
|
1218
|
+
"unit-part-range",
|
|
1219
|
+
withUnit([[0, { id: "L1", text: LINE, part: 3, of: 2 }]]),
|
|
1220
|
+
"items[0].unit.part: 3 is not between 1 and 2",
|
|
1221
|
+
],
|
|
1222
|
+
[
|
|
1223
|
+
"unit-groups-differ",
|
|
1224
|
+
withUnit([
|
|
1225
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1226
|
+
[1, { id: "L1", text: LINE, part: 2, of: 2 }],
|
|
1227
|
+
]),
|
|
1228
|
+
"items[1].group: 'python', but items[0] of the same line is in 'rust'",
|
|
1229
|
+
],
|
|
1230
|
+
[
|
|
1231
|
+
"unit-text-differs",
|
|
1232
|
+
withUnit([
|
|
1233
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1234
|
+
[3, { id: "L1", text: "Another line.", part: 2, of: 2 }],
|
|
1235
|
+
]),
|
|
1236
|
+
"items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
|
|
1237
|
+
],
|
|
1238
|
+
[
|
|
1239
|
+
"unit-halves-of-two-occurrences",
|
|
1240
|
+
withUnit([
|
|
1241
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1242
|
+
[3, { id: "L2", text: LINE, part: 2, of: 2 }],
|
|
1243
|
+
]),
|
|
1244
|
+
"items[0].unit: part 2 of 2 of line 'L1' is not among the items",
|
|
1245
|
+
],
|
|
1246
|
+
[
|
|
1247
|
+
"unit-leaks-source",
|
|
1248
|
+
withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
|
|
1249
|
+
"the source id 'case-1003' appears in the visible `unit` of item case-1000",
|
|
1250
|
+
],
|
|
1251
|
+
];
|
|
1252
|
+
|
|
1253
|
+
test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
|
|
1254
|
+
for (const [name, cfg, message] of UNIT_REFUSALS) {
|
|
1255
|
+
const dir = setup(name, cfg);
|
|
1256
|
+
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
1257
|
+
assert.equal(r.status, 1, `${name}\n${r.out}`);
|
|
1258
|
+
assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test("a line whose every requirement is an item builds, and each item carries its part and the whole line", () => {
|
|
1263
|
+
const dir = setup(
|
|
1264
|
+
"unit-ok",
|
|
1265
|
+
withUnit([
|
|
1266
|
+
[0, { id: "L1", text: LINE, part: 1, of: 2 }],
|
|
1267
|
+
[3, { id: "L1", text: LINE, part: 2, of: 2 }],
|
|
1268
|
+
]),
|
|
1269
|
+
);
|
|
1270
|
+
const { page } = build(dir);
|
|
1271
|
+
const json =
|
|
1272
|
+
/<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
|
|
1273
|
+
page,
|
|
1274
|
+
)?.[1];
|
|
1275
|
+
assert.ok(json !== undefined);
|
|
1276
|
+
const units = z
|
|
1277
|
+
.object({
|
|
1278
|
+
items: z.array(
|
|
1279
|
+
z.object({
|
|
1280
|
+
unit: z
|
|
1281
|
+
.object({ text: z.string(), part: z.number(), of: z.number() })
|
|
1282
|
+
.optional(),
|
|
1283
|
+
}),
|
|
1284
|
+
),
|
|
1285
|
+
})
|
|
1286
|
+
.parse(JSON.parse(json.replace(/<\\\//g, "</")))
|
|
1287
|
+
.items.flatMap((i) => (i.unit ? [i.unit] : []))
|
|
1288
|
+
.sort((a, b) => a.part - b.part);
|
|
1289
|
+
// what the page carries: the part and the line, never the line's private id
|
|
1290
|
+
assert.deepEqual(units, [
|
|
1291
|
+
{ text: LINE, part: 1, of: 2 },
|
|
1292
|
+
{ text: LINE, part: 2, of: 2 },
|
|
1293
|
+
]);
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
test("🔴 the same line in two places is two lines: four items, two ids, it builds, and the id stays off the page", () => {
|
|
1297
|
+
const dir = setup(
|
|
1298
|
+
"unit-same-text",
|
|
1299
|
+
withUnit([
|
|
1300
|
+
[0, { id: "a.md:3", text: LINE, part: 1, of: 2 }],
|
|
1301
|
+
[3, { id: "a.md:3", text: LINE, part: 2, of: 2 }],
|
|
1302
|
+
[6, { id: "b.md:9", text: LINE, part: 1, of: 2 }],
|
|
1303
|
+
[9, { id: "b.md:9", text: LINE, part: 2, of: 2 }],
|
|
1304
|
+
]),
|
|
1305
|
+
);
|
|
1306
|
+
const { page } = build(dir);
|
|
1307
|
+
assert.ok(!page.includes("a.md:3"), "the line's id is private");
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1170
1310
|
test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
|
|
1171
1311
|
const dir = setup("familiarity-off", approval({ familiarity: false }));
|
|
1172
1312
|
const { key } = build(dir);
|
|
@@ -94,7 +94,14 @@ const readJson = (path: string): Json => {
|
|
|
94
94
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
95
95
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
|
|
96
96
|
const PRIVATE_FIELDS = ["source_id"] as const;
|
|
97
|
-
|
|
97
|
+
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
98
|
+
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
99
|
+
const UNIT_FIELD = "unit";
|
|
100
|
+
const ALLOWED_FIELDS: readonly string[] = [
|
|
101
|
+
...PAGE_FIELDS,
|
|
102
|
+
UNIT_FIELD,
|
|
103
|
+
...PRIVATE_FIELDS,
|
|
104
|
+
];
|
|
98
105
|
|
|
99
106
|
const FAMILIARITY = [
|
|
100
107
|
{ value: "well", label: "Well" },
|
|
@@ -180,7 +187,8 @@ const mapping = (
|
|
|
180
187
|
const obj = (
|
|
181
188
|
fields: Readonly<Record<string, SchemaNode>>,
|
|
182
189
|
closed = true,
|
|
183
|
-
|
|
190
|
+
required = true,
|
|
191
|
+
): SchemaNode => ({ kind: "object", required, closed, fields });
|
|
184
192
|
|
|
185
193
|
const ITEM_SCHEMA = obj(
|
|
186
194
|
{
|
|
@@ -188,6 +196,12 @@ const ITEM_SCHEMA = obj(
|
|
|
188
196
|
...Object.fromEntries(
|
|
189
197
|
PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
|
|
190
198
|
),
|
|
199
|
+
[UNIT_FIELD]: obj(
|
|
200
|
+
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
201
|
+
{ id: string(), text: string(), part: integer(), of: integer() },
|
|
202
|
+
true,
|
|
203
|
+
false,
|
|
204
|
+
),
|
|
191
205
|
},
|
|
192
206
|
false, // an unlisted item field is the leak guard's refusal, with its own message
|
|
193
207
|
);
|
|
@@ -348,9 +362,19 @@ interface Question {
|
|
|
348
362
|
readonly short?: string;
|
|
349
363
|
readonly options: readonly Option[];
|
|
350
364
|
}
|
|
351
|
-
|
|
365
|
+
interface Unit {
|
|
366
|
+
readonly id: string;
|
|
367
|
+
readonly text: string;
|
|
368
|
+
readonly part: number;
|
|
369
|
+
readonly of: number;
|
|
370
|
+
}
|
|
371
|
+
type Item = {
|
|
372
|
+
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
373
|
+
} & {
|
|
352
374
|
readonly source_id: string;
|
|
353
375
|
readonly group: string;
|
|
376
|
+
readonly body: string;
|
|
377
|
+
readonly unit?: Unit;
|
|
354
378
|
};
|
|
355
379
|
interface Practice {
|
|
356
380
|
readonly title?: string;
|
|
@@ -507,6 +531,69 @@ const practiceOf = (
|
|
|
507
531
|
cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
|
|
508
532
|
|
|
509
533
|
// ── the items: the leak guard, and the references to the config ───────────────────────────────
|
|
534
|
+
/**
|
|
535
|
+
* A line with several requirements is split upstream into one item per requirement, each carrying
|
|
536
|
+
* `unit: {text, part, of}`. Every part of a line must be an item, once, in one group (so
|
|
537
|
+
* `labeller_knows` keeps or drops the whole line): a lost part is a requirement nobody judged.
|
|
538
|
+
*/
|
|
539
|
+
interface Part {
|
|
540
|
+
readonly item: Item;
|
|
541
|
+
readonly unit: Unit;
|
|
542
|
+
readonly n: number;
|
|
543
|
+
}
|
|
544
|
+
type Parts = readonly [Part, ...Part[]];
|
|
545
|
+
/** The items of each line, in items.json order; a line's list is never empty. */
|
|
546
|
+
const linesOf = (cfg: Config): ReadonlyMap<string, Parts> =>
|
|
547
|
+
cfg.items.reduce((byLine, item, n) => {
|
|
548
|
+
if (item.unit === undefined) return byLine;
|
|
549
|
+
const part: Part = { item, unit: item.unit, n };
|
|
550
|
+
const seen = byLine.get(item.unit.id);
|
|
551
|
+
return byLine.set(
|
|
552
|
+
item.unit.id,
|
|
553
|
+
seen ? [seen[0], ...seen.slice(1), part] : [part],
|
|
554
|
+
);
|
|
555
|
+
}, new Map<string, Parts>());
|
|
556
|
+
/** One part against the first part of its line: same count, in range, not repeated, same group. */
|
|
557
|
+
function checkPart({ item, unit, n }: Part, parts: Parts): void {
|
|
558
|
+
const [first] = parts;
|
|
559
|
+
const at = `items[${String(n)}]`;
|
|
560
|
+
if (unit.text !== first.unit.text)
|
|
561
|
+
fail(
|
|
562
|
+
`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`,
|
|
563
|
+
);
|
|
564
|
+
if (unit.of !== first.unit.of)
|
|
565
|
+
fail(
|
|
566
|
+
`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`,
|
|
567
|
+
);
|
|
568
|
+
if (unit.part < 1 || unit.part > unit.of)
|
|
569
|
+
fail(
|
|
570
|
+
`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
|
|
571
|
+
);
|
|
572
|
+
const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
|
|
573
|
+
if (earlier !== undefined)
|
|
574
|
+
fail(
|
|
575
|
+
`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`,
|
|
576
|
+
);
|
|
577
|
+
if (item.group !== first.item.group)
|
|
578
|
+
fail(
|
|
579
|
+
`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
function checkUnits(cfg: Config): void {
|
|
583
|
+
for (const [line, parts] of linesOf(cfg)) {
|
|
584
|
+
parts.forEach((p) => {
|
|
585
|
+
checkPart(p, parts);
|
|
586
|
+
});
|
|
587
|
+
const [first] = parts;
|
|
588
|
+
const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find(
|
|
589
|
+
(k) => !parts.some((p) => p.unit.part === k),
|
|
590
|
+
);
|
|
591
|
+
if (missing !== undefined)
|
|
592
|
+
fail(
|
|
593
|
+
`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
510
597
|
function checkItemFields(cfg: Config): void {
|
|
511
598
|
const sids: string[] = [];
|
|
512
599
|
cfg.items.forEach((item, n) => {
|
|
@@ -529,6 +616,11 @@ function checkItemFields(cfg: Config): void {
|
|
|
529
616
|
);
|
|
530
617
|
});
|
|
531
618
|
}
|
|
619
|
+
/** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
|
|
620
|
+
const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
|
|
621
|
+
...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
|
|
622
|
+
[UNIT_FIELD, item.unit?.text ?? ""],
|
|
623
|
+
];
|
|
532
624
|
const escapeRegExp = (s: string): string =>
|
|
533
625
|
s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
534
626
|
/**
|
|
@@ -545,7 +637,7 @@ function checkNoSourceShown(cfg: Config): void {
|
|
|
545
637
|
`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`,
|
|
546
638
|
"u",
|
|
547
639
|
);
|
|
548
|
-
const field =
|
|
640
|
+
const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
|
|
549
641
|
if (field !== undefined) {
|
|
550
642
|
fail(
|
|
551
643
|
`the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`,
|
|
@@ -612,13 +704,27 @@ function assignIds(
|
|
|
612
704
|
}
|
|
613
705
|
|
|
614
706
|
// ── a build, short of the page ──────────────────────────────────────────────────────────────────
|
|
615
|
-
|
|
616
|
-
|
|
707
|
+
/** A unit as the page shows it: the part and the line, without the line's private id. */
|
|
708
|
+
type ShownUnit = Omit<Unit, "id">;
|
|
709
|
+
const shownFields = (
|
|
710
|
+
item: Item,
|
|
711
|
+
): Readonly<Record<string, string | ShownUnit>> => ({
|
|
712
|
+
...Object.fromEntries(
|
|
617
713
|
PAGE_FIELDS.flatMap((f) => {
|
|
618
714
|
const v = item[f];
|
|
619
715
|
return v === undefined ? [] : [[f, v]];
|
|
620
716
|
}),
|
|
621
|
-
)
|
|
717
|
+
),
|
|
718
|
+
...(item.unit === undefined
|
|
719
|
+
? {}
|
|
720
|
+
: {
|
|
721
|
+
[UNIT_FIELD]: {
|
|
722
|
+
text: item.unit.text,
|
|
723
|
+
part: item.unit.part,
|
|
724
|
+
of: item.unit.of,
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
});
|
|
622
728
|
|
|
623
729
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
624
730
|
function itemsOnPage(cfg: Config, keyPath: string) {
|
|
@@ -729,6 +835,7 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
729
835
|
assertConfig(cfg);
|
|
730
836
|
checkConfig(cfg);
|
|
731
837
|
checkItemFields(cfg);
|
|
838
|
+
checkUnits(cfg);
|
|
732
839
|
checkNoSourceShown(cfg);
|
|
733
840
|
mkdirSync(outDir, { recursive: true });
|
|
734
841
|
const keyPath = join(outDir, "key.json");
|
|
@@ -412,6 +412,44 @@ test("🔴 without the store the page is read-only, and the real items stay read
|
|
|
412
412
|
assert.match(shown, /G001/, "a real item is shown read-only");
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
+
test("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
|
|
416
|
+
const line = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
417
|
+
const { page } = build(
|
|
418
|
+
setup(
|
|
419
|
+
"unit-card",
|
|
420
|
+
config({
|
|
421
|
+
items: ITEMS.map((item, n) =>
|
|
422
|
+
n === 0
|
|
423
|
+
? {
|
|
424
|
+
...item,
|
|
425
|
+
body: "TypeScript only (no JavaScript)",
|
|
426
|
+
unit: { id: "L1", text: line, part: 1, of: 2 },
|
|
427
|
+
}
|
|
428
|
+
: n === 3
|
|
429
|
+
? {
|
|
430
|
+
...item,
|
|
431
|
+
body: "top-level await is allowed",
|
|
432
|
+
unit: { id: "L1", text: line, part: 2, of: 2 },
|
|
433
|
+
}
|
|
434
|
+
: item,
|
|
435
|
+
),
|
|
436
|
+
}),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
const shown = await showPage(page, {}, {});
|
|
440
|
+
assert.match(
|
|
441
|
+
shown,
|
|
442
|
+
/From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
|
|
443
|
+
shown,
|
|
444
|
+
);
|
|
445
|
+
assert.match(shown, /From the line \(requirement 2 of 2\):/);
|
|
446
|
+
assert.equal(
|
|
447
|
+
occurrences(shown, "From the line"),
|
|
448
|
+
2,
|
|
449
|
+
"only the two parts carry the line",
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
415
453
|
test("🔴 familiarity off: no group asks how well it is known, and a group is done when its items are", async () => {
|
|
416
454
|
const { page, key } = build(
|
|
417
455
|
setup("familiarity-off-page", approval({ familiarity: false })),
|
|
@@ -53,7 +53,11 @@
|
|
|
53
53
|
* "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
|
|
54
54
|
* "practice_round": 1, optional; raise it after rewording, the practice reopens
|
|
55
55
|
* "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
|
|
56
|
-
* "context": "…"
|
|
56
|
+
* "context": "…",
|
|
57
|
+
* "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
|
|
58
|
+
* unit: optional; one requirement of a line that holds
|
|
59
|
+
* several. Every part of the line is an item, in one group;
|
|
60
|
+
* id (never shown) tells two places with the same words apart.
|
|
57
61
|
* }
|
|
58
62
|
*
|
|
59
63
|
* TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
|
|
@@ -53,7 +53,11 @@
|
|
|
53
53
|
* "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
|
|
54
54
|
* "practice_round": 1, optional; raise it after rewording, the practice reopens
|
|
55
55
|
* "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
|
|
56
|
-
* "context": "…"
|
|
56
|
+
* "context": "…",
|
|
57
|
+
* "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
|
|
58
|
+
* unit: optional; one requirement of a line that holds
|
|
59
|
+
* several. Every part of the line is an item, in one group;
|
|
60
|
+
* id (never shown) tells two places with the same words apart.
|
|
57
61
|
* }
|
|
58
62
|
*
|
|
59
63
|
* TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
|
|
@@ -312,6 +312,11 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
312
312
|
card.appendChild(rich("p", "body", item.body));
|
|
313
313
|
if (item.code) card.appendChild(codeBlock(item.code));
|
|
314
314
|
if (item.context) card.appendChild(rich("p", "muted", item.context));
|
|
315
|
+
// One requirement of a line that holds several: which one, and the whole line it came from.
|
|
316
|
+
if (item.unit) {
|
|
317
|
+
card.appendChild(el("p", "muted", "From the line (requirement " + item.unit.part + " of " + item.unit.of + "):"));
|
|
318
|
+
card.appendChild(rich("p", "quote", item.unit.text));
|
|
319
|
+
}
|
|
315
320
|
var a = answersOf(item.id);
|
|
316
321
|
askOne(card, a, state.editing[item.id], function (q, v) {
|
|
317
322
|
delete state.editing[item.id];
|