Ctrl K
ComunidadGuías…

tests/numerals.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// One numeral policy, and the labels that sit beside the numbers.
//
// The regression this file exists for: a blog card that read
// "٩ يناير ٢٠٢٦ · 3 دقائق قراءة" — the date in Eastern Arabic digits, the
// count beside it in Western ones, on the same line. shared/numerals.ts is now
// the single source for BOTH, so the tests below always check a date and a
// count together.
//
// The second rule is subtler and has bitten once: the separator between those
// two fields must never be confusable with a DIGIT. A middot beside Arabic
// numerals reads as ٠ (Arabic-Indic zero), which turns "٣ · ٥" into "٣٠٥".

// Both dictionaries, installed: under Node there is no chunk to fetch
// (client/i18n.ts loads one language at a time in a page).
import "../client/i18n/both.ts";
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { topicUrl } from "../client/blog/nav.ts";
import {
  autoDir,
  countPhrase,
  getNumerals,
  isolate,
  localeNum,
  setLang,
  setNumeralLocale,
  t,
  tf,
} from "../client/i18n.ts";
import { footerLine, initSite } from "../server/site.ts";
import { stripBidiControls } from "../shared/bidi.ts";
import {
  arabicDefaultDigits,
  localeDigits,
  numeralSystem,
  toNumerals,
  type NumeralSystem,
} from "../shared/numerals.ts";
import { makeDir, removeVault } from "./helpers/vault.ts";

const ARABIC_DIGITS = "٠١٢٣٤٥٦٧٨٩";
const MIDDOT = "·"; //   · — the meta-line separator
const ARABIC_THOUSANDS = "٬"; // ٬ — the grouping mark that belongs with ٠١٢
const ARABIC_ZERO = "٠"; // ٠

const data = makeDir();

after(() => removeVault(data));

// ---------------------------------------------------------------- the policy

describe("which digits a locale gets", () => {
  // OWNER DECREE (2026-08-24): Western digits everywhere, Hijri included.
  // Arabic locales are PINNED to latn (Intl would default them to arab);
  // only a locale that spells out `-u-nu-arab` keeps Eastern digits.
  const CASES: [string, boolean, NumeralSystem][] = [
    ["ar", true, "latn"],
    ["AR", true, "latn"],
    ["ar-EG", true, "latn"],
    ["ar-SA", true, "latn"],
    ["arabic", false, "latn"], // not a language subtag match
    ["ar-EG-u-nu-latn", false, "latn"], // the admin named the system
    ["ar-u-nu-arab", false, "arab"], // the one road back to Eastern digits
    ["en", false, "latn"],
    ["en-US", false, "latn"],
    ["fa", false, "latn"],
    ["", false, "latn"],
  ];

  for (const [locale, arabicDefault, system] of CASES) {
    it(`${locale || "(empty)"} → ${system}`, () => {
      assert.equal(arabicDefaultDigits(locale), arabicDefault);
      assert.equal(numeralSystem(locale), system);
      assert.deepEqual(localeDigits(locale), arabicDefault ? { numberingSystem: "latn" } : {});
    });
  }
});

describe("toNumerals", () => {
  it("is the identity for latn", () => {
    assert.equal(toNumerals("1,234 · 5", "latn"), "1,234 · 5");
  });

  it("maps every ASCII digit and nothing else", () => {
    assert.equal(toNumerals("0123456789", "arab"), ARABIC_DIGITS);
    // Property: only "0" may ever become "٠". Anything else that turns into a
    // digit is the confusion bug this module was written to end.
    for (let code = 32; code < 127; code++) {
      const ch = String.fromCharCode(code);
      const out = toNumerals(ch, "arab");
      if (/[0-9]/.test(ch)) assert.equal(out, ARABIC_DIGITS[Number(ch)]);
      else assert.equal(out, ch, `${JSON.stringify(ch)} was rewritten`);
    }
  });

  it("leaves the meta-line separator alone", () => {
    assert.equal(toNumerals(`3 ${MIDDOT} 5`, "arab"), `٣ ${MIDDOT} ٥`);
  });
});

describe("a separator is never a digit", () => {
  it("the middot is not an Arabic-Indic digit", () => {
    assert.ok(!ARABIC_DIGITS.includes(MIDDOT));
    assert.notEqual(MIDDOT, ARABIC_ZERO);
    assert.notEqual(MIDDOT.charCodeAt(0), ARABIC_ZERO.charCodeAt(0));
  });

  it("an Arabic site groups Western now — by owner decree", () => {
    setNumeralLocale("ar");
    const n = localeNum(1234567);
    assert.equal(n, "1,234,567");
    assert.ok(!new RegExp(`[${ARABIC_DIGITS}]`).test(n), n);
  });

  it("the one road back to Eastern digits still pairs them with U+066C", () => {
    setNumeralLocale("ar-u-nu-arab");
    const n = localeNum(1234567);
    assert.equal(n, "١٬٢٣٤٬٥٦٧");
    assert.ok(n.includes(ARABIC_THOUSANDS));
    assert.ok(!n.includes(MIDDOT), "a middot inside a number reads as a zero");
    assert.ok(!n.includes(","), "an ASCII comma beside Arabic digits");
    for (const v of [0, 7, 12345]) {
      const text = localeNum(v);
      assert.match(text, new RegExp(`^[${ARABIC_DIGITS}${ARABIC_THOUSANDS}]+$`), text);
    }
  });

  it("latn keeps ASCII digits and the ASCII comma", () => {
    setNumeralLocale("en");
    assert.equal(localeNum(1234567), "1,234,567");
    assert.equal(getNumerals(), "latn");
  });
});

describe("dates and the counts beside them agree", () => {
  const iso = "2026-01-09T00:00:00.000Z";
  const formatDate = (locale: string): string =>
    new Intl.DateTimeFormat(locale, {
      dateStyle: "long",
      timeZone: "UTC",
      ...localeDigits(locale),
    }).format(new Date(iso));

  it("an Arabic site prints WESTERN digits in BOTH — by owner decree, Hijri included", () => {
    setLang("ar");
    setNumeralLocale("ar");
    const date = formatDate("ar");
    const count = countPhrase(3, "readMinutes");
    assert.match(date, /2026/, `date kept Eastern digits: ${date}`);
    assert.match(count, /[0-9]/, `count kept Eastern digits: ${count}`);
    const line = `${date} ${MIDDOT} ${count}`;
    assert.ok(!new RegExp(`[${ARABIC_DIGITS}]`).test(line), line);
    // The decree covers the Hijri calendar too.
    const hijri = new Intl.DateTimeFormat("ar", {
      dateStyle: "long",
      timeZone: "UTC",
      calendar: "islamic-umalqura",
      ...localeDigits("ar"),
    }).format(new Date(iso));
    assert.match(hijri, /[0-9]/, `Hijri date kept Eastern digits: ${hijri}`);
    assert.ok(!new RegExp(`[${ARABIC_DIGITS}]`).test(hijri), hijri);
  });

  it("an admin who asks for Western digits gets them in BOTH", () => {
    setLang("ar");
    setNumeralLocale("ar-EG-u-nu-latn");
    const date = formatDate("ar-EG-u-nu-latn");
    const count = countPhrase(3, "readMinutes");
    assert.match(date, /[0-9]/);
    assert.match(count, /[0-9]/);
    assert.ok(!new RegExp(`[${ARABIC_DIGITS}]`).test(count), count);
  });

  it("the footer year follows the same policy", () => {
    const year = String(new Date().getFullYear());
    initSite({ ASTROLABE_DATA: data, SITE_LANG: "ar" });
    assert.equal(footerLine(), `© ${year} Astrolabe`);
    initSite({ ASTROLABE_DATA: data, SITE_LANG: "ar", SITE_FOOTER: "{siteName} — {year}", SITE_NAME: "دفتر" });
    assert.equal(footerLine(), `دفتر — ${year}`);
    initSite({ ASTROLABE_DATA: data });
    assert.equal(footerLine(), `© ${year} Astrolabe`);
  });

  it("the CALENDAR stays Gregorian for every Arabic locale the validator takes", () => {
    // blogLocale accepts any BCP47 tag Intl canonicalizes, and a regional tag
    // can carry its own calendar (ar-SA historically resolved to
    // islamic-umalqura). If a future ICU brings that back, post dates would
    // render in Hijri while the footer year — a plain Gregorian number run
    // through toNumerals — kept saying ٢٠٢٦. This is the tripwire for that.
    for (const locale of ["ar", "ar-EG", "ar-SA", "ar-MA"]) {
      assert.equal(
        new Intl.DateTimeFormat(locale).resolvedOptions().calendar,
        "gregory",
        `${locale} resolved to a non-Gregorian calendar — dates and the footer year now disagree`,
      );
      assert.match(formatDate(locale), /٢٠٢٦|2026/);
    }
    // A tag that explicitly asks for Hijri is honored — it is a real choice.
    assert.equal(
      new Intl.DateTimeFormat("ar-SA-u-ca-islamic").resolvedOptions().calendar,
      "islamic",
    );
  });

  it("localeDigits pins latn so no ICU build can answer arab (why it exists)", () => {
    // Some ICU builds default bare 'ar' to Eastern digits, some to Western;
    // the pin makes the answer the same everywhere. This build's bare answer
    // is documented, not relied on.
    const pinned = formatDate("ar");
    assert.match(pinned, /2026/);
    assert.ok(!new RegExp(`[${ARABIC_DIGITS}]`).test(pinned), pinned);
  });
});

describe("count agreement", () => {
  it("English pluralizes on 1", () => {
    setLang("en");
    setNumeralLocale("en");
    assert.equal(countPhrase(0, "notes"), "0 notes");
    assert.equal(countPhrase(1, "notes"), "1 note");
    assert.equal(countPhrase(2, "notes"), "2 notes");
    assert.equal(countPhrase(1214, "notes"), "1,214 notes");
  });

  it("Arabic keeps the real plural forms, with Western digits", () => {
    setLang("ar");
    setNumeralLocale("ar");
    assert.equal(countPhrase(0, "notes"), "لا ملاحظات");
    assert.equal(countPhrase(1, "notes"), "ملاحظة واحدة");
    assert.equal(countPhrase(2, "notes"), "ملاحظتان");
    assert.equal(countPhrase(3, "notes"), "3 ملاحظات");
    assert.equal(countPhrase(10, "notes"), "10 ملاحظات");
    assert.equal(countPhrase(11, "notes"), "11 ملاحظة");
    assert.equal(countPhrase(1214, "notes"), "1,214 ملاحظة");
  });

  it("the dual and few forms never glue a number to a singular", () => {
    setLang("ar");
    setNumeralLocale("ar");
    assert.equal(countPhrase(2, "readMinutes"), "دقيقتا قراءة");
    assert.equal(countPhrase(5, "readMinutes"), "5 دقائق قراءة");
  });
});

// ------------------------------------------------------------- tag labels

describe("tag labels", () => {
  it("a topic URL encodes every tag shape", () => {
    assert.equal(topicUrl("draft"), "/topic/draft");
    assert.equal(topicUrl("zettel/seed"), "/topic/zettel%2Fseed");
    assert.equal(topicUrl("a b"), "/topic/a%20b");
    assert.equal(topicUrl("مسودة"), `/topic/${encodeURIComponent("مسودة")}`);
    assert.equal(topicUrl("c#"), "/topic/c%23");
  });

  it("a tag spliced into a sentence is bidi-isolated", () => {
    setLang("en");
    const label = tf("searchTag", { tag: "مسودة" });
    assert.ok(!label.includes("{tag}"), "placeholder survived");
    assert.ok(label.includes(isolate("مسودة")), "the value was not isolated");
    assert.equal(t("searchTag"), "Search #{tag}");
  });

  it("the same label localizes", () => {
    setLang("ar");
    assert.match(tf("searchTag", { tag: "draft" }), /^البحث عن #/);
    setLang("en");
  });

  it("a tag carrying a bidi override is defanged before display", () => {
    const hostile = `draft‮gnp.exe`;
    assert.equal(stripBidiControls(hostile), "draftgnp.exe");
    // Direction MARKS are not controls and must survive.
    assert.equal(stripBidiControls("‏مسودة"), "‏مسودة");
  });

  it("a tag renders in ITS OWN direction, not the chrome's", () => {
    setLang("en");
    assert.equal(autoDir("مسودة"), "rtl");
    assert.equal(autoDir("draft"), "ltr");
    assert.equal(autoDir("2026"), "ltr", "no strong character → chrome language");
    setLang("ar");
    assert.equal(autoDir("2026"), "rtl");
    assert.equal(autoDir("draft"), "ltr");
    setLang("en");
  });
});