Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
|
* UI i18n unit tests (node:test).
|
|
* Pure modules only — no $app / i18n.svelte.ts ($state) imports.
|
|
*
|
|
* Run from apps/web:
|
|
* node --experimental-strip-types --test src/lib/i18n/*.test.ts
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
|
|
import {
|
|
DEFAULT_UI_LOCALE,
|
|
UI_LOCALE_KEY,
|
|
UI_LOCALES,
|
|
buildUILocaleCookie,
|
|
isUILocale,
|
|
normalizeUILocale,
|
|
persistUILocale,
|
|
preferredAcceptLanguage,
|
|
preferredAcceptLanguageFor,
|
|
readPreferredUILocale,
|
|
resolvePreferredUILocale
|
|
} from "./locales.ts";
|
|
import {
|
|
MESSAGE_CATALOG,
|
|
isRegisteredCatalogLocale,
|
|
loadAllMessages,
|
|
loadMessages,
|
|
messagesFor
|
|
} from "./messages/catalog.ts";
|
|
import { en } from "./messages/en.ts";
|
|
import {
|
|
baseMessageKeys,
|
|
coverageSummary,
|
|
missingKeysForLocale
|
|
} from "./coverage.ts";
|
|
import { resolveMessage } from "./resolve.ts";
|
|
|
|
/** Mirrors setLocale's in-memory switch (normalize + active locale). */
|
|
function setLocale(next: string): string {
|
|
return normalizeUILocale(next);
|
|
}
|
|
|
|
describe("setLocale / locale switching", () => {
|
|
it("normalizes supported codes and rejects unknown to English", () => {
|
|
assert.equal(setLocale("es"), "es");
|
|
assert.equal(setLocale("FR"), "fr");
|
|
assert.equal(setLocale(" de "), "de");
|
|
assert.equal(setLocale("xx"), DEFAULT_UI_LOCALE);
|
|
assert.equal(setLocale(""), DEFAULT_UI_LOCALE);
|
|
assert.equal(setLocale(null as unknown as string), DEFAULT_UI_LOCALE);
|
|
});
|
|
|
|
it("isUILocale matches every UI_LOCALES entry", () => {
|
|
for (const { code } of UI_LOCALES) {
|
|
assert.equal(isUILocale(code), true, code);
|
|
}
|
|
assert.equal(isUILocale("xx"), false);
|
|
});
|
|
|
|
it("switching locale changes resolved pack strings", async () => {
|
|
await loadMessages("es");
|
|
const key = "common.cancel";
|
|
const enText = resolveMessage("en", key);
|
|
const esText = resolveMessage(setLocale("es"), key);
|
|
assert.equal(enText, en[key]);
|
|
assert.notEqual(esText, enText);
|
|
assert.equal(esText, messagesFor("es")[key]);
|
|
});
|
|
});
|
|
|
|
describe("English fallback for missing keys", () => {
|
|
it("falls back to English when the active pack omits a key", () => {
|
|
const key = "common.cancel";
|
|
const english = en[key];
|
|
assert.ok(english);
|
|
const missingLocale = "zz";
|
|
assert.deepEqual(messagesFor(missingLocale), {});
|
|
assert.equal(resolveMessage(missingLocale, key), english);
|
|
});
|
|
|
|
it("falls back to the key itself when English also lacks it", () => {
|
|
assert.equal(resolveMessage("en", "totally.missing.key"), "totally.missing.key");
|
|
assert.equal(resolveMessage("es", "totally.missing.key"), "totally.missing.key");
|
|
});
|
|
|
|
it("HTML-escapes interpolation vars", () => {
|
|
const sampleKey = Object.keys(en).find((k) => en[k]?.includes("{name}"));
|
|
if (!sampleKey) {
|
|
assert.equal(resolveMessage("en", "totally.missing.key"), "totally.missing.key");
|
|
return;
|
|
}
|
|
const out = resolveMessage("en", sampleKey, { name: "<script>x</script>" });
|
|
assert.match(out, /<script>/);
|
|
assert.doesNotMatch(out, /<script>/);
|
|
});
|
|
});
|
|
|
|
describe("pack key parity across UI_LOCALES vs en", () => {
|
|
it("MESSAGE_CATALOG registers every UI_LOCALES code", () => {
|
|
for (const { code } of UI_LOCALES) {
|
|
assert.equal(
|
|
isRegisteredCatalogLocale(code),
|
|
true,
|
|
`missing catalog entry for ${code}`
|
|
);
|
|
}
|
|
assert.ok(MESSAGE_CATALOG.en);
|
|
});
|
|
|
|
it("every UI locale has the same keys as English (no blanks)", async () => {
|
|
await loadAllMessages();
|
|
const baseKeys = baseMessageKeys();
|
|
assert.ok(baseKeys.length >= 350, `expected a full en catalog, got ${baseKeys.length}`);
|
|
|
|
const summary = coverageSummary();
|
|
assert.equal(summary.length, UI_LOCALES.length);
|
|
|
|
for (const row of summary) {
|
|
assert.equal(row.total, baseKeys.length, row.code);
|
|
assert.equal(
|
|
row.missing,
|
|
0,
|
|
`${row.code} missing: ${row.missing_keys.slice(0, 5).join(", ")}`
|
|
);
|
|
assert.equal(row.translated, baseKeys.length, row.code);
|
|
assert.equal(row.registered, true, row.code);
|
|
assert.deepEqual(missingKeysForLocale(row.code, baseKeys), []);
|
|
}
|
|
});
|
|
|
|
|
|
it("feed schedule keys distinguish auto interval from manual Sync", () => {
|
|
const interval = en["feeds.field.interval"];
|
|
const hint = en["feeds.field.intervalHint"];
|
|
const manual = en["feeds.syncManualTitle"];
|
|
assert.ok(interval && /auto/i.test(interval), `interval label should say auto: ${interval}`);
|
|
assert.ok(hint && /sync/i.test(hint) && /interval|schedule/i.test(hint), `hint should contrast Sync vs schedule: ${hint}`);
|
|
assert.ok(manual && /one-off|manual|now/i.test(manual), `manual title should clarify one-off: ${manual}`);
|
|
assert.notEqual(interval, "Sync interval (minutes)");
|
|
});
|
|
it("English key count is positive and matches Object.keys(en)", () => {
|
|
const keys = baseMessageKeys();
|
|
assert.equal(keys.length, Object.keys(en).length);
|
|
assert.ok(keys.length >= 350, `expected a full catalog, got ${keys.length}`);
|
|
});
|
|
});
|
|
|
|
type LocaleWindow = Window & { __UI_LOCALE__?: string };
|
|
|
|
function installLocaleStorageMocks(protocol: "http:" | "https:" = "http:") {
|
|
const store = new Map<string, string>();
|
|
let cookieJar = "";
|
|
let lastCookieAssignment = "";
|
|
const localStorageMock = {
|
|
getItem(key: string) {
|
|
return store.has(key) ? store.get(key)! : null;
|
|
},
|
|
setItem(key: string, value: string) {
|
|
store.set(key, String(value));
|
|
},
|
|
removeItem(key: string) {
|
|
store.delete(key);
|
|
},
|
|
clear() {
|
|
store.clear();
|
|
}
|
|
};
|
|
const documentMock = {
|
|
get cookie() {
|
|
return cookieJar;
|
|
},
|
|
set cookie(value: string) {
|
|
lastCookieAssignment = String(value);
|
|
const pair = String(value).split(";")[0] ?? "";
|
|
const eq = pair.indexOf("=");
|
|
if (eq <= 0) return;
|
|
const name = pair.slice(0, eq);
|
|
const rest = cookieJar
|
|
.split(";")
|
|
.map((p) => p.trim())
|
|
.filter((p) => p && !p.startsWith(`${name}=`));
|
|
rest.push(pair);
|
|
cookieJar = rest.join("; ");
|
|
}
|
|
};
|
|
const win = { __UI_LOCALE__: undefined as string | undefined } as LocaleWindow;
|
|
const g = globalThis as typeof globalThis & {
|
|
window?: LocaleWindow;
|
|
localStorage?: typeof localStorageMock;
|
|
document?: typeof documentMock;
|
|
location?: { protocol: string };
|
|
};
|
|
const prev = {
|
|
window: g.window,
|
|
localStorage: g.localStorage,
|
|
document: g.document,
|
|
location: g.location
|
|
};
|
|
g.window = win as typeof g.window;
|
|
g.localStorage = localStorageMock as typeof g.localStorage;
|
|
g.document = documentMock as typeof g.document;
|
|
g.location = { protocol } as typeof g.location;
|
|
return {
|
|
store,
|
|
getCookie: () => cookieJar,
|
|
getLastCookieAssignment: () => lastCookieAssignment,
|
|
win,
|
|
restore() {
|
|
if (prev.window === undefined) (g as { window?: unknown }).window = undefined;
|
|
else g.window = prev.window;
|
|
if (prev.localStorage === undefined) (g as { localStorage?: unknown }).localStorage = undefined;
|
|
else g.localStorage = prev.localStorage;
|
|
if (prev.document === undefined) (g as { document?: unknown }).document = undefined;
|
|
else g.document = prev.document;
|
|
if (prev.location === undefined) (g as { location?: unknown }).location = undefined;
|
|
else g.location = prev.location;
|
|
}
|
|
};
|
|
}
|
|
|
|
describe("persistUILocale / cookie+localStorage round-trip", () => {
|
|
it("buildUILocaleCookie uses UI_LOCALE_KEY and optional Secure", () => {
|
|
assert.equal(UI_LOCALE_KEY, "descrybe-ui-locale");
|
|
assert.equal(
|
|
buildUILocaleCookie("es"),
|
|
`${UI_LOCALE_KEY}=es; Path=/; SameSite=Lax; Max-Age=31536000`
|
|
);
|
|
assert.equal(
|
|
buildUILocaleCookie("FR", true),
|
|
`${UI_LOCALE_KEY}=fr; Path=/; SameSite=Lax; Max-Age=31536000; Secure`
|
|
);
|
|
});
|
|
|
|
it("set locale → descrybe-ui-locale → read back → normalize", () => {
|
|
const mock = installLocaleStorageMocks("http:");
|
|
try {
|
|
const written = persistUILocale(" ES ");
|
|
assert.equal(written, "es");
|
|
assert.equal(mock.store.get(UI_LOCALE_KEY), "es");
|
|
assert.equal(mock.win.__UI_LOCALE__, "es");
|
|
assert.match(mock.getCookie(), new RegExp(`(?:^|;\\s*)${UI_LOCALE_KEY}=es(?:;|$)`));
|
|
assert.equal(readPreferredUILocale(), "es");
|
|
|
|
// Prefer localStorage when window marker is cleared (reload / FOUC path).
|
|
delete mock.win.__UI_LOCALE__;
|
|
assert.equal(readPreferredUILocale(), "es");
|
|
|
|
assert.equal(persistUILocale("FR"), "fr");
|
|
assert.equal(mock.store.get(UI_LOCALE_KEY), "fr");
|
|
assert.equal(readPreferredUILocale(), "fr");
|
|
|
|
assert.equal(persistUILocale("xx"), DEFAULT_UI_LOCALE);
|
|
assert.equal(mock.store.get(UI_LOCALE_KEY), DEFAULT_UI_LOCALE);
|
|
assert.equal(readPreferredUILocale(), DEFAULT_UI_LOCALE);
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
it("writes Secure cookie when location is https", () => {
|
|
const mock = installLocaleStorageMocks("https:");
|
|
try {
|
|
persistUILocale("de");
|
|
assert.equal(mock.store.get(UI_LOCALE_KEY), "de");
|
|
assert.equal(
|
|
mock.getLastCookieAssignment(),
|
|
`${UI_LOCALE_KEY}=de; Path=/; SameSite=Lax; Max-Age=31536000; Secure`
|
|
);
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
it("SSR / no window: normalize only, no throw", () => {
|
|
assert.equal(typeof window, "undefined");
|
|
assert.equal(persistUILocale("ja"), "ja");
|
|
assert.equal(readPreferredUILocale(), DEFAULT_UI_LOCALE);
|
|
});
|
|
});
|
|
|
|
describe("preferredAcceptLanguage / readPreferredUILocale → header tag", () => {
|
|
it("resolvePreferredUILocale prefers window override over stored", () => {
|
|
assert.equal(resolvePreferredUILocale("nl", "en"), "nl");
|
|
assert.equal(resolvePreferredUILocale("XX", "fr"), "fr");
|
|
assert.equal(resolvePreferredUILocale(null, "de"), "de");
|
|
assert.equal(resolvePreferredUILocale(undefined, undefined), DEFAULT_UI_LOCALE);
|
|
assert.equal(resolvePreferredUILocale("", " ES "), "es");
|
|
});
|
|
|
|
it("preferredAcceptLanguageFor maps UI locale to BCP 47 Accept-Language tag", () => {
|
|
for (const { code, htmlLang } of UI_LOCALES) {
|
|
assert.equal(preferredAcceptLanguageFor(code), htmlLang, code);
|
|
}
|
|
assert.equal(preferredAcceptLanguageFor("NL"), "nl");
|
|
assert.equal(preferredAcceptLanguageFor("xx"), "en");
|
|
});
|
|
|
|
it("preferredAcceptLanguage without window defaults to English tag", () => {
|
|
assert.equal(typeof window, "undefined");
|
|
assert.equal(readPreferredUILocale(), DEFAULT_UI_LOCALE);
|
|
assert.equal(preferredAcceptLanguage(), "en");
|
|
assert.equal(
|
|
preferredAcceptLanguageFor(resolvePreferredUILocale("nl", "en")),
|
|
"nl"
|
|
);
|
|
});
|
|
});
|