paperlint 14.4.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 +11 -1
- package/skills/build-label-page/SKILL.md.spec.ts +10 -0
- package/skills/build-label-page/references/closed-choice-generator.md +56 -0
- package/skills/build-label-page/scripts/fixtures/v7-cards-ru.json +23 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +217 -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-translations.test.ts +371 -0
- package/skills/build-label-page/scripts/page-template.html +67 -25
|
@@ -193,8 +193,45 @@ interface Config {
|
|
|
193
193
|
readonly practice: readonly Practice[];
|
|
194
194
|
readonly practice_round?: number;
|
|
195
195
|
readonly labels?: Readonly<Record<string, string>>;
|
|
196
|
+
readonly lang?: string;
|
|
197
|
+
readonly translations?: Readonly<Record<string, Translation>>;
|
|
196
198
|
readonly items: readonly Item[];
|
|
197
199
|
}
|
|
200
|
+
interface QuestionWords {
|
|
201
|
+
readonly text: string;
|
|
202
|
+
readonly short?: string;
|
|
203
|
+
readonly options: Readonly<Record<string, {
|
|
204
|
+
readonly label: string;
|
|
205
|
+
readonly example: string;
|
|
206
|
+
readonly ask_why?: string;
|
|
207
|
+
}>>;
|
|
208
|
+
}
|
|
209
|
+
/** The page's own words in one more language (#394). */
|
|
210
|
+
interface Translation {
|
|
211
|
+
readonly title?: string;
|
|
212
|
+
readonly intro?: string;
|
|
213
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
214
|
+
readonly situation_question?: string;
|
|
215
|
+
readonly questions: Readonly<Record<string, QuestionWords>>;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Every language of the page with its words whole, the translations checked: each covers every
|
|
219
|
+
* question and answer of the page and names none it does not have, and gives an intro, a reason
|
|
220
|
+
* prompt and a situation question exactly where the page shows one. A label it leaves out reads as
|
|
221
|
+
* in the page's own language.
|
|
222
|
+
*/
|
|
223
|
+
declare function translationsOf(cfg: Config): {
|
|
224
|
+
[x: string]: {
|
|
225
|
+
questions: Question[];
|
|
226
|
+
situation_question?: string;
|
|
227
|
+
title: string;
|
|
228
|
+
intro: string;
|
|
229
|
+
labels: {
|
|
230
|
+
[x: string]: string;
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
type Translated = ReturnType<typeof translationsOf>;
|
|
198
235
|
interface KeyEntry {
|
|
199
236
|
readonly source_id: string;
|
|
200
237
|
readonly group: string;
|
|
@@ -212,7 +249,7 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
|
|
|
212
249
|
idOf: (i: Item) => string;
|
|
213
250
|
};
|
|
214
251
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
215
|
-
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
|
|
252
|
+
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described, translated: Translated): {
|
|
216
253
|
questions: readonly Question[];
|
|
217
254
|
practice: {
|
|
218
255
|
round: number;
|
|
@@ -247,6 +284,71 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
|
|
|
247
284
|
}[];
|
|
248
285
|
id: string;
|
|
249
286
|
})[];
|
|
287
|
+
lang?: string;
|
|
288
|
+
answerers?: readonly string[];
|
|
289
|
+
min_seconds?: number;
|
|
290
|
+
title: string;
|
|
291
|
+
intro: string;
|
|
292
|
+
labels: {
|
|
293
|
+
[x: string]: string;
|
|
294
|
+
};
|
|
295
|
+
groups: {
|
|
296
|
+
id: string;
|
|
297
|
+
label: string;
|
|
298
|
+
}[];
|
|
299
|
+
familiarity_question: string;
|
|
300
|
+
familiarity: {
|
|
301
|
+
value: string;
|
|
302
|
+
label: string;
|
|
303
|
+
}[];
|
|
304
|
+
allowed: JsonObject;
|
|
305
|
+
} | {
|
|
306
|
+
questions: readonly Question[];
|
|
307
|
+
practice: {
|
|
308
|
+
round: number;
|
|
309
|
+
items: ({
|
|
310
|
+
rules?: never;
|
|
311
|
+
id: string;
|
|
312
|
+
expected: Readonly<Record<string, string>>;
|
|
313
|
+
why: string;
|
|
314
|
+
} | {
|
|
315
|
+
rules: {
|
|
316
|
+
description?: string;
|
|
317
|
+
family?: string;
|
|
318
|
+
linter: "eslint" | "ruff";
|
|
319
|
+
id: string;
|
|
320
|
+
}[];
|
|
321
|
+
id: string;
|
|
322
|
+
expected: Readonly<Record<string, string>>;
|
|
323
|
+
why: string;
|
|
324
|
+
})[];
|
|
325
|
+
};
|
|
326
|
+
items: ({
|
|
327
|
+
answerers?: readonly string[];
|
|
328
|
+
rules?: never;
|
|
329
|
+
id: string;
|
|
330
|
+
} | {
|
|
331
|
+
answerers?: readonly string[];
|
|
332
|
+
rules: {
|
|
333
|
+
description?: string;
|
|
334
|
+
family?: string;
|
|
335
|
+
linter: "eslint" | "ruff";
|
|
336
|
+
id: string;
|
|
337
|
+
}[];
|
|
338
|
+
id: string;
|
|
339
|
+
})[];
|
|
340
|
+
lang: string;
|
|
341
|
+
translations: {
|
|
342
|
+
[x: string]: {
|
|
343
|
+
questions: Question[];
|
|
344
|
+
situation_question?: string;
|
|
345
|
+
title: string;
|
|
346
|
+
intro: string;
|
|
347
|
+
labels: {
|
|
348
|
+
[x: string]: string;
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
};
|
|
250
352
|
answerers?: readonly string[];
|
|
251
353
|
min_seconds?: number;
|
|
252
354
|
title: string;
|
|
@@ -307,6 +409,71 @@ export declare function prepareBuild(configPath: string, outDir: string): {
|
|
|
307
409
|
}[];
|
|
308
410
|
id: string;
|
|
309
411
|
})[];
|
|
412
|
+
lang?: string;
|
|
413
|
+
answerers?: readonly string[];
|
|
414
|
+
min_seconds?: number;
|
|
415
|
+
title: string;
|
|
416
|
+
intro: string;
|
|
417
|
+
labels: {
|
|
418
|
+
[x: string]: string;
|
|
419
|
+
};
|
|
420
|
+
groups: {
|
|
421
|
+
id: string;
|
|
422
|
+
label: string;
|
|
423
|
+
}[];
|
|
424
|
+
familiarity_question: string;
|
|
425
|
+
familiarity: {
|
|
426
|
+
value: string;
|
|
427
|
+
label: string;
|
|
428
|
+
}[];
|
|
429
|
+
allowed: JsonObject;
|
|
430
|
+
} | {
|
|
431
|
+
questions: readonly Question[];
|
|
432
|
+
practice: {
|
|
433
|
+
round: number;
|
|
434
|
+
items: ({
|
|
435
|
+
rules?: never;
|
|
436
|
+
id: string;
|
|
437
|
+
expected: Readonly<Record<string, string>>;
|
|
438
|
+
why: string;
|
|
439
|
+
} | {
|
|
440
|
+
rules: {
|
|
441
|
+
description?: string;
|
|
442
|
+
family?: string;
|
|
443
|
+
linter: "eslint" | "ruff";
|
|
444
|
+
id: string;
|
|
445
|
+
}[];
|
|
446
|
+
id: string;
|
|
447
|
+
expected: Readonly<Record<string, string>>;
|
|
448
|
+
why: string;
|
|
449
|
+
})[];
|
|
450
|
+
};
|
|
451
|
+
items: ({
|
|
452
|
+
answerers?: readonly string[];
|
|
453
|
+
rules?: never;
|
|
454
|
+
id: string;
|
|
455
|
+
} | {
|
|
456
|
+
answerers?: readonly string[];
|
|
457
|
+
rules: {
|
|
458
|
+
description?: string;
|
|
459
|
+
family?: string;
|
|
460
|
+
linter: "eslint" | "ruff";
|
|
461
|
+
id: string;
|
|
462
|
+
}[];
|
|
463
|
+
id: string;
|
|
464
|
+
})[];
|
|
465
|
+
lang: string;
|
|
466
|
+
translations: {
|
|
467
|
+
[x: string]: {
|
|
468
|
+
questions: Question[];
|
|
469
|
+
situation_question?: string;
|
|
470
|
+
title: string;
|
|
471
|
+
intro: string;
|
|
472
|
+
labels: {
|
|
473
|
+
[x: string]: string;
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
};
|
|
310
477
|
answerers?: readonly string[];
|
|
311
478
|
min_seconds?: number;
|
|
312
479
|
title: string;
|
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:46ebfc043d459ff0 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
|
|
|
@@ -158,6 +158,16 @@ Two optional top-level keys of `items.json`, both left out by default:
|
|
|
158
158
|
Details and the export's shape: [`references/closed-choice-generator.md`](references/closed-choice-generator.md),
|
|
159
159
|
"Two answerers, and a minimum time on screen".
|
|
160
160
|
|
|
161
|
+
## A second language
|
|
162
|
+
|
|
163
|
+
A labeller who reads another language better gets the page's own words in it: `"translations": {"ru":
|
|
164
|
+
{…}}` (and `"lang"`, the page's own, `en` by default) adds a switch under the title. The questions,
|
|
165
|
+
answers, examples, reason prompts, intro and labels change; the quote, the code and every note never
|
|
166
|
+
do, because those words are what is judged. The build refuses a translation that leaves out or
|
|
167
|
+
invents a question or an answer; the ids and `key.json` are the same with or without it.
|
|
168
|
+
[`references/closed-choice-generator.md`](references/closed-choice-generator.md), "A second
|
|
169
|
+
language for the page's own words".
|
|
170
|
+
|
|
161
171
|
## When the page misbehaves
|
|
162
172
|
|
|
163
173
|
Look at what the store holds (`ArtifactData` `list`) before changing anything: the page may be fine
|
|
@@ -169,6 +169,16 @@ Two optional top-level keys of \`items.json\`, both left out by default:
|
|
|
169
169
|
Details and the export's shape: [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md),
|
|
170
170
|
"Two answerers, and a minimum time on screen".
|
|
171
171
|
|
|
172
|
+
## A second language
|
|
173
|
+
|
|
174
|
+
A labeller who reads another language better gets the page's own words in it: \`"translations": {"ru":
|
|
175
|
+
{…}}\` (and \`"lang"\`, the page's own, \`en\` by default) adds a switch under the title. The questions,
|
|
176
|
+
answers, examples, reason prompts, intro and labels change; the quote, the code and every note never
|
|
177
|
+
do, because those words are what is judged. The build refuses a translation that leaves out or
|
|
178
|
+
invents a question or an answer; the ids and \`key.json\` are the same with or without it.
|
|
179
|
+
[\`references/closed-choice-generator.md\`](references/closed-choice-generator.md), "A second
|
|
180
|
+
language for the page's own words".
|
|
181
|
+
|
|
172
182
|
## When the page misbehaves
|
|
173
183
|
|
|
174
184
|
Look at what the store holds (\`ArtifactData\` \`list\`) before changing anything: the page may be fine
|
|
@@ -229,6 +229,8 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
|
|
|
229
229
|
| `hidden` | a `⋮` row for lines left out | `lines {from}–{to}` |
|
|
230
230
|
| `hidden_one` | a `⋮` row for one line left out | `line {from}` |
|
|
231
231
|
| `wait` | the bar under the answers while `min_seconds` runs | `⏳ You can answer in {s} s` |
|
|
232
|
+
| `how_to` | the heading of the first screen's questions and examples | `📖 How to answer` |
|
|
233
|
+
| `for_example` | text before each example on the first screen | `For example: ` |
|
|
232
234
|
|
|
233
235
|
- The leak guard reads every string these blocks render, one string at a time.
|
|
234
236
|
|
|
@@ -286,6 +288,60 @@ author cannot read it is an exclusion made after the draw and goes unrecorded. T
|
|
|
286
288
|
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
|
|
287
289
|
`author_check` of the plan; a dropped unit is replaced by the next one in the draw.
|
|
288
290
|
|
|
291
|
+
## A second language for the page's own words
|
|
292
|
+
|
|
293
|
+
For a labeller who reads another language better (#394). Both keys are optional and top-level; left
|
|
294
|
+
out, the page is exactly as above.
|
|
295
|
+
|
|
296
|
+
```jsonc
|
|
297
|
+
{
|
|
298
|
+
"lang": "en", // the language of the page's own words; "en" when left out
|
|
299
|
+
"translations": {
|
|
300
|
+
"ru": {
|
|
301
|
+
"title": "…", // optional: the page's title when left out
|
|
302
|
+
"intro": "…", // given exactly when the page has an `intro`
|
|
303
|
+
"labels": {
|
|
304
|
+
"place": "Карточка {n} из {total}",
|
|
305
|
+
"how_to": "📖 Как отвечать",
|
|
306
|
+
"for_example": "Например: ",
|
|
307
|
+
},
|
|
308
|
+
"situation_question": "Эти слова запрещают строку {n}?", // given exactly when a card has a `situation`
|
|
309
|
+
"questions": {
|
|
310
|
+
"forbid": {
|
|
311
|
+
"text": "…",
|
|
312
|
+
"short": "…", // optional: the translated `text` stands in for it
|
|
313
|
+
"options": {
|
|
314
|
+
"forbid": { "label": "Запрещают", "example": "…" },
|
|
315
|
+
"cannot": { "label": "…", "example": "…", "ask_why": "…" },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- **What changes with the language:** the title, the intro, the `labels`, every question's text and
|
|
325
|
+
short form, every answer's label and example, every reason prompt (`ask_why`), and the first question
|
|
326
|
+
as the cards with a `situation` ask it. The first screen's rows ("How to answer") change too.
|
|
327
|
+
- **What never does:** the quote and its source, the code, the annotation, the term notes, the
|
|
328
|
+
answer sub-lines (`answer_notes`), a situation's `summary`, and every other card field. Those words
|
|
329
|
+
are what the labeller judges; a translated quote would be a different item.
|
|
330
|
+
- **`situation_question`** replaces each card's own `situation.question` in that language. `{n}` is
|
|
331
|
+
the number of the card's first marked line; `{line}` works as in `situation`. Either one on a card
|
|
332
|
+
without an `excerpt` is refused by path.
|
|
333
|
+
- **Refused, by path:** a translation into the page's own `lang`; a question or an answer it leaves
|
|
334
|
+
out, or one the page does not have; two answers whose labels read the same; an `intro`, an
|
|
335
|
+
`ask_why` or a `situation_question` given where the page has none, or missing where it has one.
|
|
336
|
+
- **Left out on purpose:** a label the translation does not give reads as in the page's language.
|
|
337
|
+
The page's fixed words ("Practice", "Change", "Saved", "Next unanswered", "Who is answering?")
|
|
338
|
+
and the word «line» that `{line}` writes stay in English.
|
|
339
|
+
- **The switch** sits under the title (`data-test="lang-switch"`), one button per language with its
|
|
340
|
+
tag ("EN", "RU"). The choice is kept in the browser's localStorage under `label-lang` when it has
|
|
341
|
+
one.
|
|
342
|
+
- **Ids and key:** the neutral ids, the store's documents, `key.json` and the export are the same,
|
|
343
|
+
byte for byte, with or without translations.
|
|
344
|
+
|
|
289
345
|
## Two answerers, and a minimum time on screen
|
|
290
346
|
|
|
291
347
|
Both keys are optional and top-level. Left out, the page, its store documents and the export are
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Запрещают ли слова",
|
|
3
|
+
"labels": {
|
|
4
|
+
"place": "Карточка {n} из {total}",
|
|
5
|
+
"wait": "Сохранить · можно через {s} с на этой карточке",
|
|
6
|
+
"how_to": "📖 Как отвечать",
|
|
7
|
+
"for_example": "Например: "
|
|
8
|
+
},
|
|
9
|
+
"situation_question": "Эти слова запрещают строку {n}?",
|
|
10
|
+
"questions": {
|
|
11
|
+
"forbid": {
|
|
12
|
+
"text": "Эти слова запрещают отмеченную строку?",
|
|
13
|
+
"short": "запрещают ли слова?",
|
|
14
|
+
"options": {
|
|
15
|
+
"forbid": { "label": "Запрещают", "example": "«No print calls» и строка print(x)" },
|
|
16
|
+
"allow": { "label": "Разрешают", "example": "«No print calls» и строка log.info(x)" },
|
|
17
|
+
"unsettled": { "label": "Из слов это не следует", "example": "«Keep functions short» и функция на 30 строк" },
|
|
18
|
+
"elsewhere": { "label": "Слова про другое", "example": "«Use tabs» и строка с запросом к базе" },
|
|
19
|
+
"cannot": { "label": "Не берусь оценить (объясните)", "example": "строка на языке, который вы не читаете", "ask_why": "Что мешает?" }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1418,3 +1418,220 @@ test(
|
|
|
1418
1418
|
},
|
|
1419
1419
|
BROWSER_TEST_MS,
|
|
1420
1420
|
);
|
|
1421
|
+
|
|
1422
|
+
// ── a second language for the page's own words (#394) ──────────────────────────────────────────
|
|
1423
|
+
const RU_FIXTURE = join(import.meta.dirname, "fixtures", "v7-cards-ru.json");
|
|
1424
|
+
const Words = z.object({
|
|
1425
|
+
labels: z.object({ for_example: z.string() }).loose(),
|
|
1426
|
+
questions: z.array(
|
|
1427
|
+
z.object({
|
|
1428
|
+
id: z.string(),
|
|
1429
|
+
text: z.string(),
|
|
1430
|
+
options: z.array(
|
|
1431
|
+
z.object({
|
|
1432
|
+
value: z.string(),
|
|
1433
|
+
label: z.string(),
|
|
1434
|
+
example: z.string(),
|
|
1435
|
+
icon: z.string(),
|
|
1436
|
+
}),
|
|
1437
|
+
),
|
|
1438
|
+
}),
|
|
1439
|
+
),
|
|
1440
|
+
});
|
|
1441
|
+
const RU_RAW = z.json().parse(JSON.parse(readFileSync(RU_FIXTURE, "utf8")));
|
|
1442
|
+
const RU_WORDS = z
|
|
1443
|
+
.object({
|
|
1444
|
+
situation_question: z.string(),
|
|
1445
|
+
labels: z.object({ for_example: z.string() }).loose(),
|
|
1446
|
+
questions: z.record(
|
|
1447
|
+
z.string(),
|
|
1448
|
+
z.object({
|
|
1449
|
+
text: z.string(),
|
|
1450
|
+
options: z.record(
|
|
1451
|
+
z.string(),
|
|
1452
|
+
z.object({ label: z.string(), example: z.string() }),
|
|
1453
|
+
),
|
|
1454
|
+
}),
|
|
1455
|
+
),
|
|
1456
|
+
})
|
|
1457
|
+
.parse(RU_RAW);
|
|
1458
|
+
/** The fixture's page words in one language: the base's, or the Russian translation laid over them. */
|
|
1459
|
+
function wordsIn(lang: "en" | "ru") {
|
|
1460
|
+
const base = Words.parse({
|
|
1461
|
+
labels: { for_example: "For example: " },
|
|
1462
|
+
questions: z.object({ questions: z.unknown() }).loose().parse(V7_CONFIG)
|
|
1463
|
+
.questions,
|
|
1464
|
+
});
|
|
1465
|
+
if (lang === "en") return base;
|
|
1466
|
+
return {
|
|
1467
|
+
labels: RU_WORDS.labels,
|
|
1468
|
+
questions: base.questions.map((q) => ({
|
|
1469
|
+
...q,
|
|
1470
|
+
text: RU_WORDS.questions[q.id]?.text ?? "",
|
|
1471
|
+
options: q.options.map((o) => ({
|
|
1472
|
+
...o,
|
|
1473
|
+
...RU_WORDS.questions[q.id]?.options[o.value],
|
|
1474
|
+
})),
|
|
1475
|
+
})),
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
/** What the docstring card and the how-to rows say in `lang`, as the fixture states it. */
|
|
1479
|
+
function expectedIn(lang: "en" | "ru") {
|
|
1480
|
+
const card = fixtureCard("card-docstring");
|
|
1481
|
+
const { situation, quote } = z
|
|
1482
|
+
.object({
|
|
1483
|
+
situation: z.object({ question: z.string() }),
|
|
1484
|
+
quote: z.object({ text: z.string() }),
|
|
1485
|
+
})
|
|
1486
|
+
.loose()
|
|
1487
|
+
.parse(card);
|
|
1488
|
+
const w = wordsIn(lang);
|
|
1489
|
+
const [q] = w.questions;
|
|
1490
|
+
assert.ok(q !== undefined);
|
|
1491
|
+
const notes = card.answer_notes.forbid;
|
|
1492
|
+
return {
|
|
1493
|
+
switch: { lang, langs: ["en", "ru"] },
|
|
1494
|
+
quote: unticked(quote.text),
|
|
1495
|
+
question:
|
|
1496
|
+
lang === "en"
|
|
1497
|
+
? situation.question
|
|
1498
|
+
: RU_WORDS.situation_question.replace(
|
|
1499
|
+
"{n}",
|
|
1500
|
+
String(card.excerpt.lines.find((l) => l.mark)?.n),
|
|
1501
|
+
),
|
|
1502
|
+
answers: q.options.map((o) => [
|
|
1503
|
+
{ question: q.id, value: o.value },
|
|
1504
|
+
`${o.icon}${o.label}${unticked(notes[o.value] ?? "")}`,
|
|
1505
|
+
]),
|
|
1506
|
+
howTo: [
|
|
1507
|
+
q.text,
|
|
1508
|
+
...q.options.map(
|
|
1509
|
+
(o) => `${o.icon} ${o.label}${w.labels.for_example}${o.example}`,
|
|
1510
|
+
),
|
|
1511
|
+
],
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
/** The same, read from the page. */
|
|
1515
|
+
async function shownIn(page: Page, card: Locator) {
|
|
1516
|
+
const texts = async (scope: Page | Locator, kind: string) =>
|
|
1517
|
+
scope.getByTestId(kind).allTextContents();
|
|
1518
|
+
return {
|
|
1519
|
+
switch: await metaOf(page.getByTestId("lang-switch")),
|
|
1520
|
+
quote: (await texts(card, "quote")).join(""),
|
|
1521
|
+
question: (await texts(card, "question")).join(""),
|
|
1522
|
+
answers: await Promise.all(
|
|
1523
|
+
(await allByKind(card, "answer")).map(async (a) => [
|
|
1524
|
+
a.meta,
|
|
1525
|
+
await a.loc.textContent(),
|
|
1526
|
+
]),
|
|
1527
|
+
),
|
|
1528
|
+
howTo: [
|
|
1529
|
+
...(await texts(page, "how-to-question")),
|
|
1530
|
+
...(await texts(page, "how-to-answer")),
|
|
1531
|
+
],
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
test(
|
|
1535
|
+
"🔴 the language switch swaps the page's own words both ways, keeps the quote and the code as built, and is remembered over a reload",
|
|
1536
|
+
async () => {
|
|
1537
|
+
const cfg = {
|
|
1538
|
+
...R.omit(V7_CONFIG, ["min_seconds"]),
|
|
1539
|
+
translations: { ru: RU_RAW },
|
|
1540
|
+
};
|
|
1541
|
+
const { page: html, key } = build(setup("browser-v7-lang", cfg));
|
|
1542
|
+
const at = "http://label.test/";
|
|
1543
|
+
const page = await (
|
|
1544
|
+
await sharedBrowser()
|
|
1545
|
+
).newPage({ viewport: { width: 400, height: 800 } });
|
|
1546
|
+
try {
|
|
1547
|
+
// A real origin, so the page has a localStorage; every other request is refused, none leaves.
|
|
1548
|
+
await page.route("**/*", (r) =>
|
|
1549
|
+
r.request().url() === at
|
|
1550
|
+
? r.fulfill({ contentType: "text/html; charset=utf-8", body: html })
|
|
1551
|
+
: r.abort(),
|
|
1552
|
+
);
|
|
1553
|
+
await page.addInitScript(
|
|
1554
|
+
STORE(
|
|
1555
|
+
Object.fromEntries(
|
|
1556
|
+
["forbid", "allow", "elsewhere"].map((v, n) => [
|
|
1557
|
+
`r1-P${String(n + 1)}`,
|
|
1558
|
+
{ answers: { forbid: v }, source: "page" },
|
|
1559
|
+
]),
|
|
1560
|
+
),
|
|
1561
|
+
20,
|
|
1562
|
+
),
|
|
1563
|
+
);
|
|
1564
|
+
await page.goto(at, { waitUntil: "domcontentloaded" });
|
|
1565
|
+
const id = Object.entries(key.ids).find(
|
|
1566
|
+
([, v]) => v.source_id === "card-docstring",
|
|
1567
|
+
)?.[0];
|
|
1568
|
+
const card = async () => {
|
|
1569
|
+
await page.getByTestId("item").first().waitFor();
|
|
1570
|
+
const found = (await allByKind(page, "item")).find(
|
|
1571
|
+
(c) => Id.parse(c.meta).id === id,
|
|
1572
|
+
);
|
|
1573
|
+
assert.ok(found !== undefined, "the card is drawn");
|
|
1574
|
+
return found.loc;
|
|
1575
|
+
};
|
|
1576
|
+
const option = async (lang: string) =>
|
|
1577
|
+
(await allByKind(page, "lang-option")).find(
|
|
1578
|
+
(o) => z.object({ lang: z.string() }).parse(o.meta).lang === lang,
|
|
1579
|
+
)?.loc;
|
|
1580
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("en"));
|
|
1581
|
+
await (await option("ru"))?.click();
|
|
1582
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("ru"));
|
|
1583
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
1584
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("ru"));
|
|
1585
|
+
await (await option("en"))?.click();
|
|
1586
|
+
assert.deepEqual(await shownIn(page, await card()), expectedIn("en"));
|
|
1587
|
+
} finally {
|
|
1588
|
+
await page.context().close();
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
BROWSER_TEST_MS,
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
test(
|
|
1595
|
+
"🔴 a page without translations names its own language on the document: the one it declares, else English",
|
|
1596
|
+
async () => {
|
|
1597
|
+
const htmlLang = async (cfg: Record<string, unknown>, dir: string) => {
|
|
1598
|
+
const { page: html } = build(setup(dir, cfg));
|
|
1599
|
+
const at = "http://label.test/";
|
|
1600
|
+
const page = await (
|
|
1601
|
+
await sharedBrowser()
|
|
1602
|
+
).newPage({ viewport: { width: 400, height: 800 } });
|
|
1603
|
+
try {
|
|
1604
|
+
await page.route("**/*", (r) =>
|
|
1605
|
+
r.request().url() === at
|
|
1606
|
+
? r.fulfill({ contentType: "text/html; charset=utf-8", body: html })
|
|
1607
|
+
: r.abort(),
|
|
1608
|
+
);
|
|
1609
|
+
await page.addInitScript(
|
|
1610
|
+
STORE(
|
|
1611
|
+
Object.fromEntries(
|
|
1612
|
+
["forbid", "allow", "elsewhere"].map((v, n) => [
|
|
1613
|
+
`r1-P${String(n + 1)}`,
|
|
1614
|
+
{ answers: { forbid: v }, source: "page" },
|
|
1615
|
+
]),
|
|
1616
|
+
),
|
|
1617
|
+
20,
|
|
1618
|
+
),
|
|
1619
|
+
);
|
|
1620
|
+
await page.goto(at, { waitUntil: "domcontentloaded" });
|
|
1621
|
+
await page.getByTestId("item").first().waitFor();
|
|
1622
|
+
return await page.evaluate(() => document.documentElement.lang);
|
|
1623
|
+
} finally {
|
|
1624
|
+
await page.context().close();
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
const plain = R.omit(V7_CONFIG, ["min_seconds"]);
|
|
1628
|
+
assert.deepEqual(
|
|
1629
|
+
[
|
|
1630
|
+
await htmlLang(plain, "browser-v7-lang-none"),
|
|
1631
|
+
await htmlLang({ ...plain, lang: "de" }, "browser-v7-lang-de"),
|
|
1632
|
+
],
|
|
1633
|
+
["en", "de"],
|
|
1634
|
+
);
|
|
1635
|
+
},
|
|
1636
|
+
BROWSER_TEST_MS,
|
|
1637
|
+
);
|