/** * Unit tests for the shared i18n module. * * Covers the label dictionary (en/es), the unit display mapping, * the formatter helpers, and the lang-detection function. The * vitest env is "node" (no jsdom), so we stub `document` and * `navigator` directly via property assignment. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { detectInitialLang, displayUnit, getT, LABELS, setLangCookie, WL_LANG_COOKIE, type Lang, } from "@/lib/water-log/i18n"; // ── Minimal DOM stub ───────────────────────────────────────────────── type CookieStore = Record; function installDomStub(initialCookies: CookieStore = {}) { const cookies: CookieStore = { ...initialCookies }; const document = { get cookie() { return Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) .join("; "); }, set cookie(value: string) { // Parse "name=value; ..." — keep just the first pair. const [pair] = value.split(";"); const eq = pair.indexOf("="); if (eq < 0) return; const name = pair.slice(0, eq).trim(); const val = pair.slice(eq + 1).trim(); // Treat "name=; expires=..." as a delete. if ( val === "" || /expires=Thu, 01 Jan 1970/i.test(value) || /max-age=0/i.test(value) ) { delete cookies[name]; } else { cookies[name] = val; } }, }; const originalDoc = (globalThis as { document?: unknown }).document; const originalNav = (globalThis as { navigator?: unknown }).navigator; Object.defineProperty(globalThis, "document", { value: document, writable: true, configurable: true, }); return { setNav(nav: { language: string } | undefined) { Object.defineProperty(globalThis, "navigator", { value: nav, writable: true, configurable: true, }); }, restore() { Object.defineProperty(globalThis, "document", { value: originalDoc, writable: true, configurable: true, }); Object.defineProperty(globalThis, "navigator", { value: originalNav, writable: true, configurable: true, }); }, }; } // ── Lang dictionary ──────────────────────────────────────────────────── describe("LABELS dictionary", () => { it("has both en and es keys", () => { expect(LABELS.en).toBeDefined(); expect(LABELS.es).toBeDefined(); }); it("en and es share the same top-level screen keys", () => { const enKeys = Object.keys(LABELS.en).sort(); const esKeys = Object.keys(LABELS.es).sort(); expect(esKeys).toEqual(enKeys); }); it("every screen's label has matching en and es inner keys", () => { for (const screen of Object.keys(LABELS.en) as Array< keyof typeof LABELS.en >) { const enScreen = LABELS.en[screen] as Record; const esScreen = LABELS.es[screen] as Record; const enInner = Object.keys(enScreen).sort(); const esInner = Object.keys(esScreen).sort(); expect(esInner, `screen "${String(screen)}"`).toEqual(enInner); } }); it("PIN screen labels are non-empty and translated", () => { expect(LABELS.en.pin.title.length).toBeGreaterThan(0); expect(LABELS.es.pin.title.length).toBeGreaterThan(0); expect(LABELS.en.pin.title).not.toBe(LABELS.es.pin.title); }); }); // ── getT ────────────────────────────────────────────────────────────── describe("getT()", () => { it("returns the en dict for 'en'", () => { expect(getT("en")).toBe(LABELS.en); }); it("returns the es dict for 'es'", () => { expect(getT("es")).toBe(LABELS.es); }); }); // ── displayUnit ────────────────────────────────────────────────────── describe("displayUnit()", () => { it("translates 'holes' to 'hoyos' in Spanish", () => { expect(displayUnit("holes", "es")).toBe("hoyos"); }); it("leaves 'holes' as 'holes' in English", () => { expect(displayUnit("holes", "en")).toBe("holes"); }); it("leaves CFS, GPM, gal, ac-in, ac-ft unchanged in both langs", () => { const passThrough: Lang[] = ["en", "es"]; for (const lang of passThrough) { expect(displayUnit("CFS", lang)).toBe("CFS"); expect(displayUnit("GPM", lang)).toBe("GPM"); expect(displayUnit("gal", lang)).toBe("gal"); expect(displayUnit("ac-in", lang)).toBe("ac-in"); expect(displayUnit("ac-ft", lang)).toBe("ac-ft"); } }); it("passes through unknown units unchanged", () => { expect(displayUnit("custom-unit", "en")).toBe("custom-unit"); expect(displayUnit("custom-unit", "es")).toBe("custom-unit"); }); }); // ── detectInitialLang ─────────────────────────────────────────────── describe("detectInitialLang()", () => { let stub: ReturnType; beforeEach(() => { stub = installDomStub(); }); afterEach(() => { stub.restore(); }); it("returns 'es' when document is undefined (SSR)", () => { Object.defineProperty(globalThis, "document", { value: undefined, writable: true, configurable: true, }); expect(detectInitialLang()).toBe("es"); }); it("returns 'en' when wl_lang cookie is 'en'", () => { document.cookie = `${WL_LANG_COOKIE}=en; path=/`; expect(detectInitialLang()).toBe("en"); }); it("returns 'es' when wl_lang cookie is 'es'", () => { document.cookie = `${WL_LANG_COOKIE}=es; path=/`; expect(detectInitialLang()).toBe("es"); }); it("ignores a malformed wl_lang cookie and falls back to navigator", () => { document.cookie = `${WL_LANG_COOKIE}=fr; path=/`; stub.setNav({ language: "en-US" }); expect(detectInitialLang()).toBe("en"); }); it("falls back to 'es' when no cookie and navigator is non-English", () => { stub.setNav({ language: "fr-FR" }); expect(detectInitialLang()).toBe("es"); }); it("ignores a wl_lang value that contains a JS payload (injection guard)", () => { // Important: the regex in `detectInitialLang` only matches the // exact 2-letter codes. Anything else must fall through to the // navigator/default. This locks in that we never feed untrusted // cookie contents into React state. document.cookie = `${WL_LANG_COOKIE}=; path=/`; stub.setNav({ language: "en-US" }); expect(detectInitialLang()).toBe("en"); }); }); // ── setLangCookie ─────────────────────────────────────────────────── describe("setLangCookie()", () => { let stub: ReturnType; beforeEach(() => { stub = installDomStub(); }); afterEach(() => { stub.restore(); }); it("writes the wl_lang cookie with the chosen lang", () => { setLangCookie("en"); expect(document.cookie).toMatch(/wl_lang=en/); }); it("overwrites a previous value", () => { setLangCookie("en"); setLangCookie("es"); expect(document.cookie).toMatch(/wl_lang=es/); }); it("is a no-op when document is undefined (SSR)", () => { Object.defineProperty(globalThis, "document", { value: undefined, writable: true, configurable: true, }); expect(() => setLangCookie("en")).not.toThrow(); }); }); // ── Mobile label interpolation ───────────────────────────────────── describe("Mobile label helpers (interpolation)", () => { it("headgates.signedInAs interpolates the name", () => { expect(LABELS.en.headgates.signedInAs("Tyler")).toBe( "Signed in as Tyler", ); expect(LABELS.es.headgates.signedInAs("Tyler")).toBe( "Sesión iniciada como Tyler", ); }); it("log.normalRange interpolates the bounds + unit", () => { expect(LABELS.en.log.normalRange("2", "8", "CFS")).toBe( "Normal range: 2 – 8 CFS", ); expect(LABELS.es.log.normalRange("2", "8", "CFS")).toBe( "Rango normal: 2 – 8 CFS", ); }); it("log.alertAbove / alertBelow interpolate single bound + unit", () => { expect(LABELS.en.log.alertAbove("10", "CFS")).toBe("Alert above 10 CFS"); expect(LABELS.es.log.alertAbove("10", "CFS")).toBe( "Alerta arriba de 10 CFS", ); expect(LABELS.en.log.alertBelow("1", "CFS")).toBe("Alert below 1 CFS"); expect(LABELS.es.log.alertBelow("1", "CFS")).toBe("Alerta abajo de 1 CFS"); }); it("success.loggedBy interpolates the irrigator name", () => { expect(LABELS.en.success.loggedBy("Maria")).toBe("Logged by Maria"); expect(LABELS.es.success.loggedBy("Maria")).toBe("Registrado por Maria"); }); it("format.neverLogged / justNow are non-empty and translated", () => { expect(LABELS.en.format.neverLogged.length).toBeGreaterThan(0); expect(LABELS.es.format.neverLogged.length).toBeGreaterThan(0); expect(LABELS.en.format.justNow).not.toBe(LABELS.es.format.justNow); }); it("format.minutesAgo / hoursAgo interpolate the count", () => { expect(LABELS.en.format.minutesAgo(5)).toBe("5m ago"); expect(LABELS.es.format.minutesAgo(5)).toBe("hace 5m"); expect(LABELS.en.format.hoursAgo(3)).toBe("3h ago"); expect(LABELS.es.format.hoursAgo(3)).toBe("hace 3h"); }); }); // ── Unit catalog (regression guard for "holes") ───────────────────── describe("Unit catalog includes 'holes'", () => { it("'holes' is in the en catalog and translates to 'hoyos' in es", () => { expect(LABELS.en.units.holes).toBe("holes"); expect(LABELS.es.units.holes).toBe("hoyos"); }); it("all six MOBILE_UNITS keys exist in the units dict for both langs", () => { const expected = ["CFS", "GPM", "gal", "ac-in", "ac-ft", "holes"]; for (const unit of expected) { expect(LABELS.en.units).toHaveProperty(unit); expect(LABELS.es.units).toHaveProperty(unit); } }); });