tests/numerals.test.ts
Rama main · réplica de lectura del repositorio original.
// 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 = `draftgnp.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");
});
});