paperlint 14.4.0 → 14.6.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.
@@ -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
+ }
@@ -1000,9 +1000,10 @@ async function onV7Page(
1000
1000
  cardOf: (sourceId: string) => Promise<Locator>,
1001
1001
  ) => Promise<void>,
1002
1002
  withTime = false,
1003
+ cfg: Record<string, unknown> = V7_CONFIG,
1003
1004
  ): Promise<void> {
1004
1005
  const { page: html, key } = build(
1005
- setup(name, withTime ? V7_CONFIG : R.omit(V7_CONFIG, ["min_seconds"])),
1006
+ setup(name, withTime ? cfg : R.omit(cfg, ["min_seconds"])),
1006
1007
  );
1007
1008
  const done = ["forbid", "allow", "elsewhere"];
1008
1009
  const page = await (
@@ -1418,3 +1419,345 @@ test(
1418
1419
  },
1419
1420
  BROWSER_TEST_MS,
1420
1421
  );
1422
+
1423
+ // ── a second language for the page's own words (#394) ──────────────────────────────────────────
1424
+ const RU_FIXTURE = join(import.meta.dirname, "fixtures", "v7-cards-ru.json");
1425
+ const Words = z.object({
1426
+ labels: z.object({ for_example: z.string() }).loose(),
1427
+ questions: z.array(
1428
+ z.object({
1429
+ id: z.string(),
1430
+ text: z.string(),
1431
+ options: z.array(
1432
+ z.object({
1433
+ value: z.string(),
1434
+ label: z.string(),
1435
+ example: z.string(),
1436
+ icon: z.string(),
1437
+ }),
1438
+ ),
1439
+ }),
1440
+ ),
1441
+ });
1442
+ const RU_RAW = z.json().parse(JSON.parse(readFileSync(RU_FIXTURE, "utf8")));
1443
+ const RU_WORDS = z
1444
+ .object({
1445
+ situation_question: z.string(),
1446
+ labels: z.object({ for_example: z.string() }).loose(),
1447
+ questions: z.record(
1448
+ z.string(),
1449
+ z.object({
1450
+ text: z.string(),
1451
+ options: z.record(
1452
+ z.string(),
1453
+ z.object({ label: z.string(), example: z.string() }),
1454
+ ),
1455
+ }),
1456
+ ),
1457
+ })
1458
+ .parse(RU_RAW);
1459
+ /** The fixture's page words in one language: the base's, or the Russian translation laid over them. */
1460
+ function wordsIn(lang: "en" | "ru") {
1461
+ const base = Words.parse({
1462
+ labels: { for_example: "For example: " },
1463
+ questions: z.object({ questions: z.unknown() }).loose().parse(V7_CONFIG)
1464
+ .questions,
1465
+ });
1466
+ if (lang === "en") return base;
1467
+ return {
1468
+ labels: RU_WORDS.labels,
1469
+ questions: base.questions.map((q) => ({
1470
+ ...q,
1471
+ text: RU_WORDS.questions[q.id]?.text ?? "",
1472
+ options: q.options.map((o) => ({
1473
+ ...o,
1474
+ ...RU_WORDS.questions[q.id]?.options[o.value],
1475
+ })),
1476
+ })),
1477
+ };
1478
+ }
1479
+ /** What the docstring card and the how-to rows say in `lang`, as the fixture states it. */
1480
+ function expectedIn(lang: "en" | "ru") {
1481
+ const card = fixtureCard("card-docstring");
1482
+ const { situation, quote } = z
1483
+ .object({
1484
+ situation: z.object({ question: z.string() }),
1485
+ quote: z.object({ text: z.string() }),
1486
+ })
1487
+ .loose()
1488
+ .parse(card);
1489
+ const w = wordsIn(lang);
1490
+ const [q] = w.questions;
1491
+ assert.ok(q !== undefined);
1492
+ const notes = card.answer_notes.forbid;
1493
+ return {
1494
+ switch: { lang, langs: ["en", "ru"] },
1495
+ quote: unticked(quote.text),
1496
+ question:
1497
+ lang === "en"
1498
+ ? situation.question
1499
+ : RU_WORDS.situation_question.replace(
1500
+ "{n}",
1501
+ String(card.excerpt.lines.find((l) => l.mark)?.n),
1502
+ ),
1503
+ answers: q.options.map((o) => [
1504
+ { question: q.id, value: o.value },
1505
+ `${o.icon}${o.label}${unticked(notes[o.value] ?? "")}`,
1506
+ ]),
1507
+ howTo: [
1508
+ q.text,
1509
+ ...q.options.map(
1510
+ (o) => `${o.icon} ${o.label}${w.labels.for_example}${o.example}`,
1511
+ ),
1512
+ ],
1513
+ };
1514
+ }
1515
+ /** The same, read from the page. */
1516
+ async function shownIn(page: Page, card: Locator) {
1517
+ const texts = async (scope: Page | Locator, kind: string) =>
1518
+ scope.getByTestId(kind).allTextContents();
1519
+ return {
1520
+ switch: await metaOf(page.getByTestId("lang-switch")),
1521
+ quote: (await texts(card, "quote")).join(""),
1522
+ question: (await texts(card, "question")).join(""),
1523
+ answers: await Promise.all(
1524
+ (await allByKind(card, "answer")).map(async (a) => [
1525
+ a.meta,
1526
+ await a.loc.textContent(),
1527
+ ]),
1528
+ ),
1529
+ howTo: [
1530
+ ...(await texts(page, "how-to-question")),
1531
+ ...(await texts(page, "how-to-answer")),
1532
+ ],
1533
+ };
1534
+ }
1535
+ test(
1536
+ "🔴 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",
1537
+ async () => {
1538
+ const cfg = {
1539
+ ...R.omit(V7_CONFIG, ["min_seconds"]),
1540
+ translations: { ru: RU_RAW },
1541
+ };
1542
+ const { page: html, key } = build(setup("browser-v7-lang", cfg));
1543
+ const at = "http://label.test/";
1544
+ const page = await (
1545
+ await sharedBrowser()
1546
+ ).newPage({ viewport: { width: 400, height: 800 } });
1547
+ try {
1548
+ // A real origin, so the page has a localStorage; every other request is refused, none leaves.
1549
+ await page.route("**/*", (r) =>
1550
+ r.request().url() === at
1551
+ ? r.fulfill({ contentType: "text/html; charset=utf-8", body: html })
1552
+ : r.abort(),
1553
+ );
1554
+ await page.addInitScript(
1555
+ STORE(
1556
+ Object.fromEntries(
1557
+ ["forbid", "allow", "elsewhere"].map((v, n) => [
1558
+ `r1-P${String(n + 1)}`,
1559
+ { answers: { forbid: v }, source: "page" },
1560
+ ]),
1561
+ ),
1562
+ 20,
1563
+ ),
1564
+ );
1565
+ await page.goto(at, { waitUntil: "domcontentloaded" });
1566
+ const id = Object.entries(key.ids).find(
1567
+ ([, v]) => v.source_id === "card-docstring",
1568
+ )?.[0];
1569
+ const card = async () => {
1570
+ await page.getByTestId("item").first().waitFor();
1571
+ const found = (await allByKind(page, "item")).find(
1572
+ (c) => Id.parse(c.meta).id === id,
1573
+ );
1574
+ assert.ok(found !== undefined, "the card is drawn");
1575
+ return found.loc;
1576
+ };
1577
+ const option = async (lang: string) =>
1578
+ (await allByKind(page, "lang-option")).find(
1579
+ (o) => z.object({ lang: z.string() }).parse(o.meta).lang === lang,
1580
+ )?.loc;
1581
+ assert.deepEqual(await shownIn(page, await card()), expectedIn("en"));
1582
+ await (await option("ru"))?.click();
1583
+ assert.deepEqual(await shownIn(page, await card()), expectedIn("ru"));
1584
+ await page.reload({ waitUntil: "domcontentloaded" });
1585
+ assert.deepEqual(await shownIn(page, await card()), expectedIn("ru"));
1586
+ await (await option("en"))?.click();
1587
+ assert.deepEqual(await shownIn(page, await card()), expectedIn("en"));
1588
+ } finally {
1589
+ await page.context().close();
1590
+ }
1591
+ },
1592
+ BROWSER_TEST_MS,
1593
+ );
1594
+
1595
+ test(
1596
+ "🔴 a page without translations names its own language on the document: the one it declares, else English",
1597
+ async () => {
1598
+ const htmlLang = async (cfg: Record<string, unknown>, dir: string) => {
1599
+ const { page: html } = build(setup(dir, cfg));
1600
+ const at = "http://label.test/";
1601
+ const page = await (
1602
+ await sharedBrowser()
1603
+ ).newPage({ viewport: { width: 400, height: 800 } });
1604
+ try {
1605
+ await page.route("**/*", (r) =>
1606
+ r.request().url() === at
1607
+ ? r.fulfill({ contentType: "text/html; charset=utf-8", body: html })
1608
+ : r.abort(),
1609
+ );
1610
+ await page.addInitScript(
1611
+ STORE(
1612
+ Object.fromEntries(
1613
+ ["forbid", "allow", "elsewhere"].map((v, n) => [
1614
+ `r1-P${String(n + 1)}`,
1615
+ { answers: { forbid: v }, source: "page" },
1616
+ ]),
1617
+ ),
1618
+ 20,
1619
+ ),
1620
+ );
1621
+ await page.goto(at, { waitUntil: "domcontentloaded" });
1622
+ await page.getByTestId("item").first().waitFor();
1623
+ return await page.evaluate(() => document.documentElement.lang);
1624
+ } finally {
1625
+ await page.context().close();
1626
+ }
1627
+ };
1628
+ const plain = R.omit(V7_CONFIG, ["min_seconds"]);
1629
+ assert.deepEqual(
1630
+ [
1631
+ await htmlLang(plain, "browser-v7-lang-none"),
1632
+ await htmlLang({ ...plain, lang: "de" }, "browser-v7-lang-de"),
1633
+ ],
1634
+ ["en", "de"],
1635
+ );
1636
+ },
1637
+ BROWSER_TEST_MS,
1638
+ );
1639
+
1640
+ const TAG_LABELS = {
1641
+ repo: "From the repository",
1642
+ ours: "Our restatement",
1643
+ model: "Written by the model under test",
1644
+ count: "Counted by our script",
1645
+ };
1646
+ const TAG_LABELS_RU = {
1647
+ repo: "Из репозитория",
1648
+ ours: "Пересказ нашей модели",
1649
+ model: "Написано проверяемой моделью",
1650
+ count: "Посчитано нашим скриптом",
1651
+ };
1652
+ const Raw = z.record(z.string(), z.unknown());
1653
+ /** A card's block as the fixture has it, with more fields. */
1654
+ const withIn = (
1655
+ card: Record<string, unknown>,
1656
+ block: string,
1657
+ more: object,
1658
+ ) => ({
1659
+ ...card,
1660
+ [block]: { ...Raw.parse(card[block]), ...more },
1661
+ });
1662
+ /** The v7 fixture with tags: the docstring card has all four kinds, the call card a quote tag only, the depth card none. */
1663
+ const TAGGED_CONFIG = {
1664
+ ...V7_CONFIG,
1665
+ sources: TAG_LABELS,
1666
+ translations: { ru: { ...Raw.parse(RU_RAW), sources: TAG_LABELS_RU } },
1667
+ items: V7_CONFIG.items.map((raw, n) => {
1668
+ const card = Raw.parse(raw);
1669
+ return n === 0
1670
+ ? withIn(
1671
+ withIn(withIn(card, "quote", { from: "repo" }), "situation", {
1672
+ from: "ours",
1673
+ summary: "A reviewer objected to this line.",
1674
+ }),
1675
+ "excerpt",
1676
+ {
1677
+ from: "model",
1678
+ caption: "Counted over the file.",
1679
+ caption_from: "count",
1680
+ },
1681
+ )
1682
+ : n === 1
1683
+ ? withIn(card, "quote", { from: "ours" })
1684
+ : card;
1685
+ }),
1686
+ };
1687
+ const SourceTag = z.object({ source: z.string(), block: z.string() });
1688
+ /** Each source tag of a card: its values and its text. */
1689
+ const tagsOn = async (card: Locator) =>
1690
+ Promise.all(
1691
+ (await allByKind(card, "source-tag")).map(async (t) => ({
1692
+ ...SourceTag.parse(t.meta),
1693
+ text: await t.loc.textContent(),
1694
+ })),
1695
+ );
1696
+ /** How far a tag's bottom edge is above the top of the block it tags (negative: not above). */
1697
+ const gapAbove = async (tag: Locator, block: Locator) => {
1698
+ const [t, b] = [await tag.boundingBox(), await block.boundingBox()];
1699
+ assert.ok(t !== null && b !== null);
1700
+ return b.y - (t.y + t.height);
1701
+ };
1702
+
1703
+ test(
1704
+ "🔴 each tagged block gets a tag with its source id right above it, the label follows the language, and an untagged card has none",
1705
+ async () => {
1706
+ await onV7Page(
1707
+ "browser-v7-source-tags",
1708
+ async (page, cardOf) => {
1709
+ const docstring = await cardOf("card-docstring");
1710
+ assert.deepEqual(await tagsOn(docstring), [
1711
+ { source: "repo", block: "quote", text: TAG_LABELS.repo },
1712
+ { source: "ours", block: "situation", text: TAG_LABELS.ours },
1713
+ { source: "model", block: "excerpt", text: TAG_LABELS.model },
1714
+ { source: "count", block: "caption", text: TAG_LABELS.count },
1715
+ ]);
1716
+ assert.deepEqual(await tagsOn(await cardOf("card-call")), [
1717
+ { source: "ours", block: "quote", text: TAG_LABELS.ours },
1718
+ ]);
1719
+ // Guards: a card whose blocks name no source has no tag at all.
1720
+ assert.deepEqual(await tagsOn(await cardOf("card-depth")), []);
1721
+ const tags = docstring.getByTestId("source-tag");
1722
+ const gaps = [
1723
+ await gapAbove(tags.nth(0), docstring.getByTestId("quote")),
1724
+ await gapAbove(tags.nth(2), docstring.getByTestId("excerpt-path")),
1725
+ ];
1726
+ // Guards: the tag sits above its block, close to it, never beside or below.
1727
+ assert.ok(
1728
+ gaps.every((g) => g >= 0 && g < 16),
1729
+ JSON.stringify(gaps),
1730
+ );
1731
+ const switchTo = async (lang: string) =>
1732
+ (await allByKind(page, "lang-option"))
1733
+ .find(
1734
+ (o) => z.object({ lang: z.string() }).parse(o.meta).lang === lang,
1735
+ )
1736
+ ?.loc.click();
1737
+ await switchTo("ru");
1738
+ assert.deepEqual(
1739
+ (await tagsOn(await cardOf("card-docstring"))).map((t) => t.text),
1740
+ [
1741
+ TAG_LABELS_RU.repo,
1742
+ TAG_LABELS_RU.ours,
1743
+ TAG_LABELS_RU.model,
1744
+ TAG_LABELS_RU.count,
1745
+ ],
1746
+ );
1747
+ },
1748
+ false,
1749
+ TAGGED_CONFIG,
1750
+ );
1751
+ },
1752
+ BROWSER_TEST_MS,
1753
+ );
1754
+
1755
+ test(
1756
+ "a page without sources draws no source tag",
1757
+ async () => {
1758
+ await onV7Page("browser-v7-no-source-tags", async (page) => {
1759
+ assert.equal(await page.getByTestId("source-tag").count(), 0);
1760
+ });
1761
+ },
1762
+ BROWSER_TEST_MS,
1763
+ );