Files
beacon-web/tests/i18n/index.test.ts
MrAlders0n 3e43cfd009 fix: follow up on last week's merges (#55-#101) (#102)
Bugs: clear compare params on tab change, recover channel live messages after a 429,
observer copy link keeps only region params, route table no longer navigates on region
change with nothing selected, map borders memo on data identity, live overview can't
resurrect a failed query, French relative times, no language write on first visit,
0/0 node locations hidden outside the map, path/signal stats use the shared retry rule.

i18n: node/packet/mesh/observer strings that were still hardcoded after #62.

Cleanup: formatUtc, ACTION_BUTTON_CLASS, observer-stats, useWindowedStats, observer-id;
observer pages use StatCard/Card/Field/Segmented/SearchBar/SelectDropdown; one details
chrome on the stats tabs; comment and translations-guide trims; localStorage stub for
Node 26 in the test setup.
2026-09-30 10:19:32 -04:00

107 lines
4.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import i18n, { languages, readLanguagePreference } from "../../src/i18n";
afterEach(() => {
vi.restoreAllMocks();
localStorage.removeItem("beacon-language");
});
describe("language preferences and catalogs", () => {
it("offers the bundled English and French catalogs by their native names", () => {
expect(languages).toEqual(expect.arrayContaining([
{ code: "en", name: "English" },
{ code: "fr", name: "Français" },
]));
});
it("defaults to English and restores a supported saved choice", () => {
localStorage.removeItem("beacon-language");
expect(readLanguagePreference()).toBe("en");
localStorage.setItem("beacon-language", "fr");
expect(readLanguagePreference()).toBe("fr");
});
it.each(["unknown", "constructor", "", "../fr"])("ignores unsupported saved language %j", (value) => {
localStorage.setItem("beacon-language", value);
expect(readLanguagePreference()).toBe("en");
});
it("still changes language when browser storage is unavailable", async () => {
vi.stubGlobal("localStorage", {
getItem() { throw new Error("blocked"); },
setItem() { throw new Error("blocked"); },
removeItem() {},
});
try {
expect(readLanguagePreference()).toBe("en");
await i18n.changeLanguage("fr");
expect(i18n.t("tabs.Packets")).toBe("Paquets");
expect(document.documentElement.lang).toBe("fr");
expect(document.documentElement.dir).toBe("ltr");
} finally {
vi.unstubAllGlobals();
}
});
it("does not persist a language on module init, only on an explicit change", async () => {
// Isolated in-memory store: the real localStorage is a single process-wide object (Node's
// localStorage shadows jsdom's per test file), so a leftover "beacon-language" from another
// suite sharing this worker could otherwise land here before the dynamic import reads it.
const store = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
});
try {
vi.resetModules();
const fresh = await import("../../src/i18n");
expect(localStorage.getItem("beacon-language")).toBeNull();
await fresh.default.changeLanguage("fr");
expect(localStorage.getItem("beacon-language")).toBe("fr");
} finally {
vi.unstubAllGlobals();
}
});
it("persists only the language preference and restores English document metadata", async () => {
localStorage.setItem("beacon-region", "YVR");
await i18n.changeLanguage("fr");
expect(localStorage.getItem("beacon-language")).toBe("fr");
expect(localStorage.getItem("beacon-region")).toBe("YVR");
await i18n.changeLanguage("en");
expect(document.documentElement.lang).toBe("en");
localStorage.removeItem("beacon-region");
});
it("falls back to English for missing and empty translated strings", async () => {
i18n.addResource("en", "translation", "fallbackTest", "English fallback");
await i18n.changeLanguage("fr");
expect(i18n.t("fallbackTest")).toBe("English fallback");
i18n.addResource("fr", "translation", "fallbackTest", "");
expect(i18n.t("fallbackTest")).toBe("English fallback");
});
it("uses each language's plural forms and interpolates the retry duration", () => {
expect(i18n.t("region.count", { lng: "en", count: 1 })).toBe("1 region");
expect(i18n.t("region.count", { lng: "en", count: 2 })).toBe("2 regions");
expect(i18n.t("region.count", { lng: "fr", count: 1 })).toBe("1 région");
expect(i18n.t("region.count", { lng: "fr", count: 2 })).toBe("2 régions");
expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("DÉBIT LIMITÉ 5 s");
});
it("has whole-phrase battery/noise labels in both catalogs (no joined-word strings)", () => {
expect(i18n.t("observerPage.batteryV", { lng: "en" })).toBe("Battery V");
expect(i18n.t("observerPage.noiseDbm", { lng: "en" })).toBe("Noise dBm");
expect(i18n.t("observerPage.batteryV", { lng: "fr" })).toBe("Batterie V");
expect(i18n.t("observerPage.noiseDbm", { lng: "fr" })).toBe("Bruit dBm");
});
it("interpolates the payload-type total as a plain value, not a plural count", () => {
// formatCount can return a non-numeric string like "1.2k"; a `count` placeholder would feed that
// into plural resolution instead of a straight interpolation.
expect(i18n.t("mesh.obs", { lng: "en", value: "1.2k" })).toBe("1.2k obs");
expect(i18n.t("mesh.obs", { lng: "fr", value: "1,2k" })).toBe("1,2k obs");
});
});