paperlint 14.3.0 → 14.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 +168 -1
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +12 -2
- package/skills/build-label-page/SKILL.md.spec.ts +11 -1
- package/skills/build-label-page/references/checklist.md +3 -3
- package/skills/build-label-page/references/closed-choice-generator.md +73 -13
- package/skills/build-label-page/scripts/fixtures/v7-cards-ru.json +23 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +8 -9
- package/skills/build-label-page/scripts/label-page-browser.test.ts +278 -0
- package/skills/build-label-page/scripts/label-page-core.js +126 -14
- package/skills/build-label-page/scripts/label-page-core.test.ts +68 -0
- package/skills/build-label-page/scripts/label-page-core.ts +193 -13
- package/skills/build-label-page/scripts/label-page-html.test.ts +29 -0
- package/skills/build-label-page/scripts/label-page-translations.test.ts +371 -0
- package/skills/build-label-page/scripts/page-template.html +83 -40
|
@@ -451,6 +451,42 @@ const QUESTION_SCHEMA = obj({
|
|
|
451
451
|
short: string(false),
|
|
452
452
|
options: array(OPTION_SCHEMA, true, 2),
|
|
453
453
|
});
|
|
454
|
+
// Page-level words the blocks use (references/closed-choice-generator.md §«Content blocks»).
|
|
455
|
+
const LABELS = optional({
|
|
456
|
+
place: string(false),
|
|
457
|
+
body: string(false),
|
|
458
|
+
line: string(false),
|
|
459
|
+
line_part: string(false),
|
|
460
|
+
repeat: string(false),
|
|
461
|
+
practice_example: string(false),
|
|
462
|
+
hidden: string(false),
|
|
463
|
+
hidden_one: string(false),
|
|
464
|
+
wait: string(false),
|
|
465
|
+
how_to: string(false),
|
|
466
|
+
for_example: string(false),
|
|
467
|
+
});
|
|
468
|
+
// The page's own words in another language (#394): never the quote, code, notes or any card text
|
|
469
|
+
// the labeller judges. Keyed like the page, so a translation names its questions and answers by id.
|
|
470
|
+
const TRANSLATION = obj({
|
|
471
|
+
title: string(false),
|
|
472
|
+
intro: string(false),
|
|
473
|
+
labels: LABELS,
|
|
474
|
+
// the first question as the cards with a `situation` ask it; `{n}` is the marked line's number
|
|
475
|
+
situation_question: string(false),
|
|
476
|
+
questions: mapping(
|
|
477
|
+
obj({
|
|
478
|
+
text: string(),
|
|
479
|
+
short: string(false),
|
|
480
|
+
options: mapping(
|
|
481
|
+
obj({ label: string(), example: string(), ask_why: string(false) }),
|
|
482
|
+
NAME_PATTERN,
|
|
483
|
+
1,
|
|
484
|
+
),
|
|
485
|
+
}),
|
|
486
|
+
NAME_PATTERN,
|
|
487
|
+
1,
|
|
488
|
+
),
|
|
489
|
+
});
|
|
454
490
|
const configSchema = (kind: Kind): SchemaNode =>
|
|
455
491
|
obj({
|
|
456
492
|
study: string(true, NAME_PATTERN), // becomes a file name
|
|
@@ -471,18 +507,10 @@ const configSchema = (kind: Kind): SchemaNode =>
|
|
|
471
507
|
questions: array(QUESTION_SCHEMA, true, 1),
|
|
472
508
|
practice: array(practiceSchema(kind), true, MIN_PRACTICE),
|
|
473
509
|
practice_round: integer(false),
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
line: string(false),
|
|
479
|
-
line_part: string(false),
|
|
480
|
-
repeat: string(false),
|
|
481
|
-
practice_example: string(false),
|
|
482
|
-
hidden: string(false),
|
|
483
|
-
hidden_one: string(false),
|
|
484
|
-
wait: string(false),
|
|
485
|
-
}),
|
|
510
|
+
labels: LABELS,
|
|
511
|
+
// the language of the page's own words, and the same words in more languages, with a switch
|
|
512
|
+
lang: string(false, NAME_PATTERN),
|
|
513
|
+
translations: mapping(TRANSLATION, NAME_PATTERN, 1, false),
|
|
486
514
|
items: array(itemSchema(kind), true, 1),
|
|
487
515
|
});
|
|
488
516
|
/** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
|
|
@@ -736,8 +764,32 @@ interface Config {
|
|
|
736
764
|
readonly practice: readonly Practice[];
|
|
737
765
|
readonly practice_round?: number;
|
|
738
766
|
readonly labels?: Readonly<Record<string, string>>;
|
|
767
|
+
readonly lang?: string;
|
|
768
|
+
readonly translations?: Readonly<Record<string, Translation>>;
|
|
739
769
|
readonly items: readonly Item[];
|
|
740
770
|
}
|
|
771
|
+
interface QuestionWords {
|
|
772
|
+
readonly text: string;
|
|
773
|
+
readonly short?: string;
|
|
774
|
+
readonly options: Readonly<
|
|
775
|
+
Record<
|
|
776
|
+
string,
|
|
777
|
+
{
|
|
778
|
+
readonly label: string;
|
|
779
|
+
readonly example: string;
|
|
780
|
+
readonly ask_why?: string;
|
|
781
|
+
}
|
|
782
|
+
>
|
|
783
|
+
>;
|
|
784
|
+
}
|
|
785
|
+
/** The page's own words in one more language (#394). */
|
|
786
|
+
interface Translation {
|
|
787
|
+
readonly title?: string;
|
|
788
|
+
readonly intro?: string;
|
|
789
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
790
|
+
readonly situation_question?: string;
|
|
791
|
+
readonly questions: Readonly<Record<string, QuestionWords>>;
|
|
792
|
+
}
|
|
741
793
|
/** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
|
|
742
794
|
function assertConfig(value: Json): asserts value is Json & Config {
|
|
743
795
|
checkValue(value, CONFIG_SCHEMA, "");
|
|
@@ -1157,6 +1209,129 @@ function checkNoSourceShown(cfg: Config): void {
|
|
|
1157
1209
|
}
|
|
1158
1210
|
}
|
|
1159
1211
|
|
|
1212
|
+
// ── a second language for the page's own words (#394) ─────────────────────────────────────────
|
|
1213
|
+
/** The language of the page's own words when items.json does not name it. */
|
|
1214
|
+
const BASE_LANG = "en";
|
|
1215
|
+
/** In a translated `situation_question`, the number of the card's marked line. */
|
|
1216
|
+
const NUMBER_TOKEN = "{n}";
|
|
1217
|
+
/** A translated text is given exactly when the page shows the text it translates. */
|
|
1218
|
+
function checkPresence(
|
|
1219
|
+
where: string,
|
|
1220
|
+
shown: string | undefined,
|
|
1221
|
+
given: string | undefined,
|
|
1222
|
+
): void {
|
|
1223
|
+
if (shown !== undefined && given === undefined)
|
|
1224
|
+
fail(`${where}: is required and missing — ${shown}`);
|
|
1225
|
+
if (shown === undefined && given !== undefined)
|
|
1226
|
+
fail(`${where}: is never shown — the page has no text it translates`);
|
|
1227
|
+
}
|
|
1228
|
+
/** The cards' first question, translated: given exactly when a card has a `situation`. */
|
|
1229
|
+
function checkSituationWords(cfg: Config, at: string, t: Translation): void {
|
|
1230
|
+
const cards = cardsOf(cfg).filter(([, c]) => c.situation !== undefined);
|
|
1231
|
+
const first = cards[0];
|
|
1232
|
+
checkPresence(
|
|
1233
|
+
`${at}.situation_question`,
|
|
1234
|
+
first &&
|
|
1235
|
+
`${first[0]}.situation asks the first question in the card's own words`,
|
|
1236
|
+
t.situation_question,
|
|
1237
|
+
);
|
|
1238
|
+
const q = t.situation_question ?? "";
|
|
1239
|
+
const bare = cards.find(([, c]) => lineOf(c) === undefined);
|
|
1240
|
+
if (
|
|
1241
|
+
bare !== undefined &&
|
|
1242
|
+
[NUMBER_TOKEN, LINE_TOKEN].some((token) => q.includes(token))
|
|
1243
|
+
)
|
|
1244
|
+
fail(
|
|
1245
|
+
`${at}.situation_question: names the marked line, and ${bare[0]} has no \`excerpt\` — add it, or write the question without \`${NUMBER_TOKEN}\` and \`${LINE_TOKEN}\``,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
/** One answer in the translation's words; refused when the translation leaves it out. */
|
|
1249
|
+
const optionIn = (o: Option, words: QuestionWords, where: string): Option => {
|
|
1250
|
+
const w =
|
|
1251
|
+
words.options[o.value] ??
|
|
1252
|
+
fail(
|
|
1253
|
+
`${where}.options.${o.value}: is required and missing — every answer is translated`,
|
|
1254
|
+
);
|
|
1255
|
+
checkPresence(
|
|
1256
|
+
`${where}.options.${o.value}.ask_why`,
|
|
1257
|
+
o.ask_why && "this answer asks why",
|
|
1258
|
+
w.ask_why,
|
|
1259
|
+
);
|
|
1260
|
+
return {
|
|
1261
|
+
...R.omit(o, ["ask_why"]),
|
|
1262
|
+
label: w.label,
|
|
1263
|
+
example: w.example,
|
|
1264
|
+
...R.pickBy({ ask_why: w.ask_why }, R.isDefined),
|
|
1265
|
+
};
|
|
1266
|
+
};
|
|
1267
|
+
/** One question in the translation's words: every answer of it, no answer it does not have. */
|
|
1268
|
+
function questionIn(q: Question, t: Translation, at: string): Question {
|
|
1269
|
+
const where = `${at}.questions.${q.id}`;
|
|
1270
|
+
const words =
|
|
1271
|
+
t.questions[q.id] ??
|
|
1272
|
+
fail(
|
|
1273
|
+
`${where}: is required and missing — every question of the page is translated`,
|
|
1274
|
+
);
|
|
1275
|
+
const values = q.options.map((o) => o.value);
|
|
1276
|
+
const stray = Object.keys(words.options).find((v) => !values.includes(v));
|
|
1277
|
+
if (stray !== undefined)
|
|
1278
|
+
fail(`${where}.options.${stray}: not one of ${values.join(", ")}`);
|
|
1279
|
+
const options = q.options.map((o) => optionIn(o, words, where));
|
|
1280
|
+
const twice = firstRepeat(options.map((o) => o.label));
|
|
1281
|
+
if (twice !== undefined)
|
|
1282
|
+
fail(
|
|
1283
|
+
`${where}.options.${String(values[twice.at])}.label: ${repr(options[twice.at]?.label)} reads the same as options.${String(values[twice.earlier])}.label — each button needs its own label`,
|
|
1284
|
+
);
|
|
1285
|
+
return {
|
|
1286
|
+
id: q.id,
|
|
1287
|
+
text: words.text,
|
|
1288
|
+
...R.pickBy({ short: words.short }, R.isDefined),
|
|
1289
|
+
options,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Every language of the page with its words whole, the translations checked: each covers every
|
|
1294
|
+
* question and answer of the page and names none it does not have, and gives an intro, a reason
|
|
1295
|
+
* prompt and a situation question exactly where the page shows one. A label it leaves out reads as
|
|
1296
|
+
* in the page's own language.
|
|
1297
|
+
*/
|
|
1298
|
+
function translationsOf(cfg: Config) {
|
|
1299
|
+
const base = cfg.lang ?? BASE_LANG;
|
|
1300
|
+
return R.mapValues(cfg.translations ?? {}, (t, lang) => {
|
|
1301
|
+
const at = `translations.${lang}`;
|
|
1302
|
+
if (lang === base)
|
|
1303
|
+
fail(
|
|
1304
|
+
`${at}: is the page's own language (\`lang\` is ${repr(base)}) — translate into another one`,
|
|
1305
|
+
);
|
|
1306
|
+
checkPresence(
|
|
1307
|
+
`${at}.intro`,
|
|
1308
|
+
cfg.intro && "the page has an `intro`",
|
|
1309
|
+
t.intro,
|
|
1310
|
+
);
|
|
1311
|
+
checkSituationWords(cfg, at, t);
|
|
1312
|
+
const ids = cfg.questions.map((q) => q.id);
|
|
1313
|
+
const stray = Object.keys(t.questions).find((id) => !ids.includes(id));
|
|
1314
|
+
if (stray !== undefined)
|
|
1315
|
+
fail(
|
|
1316
|
+
`${at}.questions.${stray}: not a question of this page (${ids.join(", ")})`,
|
|
1317
|
+
);
|
|
1318
|
+
return {
|
|
1319
|
+
title: t.title ?? cfg.title,
|
|
1320
|
+
intro: t.intro ?? "",
|
|
1321
|
+
labels: { ...DEFAULT_LABELS, ...cfg.labels, ...t.labels },
|
|
1322
|
+
...R.pickBy({ situation_question: t.situation_question }, R.isDefined),
|
|
1323
|
+
questions: cfg.questions.map((q) => questionIn(q, t, at)),
|
|
1324
|
+
};
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
type Translated = ReturnType<typeof translationsOf>;
|
|
1328
|
+
/** The languages: the page's own when items.json names it, and with translations always (English when unnamed).
|
|
1329
|
+
* A page that names neither is as it was. */
|
|
1330
|
+
const languagesOf = (cfg: Config, translated: Translated) =>
|
|
1331
|
+
cfg.translations === undefined
|
|
1332
|
+
? R.pickBy({ lang: cfg.lang }, R.isDefined)
|
|
1333
|
+
: { lang: cfg.lang ?? BASE_LANG, translations: translated };
|
|
1334
|
+
|
|
1160
1335
|
// ── ids: neutral, in an order fixed by the seed, kept across rebuilds ─────────────────────────
|
|
1161
1336
|
interface KeyEntry {
|
|
1162
1337
|
readonly source_id: string;
|
|
@@ -1320,6 +1495,8 @@ const DEFAULT_LABELS: Readonly<Record<string, string>> = {
|
|
|
1320
1495
|
hidden: "lines {from}\u2013{to}",
|
|
1321
1496
|
hidden_one: "line {from}",
|
|
1322
1497
|
wait: "\u23f3 You can answer in {s} s",
|
|
1498
|
+
how_to: "📖 How to answer",
|
|
1499
|
+
for_example: "For example: ",
|
|
1323
1500
|
};
|
|
1324
1501
|
/** The answerers and the dwell, only when items.json sets them: a page without them is as it was. */
|
|
1325
1502
|
const twoKeys = (cfg: Config) =>
|
|
@@ -1332,6 +1509,7 @@ function payloadOf(
|
|
|
1332
1509
|
cfg: Config,
|
|
1333
1510
|
page: ReturnType<typeof itemsOnPage>,
|
|
1334
1511
|
described: Described,
|
|
1512
|
+
translated: Translated,
|
|
1335
1513
|
) {
|
|
1336
1514
|
const used = Object.keys(cfg.groups).filter((g) =>
|
|
1337
1515
|
page.items.some((i) => i.group === g),
|
|
@@ -1347,6 +1525,7 @@ function payloadOf(
|
|
|
1347
1525
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
1348
1526
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
1349
1527
|
...twoKeys(cfg),
|
|
1528
|
+
...languagesOf(cfg, translated),
|
|
1350
1529
|
questions: cfg.questions,
|
|
1351
1530
|
practice: {
|
|
1352
1531
|
round: practiceRound(cfg),
|
|
@@ -1445,6 +1624,7 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1445
1624
|
checkBlocks(cfg);
|
|
1446
1625
|
checkCardText(cfg);
|
|
1447
1626
|
checkNoSourceShown(cfg);
|
|
1627
|
+
const translated = translationsOf(cfg);
|
|
1448
1628
|
const described = describeRules(cfg, dirname(configPath));
|
|
1449
1629
|
mkdirSync(outDir, { recursive: true });
|
|
1450
1630
|
const keyPath = join(outDir, "key.json");
|
|
@@ -1452,7 +1632,7 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
1452
1632
|
const page = itemsOnPage(cfg, keyPath);
|
|
1453
1633
|
writeJson(keyPath, keyOf(cfg, page, earlier));
|
|
1454
1634
|
return {
|
|
1455
|
-
payload: payloadOf(cfg, page, described),
|
|
1635
|
+
payload: payloadOf(cfg, page, described, translated),
|
|
1456
1636
|
dropped: cfg.items.length - page.items.length,
|
|
1457
1637
|
};
|
|
1458
1638
|
}
|
|
@@ -504,3 +504,32 @@ test("🔴 «All items are complete» waits for every group's familiarity too, a
|
|
|
504
504
|
);
|
|
505
505
|
assert.deepEqual([await said({}), await said(rated)], [false, true]);
|
|
506
506
|
});
|
|
507
|
+
|
|
508
|
+
test("🔴 the status line names nothing missing: empty while a card is unanswered, «Saved <time>» once part of it is saved, and its values keep what is missing", async () => {
|
|
509
|
+
const { page, key } = build(setup("status-missing", config()));
|
|
510
|
+
const [a, b] = Object.keys(key.ids);
|
|
511
|
+
assert.ok(a !== undefined && b !== undefined);
|
|
512
|
+
const savedAt = "2026-01-02T10:00:00Z";
|
|
513
|
+
const { app } = await openLive(page, {
|
|
514
|
+
answers: { [a]: { answers: { label: "yes" }, savedAt, source: "page" } },
|
|
515
|
+
groups: {},
|
|
516
|
+
practice: DONE_PRACTICE,
|
|
517
|
+
});
|
|
518
|
+
const statusOf = (id: string) => {
|
|
519
|
+
const card = app
|
|
520
|
+
.getByTestId("item")
|
|
521
|
+
.find((n) => z.object({ id: z.string() }).parse(n.meta()).id === id);
|
|
522
|
+
const line = card?.getByTestId("status")[0];
|
|
523
|
+
return [line?.meta(), line?.textContent];
|
|
524
|
+
};
|
|
525
|
+
assert.deepEqual(
|
|
526
|
+
[statusOf(a), statusOf(b)],
|
|
527
|
+
[
|
|
528
|
+
[
|
|
529
|
+
{ state: "missing", missing: ["boundary"] },
|
|
530
|
+
`Saved ${new Date(savedAt).toLocaleTimeString()}`,
|
|
531
|
+
],
|
|
532
|
+
[{ state: "missing", missing: ["label", "boundary"] }, ""],
|
|
533
|
+
],
|
|
534
|
+
);
|
|
535
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second language for the page's own words (#394): what `translations` must cover, what the page
|
|
3
|
+
* is handed, and that the ids and the key do not depend on it.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { test } from "vitest";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import {
|
|
11
|
+
ITEMS,
|
|
12
|
+
QUESTIONS,
|
|
13
|
+
build,
|
|
14
|
+
config,
|
|
15
|
+
run,
|
|
16
|
+
setup,
|
|
17
|
+
} from "../../../test/label-page-kit.ts";
|
|
18
|
+
|
|
19
|
+
/** The questions of the kit's page in Russian: every question and every answer, by id and value. */
|
|
20
|
+
const RU = {
|
|
21
|
+
title: "Разметьте",
|
|
22
|
+
labels: { how_to: "📖 Как отвечать", for_example: "Например: " },
|
|
23
|
+
questions: {
|
|
24
|
+
label: {
|
|
25
|
+
text: "Это верно?",
|
|
26
|
+
short: "верно?",
|
|
27
|
+
options: {
|
|
28
|
+
yes: { label: "Да", example: "Сортировка вернула 1, 2, 3." },
|
|
29
|
+
no: { label: "Нет", example: "Сортировка вернула 3, 1, 2." },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
boundary: {
|
|
33
|
+
text: "Ясный случай или пограничный?",
|
|
34
|
+
options: {
|
|
35
|
+
clear: { label: "Ясный", example: "Пустой список." },
|
|
36
|
+
boundary: { label: "Пограничный", example: "Два равных ключа." },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const withRu = (over: Record<string, unknown> = {}) =>
|
|
42
|
+
config({ translations: { ru: RU }, ...over });
|
|
43
|
+
|
|
44
|
+
/** The page's data: the JSON the template is filled with. */
|
|
45
|
+
const dataOf = (page: string): unknown =>
|
|
46
|
+
JSON.parse(
|
|
47
|
+
String(
|
|
48
|
+
page
|
|
49
|
+
.split('<script type="application/json" id="data">')[1]
|
|
50
|
+
?.split("</script>")[0],
|
|
51
|
+
).replaceAll("<\\/", "</"),
|
|
52
|
+
);
|
|
53
|
+
const Data = z
|
|
54
|
+
.object({ lang: z.string(), translations: z.record(z.string(), z.unknown()) })
|
|
55
|
+
.loose();
|
|
56
|
+
|
|
57
|
+
/** What the page is handed for Russian: the kit's labels with RU's over them, and every question. */
|
|
58
|
+
function expectedRu() {
|
|
59
|
+
const [label, boundary] = QUESTIONS;
|
|
60
|
+
assert.ok(label !== undefined && boundary !== undefined);
|
|
61
|
+
const inRu = (qid: "label" | "boundary", value: string) =>
|
|
62
|
+
qid === "label"
|
|
63
|
+
? RU.questions.label.options[value === "yes" ? "yes" : "no"]
|
|
64
|
+
: RU.questions.boundary.options[value === "clear" ? "clear" : "boundary"];
|
|
65
|
+
return {
|
|
66
|
+
title: RU.title,
|
|
67
|
+
intro: "",
|
|
68
|
+
labels: {
|
|
69
|
+
place: "{id} · {n} of {total}",
|
|
70
|
+
line: "Line",
|
|
71
|
+
line_part: "{line} · part {part} of {of}",
|
|
72
|
+
repeat: "Card {index} of {of}",
|
|
73
|
+
practice_example: "",
|
|
74
|
+
hidden: "lines {from}–{to}",
|
|
75
|
+
hidden_one: "line {from}",
|
|
76
|
+
wait: "⏳ You can answer in {s} s",
|
|
77
|
+
...RU.labels,
|
|
78
|
+
},
|
|
79
|
+
questions: [
|
|
80
|
+
{
|
|
81
|
+
id: "label",
|
|
82
|
+
text: "Это верно?",
|
|
83
|
+
short: "верно?",
|
|
84
|
+
options: label.options.map((o) => ({
|
|
85
|
+
value: o.value,
|
|
86
|
+
...inRu("label", o.value),
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "boundary",
|
|
91
|
+
text: "Ясный случай или пограничный?",
|
|
92
|
+
options: boundary.options.map((o) => ({
|
|
93
|
+
value: o.value,
|
|
94
|
+
...inRu("boundary", o.value),
|
|
95
|
+
})),
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** The keys a built page's data has, of `names`. */
|
|
101
|
+
const dataKeys = (page: string, names: readonly string[]) =>
|
|
102
|
+
names.filter((k) =>
|
|
103
|
+
Object.hasOwn(z.object({}).loose().parse(dataOf(page)), k),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
test("🔴 a translation hands the page each language's words whole, and leaves the key byte for byte as without it", () => {
|
|
107
|
+
const plain = setup("lang-plain", config());
|
|
108
|
+
const translated = setup("lang-ru", withRu());
|
|
109
|
+
const { page } = build(translated);
|
|
110
|
+
const keyOf = (dir: string) =>
|
|
111
|
+
readFileSync(join(dir, "out", "key.json"), "utf8");
|
|
112
|
+
const data = Data.parse(dataOf(page));
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
[
|
|
115
|
+
data.lang,
|
|
116
|
+
data.translations,
|
|
117
|
+
dataKeys(build(plain).page, ["lang", "translations"]),
|
|
118
|
+
],
|
|
119
|
+
// Guards: a page without translations carries neither key, so it is as it was.
|
|
120
|
+
["en", { ru: expectedRu() }, []],
|
|
121
|
+
);
|
|
122
|
+
assert.equal(keyOf(translated), keyOf(plain));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("🔴 a page that names its language without translations keeps it, and still carries no translations", () => {
|
|
126
|
+
const page = build(setup("lang-only", config({ lang: "de" }))).page;
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
[
|
|
129
|
+
z.object({ lang: z.string() }).loose().parse(dataOf(page)).lang,
|
|
130
|
+
dataKeys(page, ["lang", "translations"]),
|
|
131
|
+
],
|
|
132
|
+
["de", ["lang"]],
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** RU with an intro, a reason prompt on «no» and a card question naming the marked line. */
|
|
137
|
+
const RU_FULL = {
|
|
138
|
+
...RU,
|
|
139
|
+
intro: "Отвечайте как знаете.",
|
|
140
|
+
situation_question: "Строка {n} верна?",
|
|
141
|
+
questions: {
|
|
142
|
+
...RU.questions,
|
|
143
|
+
label: {
|
|
144
|
+
...RU.questions.label,
|
|
145
|
+
options: {
|
|
146
|
+
...RU.questions.label.options,
|
|
147
|
+
no: { ...RU.questions.label.options.no, ask_why: "Почему?" },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
/** A German page with an intro, a reason prompt and a card that asks in its own words, and RU_FULL. */
|
|
153
|
+
function fullConfig() {
|
|
154
|
+
const [label, boundary] = QUESTIONS;
|
|
155
|
+
assert.ok(label !== undefined && boundary !== undefined);
|
|
156
|
+
const [first, ...rest] = ITEMS;
|
|
157
|
+
return config({
|
|
158
|
+
lang: "de",
|
|
159
|
+
intro: "Answer as you know.",
|
|
160
|
+
questions: [
|
|
161
|
+
{
|
|
162
|
+
...label,
|
|
163
|
+
options: [
|
|
164
|
+
label.options[0],
|
|
165
|
+
{ ...label.options[1], ask_why: "Why not?" },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
boundary,
|
|
169
|
+
],
|
|
170
|
+
translations: { ru: RU_FULL },
|
|
171
|
+
items: [
|
|
172
|
+
{
|
|
173
|
+
...first,
|
|
174
|
+
situation: { question: "Is line 3 correct?" },
|
|
175
|
+
excerpt: { lines: [{ n: 3, text: "x = 1", mark: true }] },
|
|
176
|
+
},
|
|
177
|
+
...rest,
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const FullData = z
|
|
182
|
+
.object({
|
|
183
|
+
lang: z.string(),
|
|
184
|
+
translations: z.object({
|
|
185
|
+
ru: z
|
|
186
|
+
.object({
|
|
187
|
+
intro: z.string(),
|
|
188
|
+
situation_question: z.string(),
|
|
189
|
+
questions: z.array(z.unknown()),
|
|
190
|
+
})
|
|
191
|
+
.loose(),
|
|
192
|
+
}),
|
|
193
|
+
})
|
|
194
|
+
.loose();
|
|
195
|
+
|
|
196
|
+
test("🔴 the page's own language may be named, and an intro, a reason prompt and a card's own question are translated with it", () => {
|
|
197
|
+
const data = FullData.parse(
|
|
198
|
+
dataOf(build(setup("lang-full", fullConfig())).page),
|
|
199
|
+
);
|
|
200
|
+
const ru = data.translations.ru;
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
[data.lang, ru.intro, ru.situation_question, ru.questions[0]],
|
|
203
|
+
[
|
|
204
|
+
"de",
|
|
205
|
+
RU_FULL.intro,
|
|
206
|
+
RU_FULL.situation_question,
|
|
207
|
+
{
|
|
208
|
+
id: "label",
|
|
209
|
+
text: "Это верно?",
|
|
210
|
+
short: "верно?",
|
|
211
|
+
options: [
|
|
212
|
+
{ value: "yes", ...RU.questions.label.options.yes },
|
|
213
|
+
{ value: "no", ...RU_FULL.questions.label.options.no },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/** The kit's page with its Russian words changed by `ru`, and more of the config by `over`. */
|
|
221
|
+
const refusedWith = (
|
|
222
|
+
ru: Record<string, unknown>,
|
|
223
|
+
over: Record<string, unknown> = {},
|
|
224
|
+
) => withRu({ translations: { ru: { ...RU, ...ru } }, ...over });
|
|
225
|
+
const ruQuestion = (
|
|
226
|
+
qid: "label" | "boundary",
|
|
227
|
+
change: Record<string, unknown>,
|
|
228
|
+
) => ({
|
|
229
|
+
questions: { ...RU.questions, [qid]: { ...RU.questions[qid], ...change } },
|
|
230
|
+
});
|
|
231
|
+
const ruOptions = (options: Record<string, unknown>) =>
|
|
232
|
+
ruQuestion("label", { options });
|
|
233
|
+
const situated = (excerpt: boolean) => ({
|
|
234
|
+
items: ITEMS.map((i, n) =>
|
|
235
|
+
n === 0
|
|
236
|
+
? {
|
|
237
|
+
...i,
|
|
238
|
+
situation: { question: "Is it correct?" },
|
|
239
|
+
...(excerpt
|
|
240
|
+
? { excerpt: { lines: [{ n: 3, text: "x", mark: true }] } }
|
|
241
|
+
: {}),
|
|
242
|
+
}
|
|
243
|
+
: i,
|
|
244
|
+
),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/** A page whose first question asks why on «no». */
|
|
248
|
+
function asksWhy() {
|
|
249
|
+
const [label] = QUESTIONS;
|
|
250
|
+
assert.ok(label !== undefined);
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
...label,
|
|
254
|
+
options: [label.options[0], { ...label.options[1], ask_why: "Why?" }],
|
|
255
|
+
},
|
|
256
|
+
QUESTIONS[1],
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
/** Configs only the translation checks refuse, each with its refusal. */
|
|
260
|
+
const REFUSALS: readonly (readonly [
|
|
261
|
+
string,
|
|
262
|
+
Record<string, unknown>,
|
|
263
|
+
string,
|
|
264
|
+
])[] = [
|
|
265
|
+
[
|
|
266
|
+
"own",
|
|
267
|
+
withRu({ translations: { en: RU } }),
|
|
268
|
+
"translations.en: is the page's own language (`lang` is 'en') — translate into another one",
|
|
269
|
+
],
|
|
270
|
+
[
|
|
271
|
+
"no-question",
|
|
272
|
+
refusedWith({ questions: { label: RU.questions.label } }),
|
|
273
|
+
"translations.ru.questions.boundary: is required and missing — every question of the page is translated",
|
|
274
|
+
],
|
|
275
|
+
[
|
|
276
|
+
"stray-question",
|
|
277
|
+
refusedWith({
|
|
278
|
+
questions: { ...RU.questions, ghost: RU.questions.label },
|
|
279
|
+
}),
|
|
280
|
+
"translations.ru.questions.ghost: not a question of this page (label, boundary)",
|
|
281
|
+
],
|
|
282
|
+
[
|
|
283
|
+
"no-answer",
|
|
284
|
+
refusedWith(ruOptions({ yes: RU.questions.label.options.yes })),
|
|
285
|
+
"translations.ru.questions.label.options.no: is required and missing — every answer is translated",
|
|
286
|
+
],
|
|
287
|
+
[
|
|
288
|
+
"stray-answer",
|
|
289
|
+
refusedWith(
|
|
290
|
+
ruOptions({
|
|
291
|
+
...RU.questions.label.options,
|
|
292
|
+
maybe: { label: "Может", example: "e" },
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
"translations.ru.questions.label.options.maybe: not one of yes, no",
|
|
296
|
+
],
|
|
297
|
+
[
|
|
298
|
+
"same-label",
|
|
299
|
+
refusedWith(
|
|
300
|
+
ruOptions({
|
|
301
|
+
...RU.questions.label.options,
|
|
302
|
+
no: { label: " да ", example: "e" },
|
|
303
|
+
}),
|
|
304
|
+
),
|
|
305
|
+
"translations.ru.questions.label.options.no.label: ' да ' reads the same as options.yes.label — each button needs its own label",
|
|
306
|
+
],
|
|
307
|
+
[
|
|
308
|
+
"no-why",
|
|
309
|
+
withRu({ questions: asksWhy() }),
|
|
310
|
+
"translations.ru.questions.label.options.no.ask_why: is required and missing — this answer asks why",
|
|
311
|
+
],
|
|
312
|
+
[
|
|
313
|
+
"stray-why",
|
|
314
|
+
refusedWith(
|
|
315
|
+
ruOptions({
|
|
316
|
+
...RU.questions.label.options,
|
|
317
|
+
no: { ...RU.questions.label.options.no, ask_why: "Почему?" },
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
320
|
+
"translations.ru.questions.label.options.no.ask_why: is never shown — the page has no text it translates",
|
|
321
|
+
],
|
|
322
|
+
[
|
|
323
|
+
"no-intro",
|
|
324
|
+
withRu({ intro: "Answer." }),
|
|
325
|
+
"translations.ru.intro: is required and missing — the page has an `intro`",
|
|
326
|
+
],
|
|
327
|
+
[
|
|
328
|
+
"stray-intro",
|
|
329
|
+
refusedWith({ intro: "Отвечайте." }),
|
|
330
|
+
"translations.ru.intro: is never shown — the page has no text it translates",
|
|
331
|
+
],
|
|
332
|
+
[
|
|
333
|
+
"no-situation",
|
|
334
|
+
withRu(situated(true)),
|
|
335
|
+
"translations.ru.situation_question: is required and missing — items[0].situation asks the first question in the card's own words",
|
|
336
|
+
],
|
|
337
|
+
[
|
|
338
|
+
"stray-situation",
|
|
339
|
+
refusedWith({ situation_question: "Верно?" }),
|
|
340
|
+
"translations.ru.situation_question: is never shown — the page has no text it translates",
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
"line-without-code",
|
|
344
|
+
refusedWith({ situation_question: "Строка {n} верна?" }, situated(false)),
|
|
345
|
+
"translations.ru.situation_question: names the marked line, and items[0] has no `excerpt` — add it, or write the question without `{n}` and `{line}`",
|
|
346
|
+
],
|
|
347
|
+
];
|
|
348
|
+
/** Build `cfg` in a folder of its own; the exit status and what it printed. */
|
|
349
|
+
const tryBuild = (name: string, cfg: Record<string, unknown>) => {
|
|
350
|
+
const dir = setup(name, cfg);
|
|
351
|
+
return run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
test("🔴 a translation that leaves out or invents a question, an answer, an intro, a reason prompt or a card's question stops the build, by path", () => {
|
|
355
|
+
const cases = REFUSALS;
|
|
356
|
+
assert.deepEqual(
|
|
357
|
+
cases.map(([name, cfg]) =>
|
|
358
|
+
tryBuild(`lang-refused-${name}`, cfg).out.trim(),
|
|
359
|
+
),
|
|
360
|
+
cases.map(([, , why]) => `REFUSED: ${why}`),
|
|
361
|
+
);
|
|
362
|
+
// Guards: the same card question with its marked line in the code, and one without `{n}` on a
|
|
363
|
+
// card without code, both build.
|
|
364
|
+
assert.deepEqual(
|
|
365
|
+
[
|
|
366
|
+
refusedWith({ situation_question: "Строка {n} верна?" }, situated(true)),
|
|
367
|
+
refusedWith({ situation_question: "Верно?" }, situated(false)),
|
|
368
|
+
].map((cfg, n) => tryBuild(`lang-situated-${String(n)}`, cfg).status),
|
|
369
|
+
[0, 0],
|
|
370
|
+
);
|
|
371
|
+
});
|