Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user