Files
route-commerce/tests/unit/water-log-i18n.test.ts
T
Tyler 9ecb496a7c
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
fix(water-log): address code-review findings from i18n PR
- Session-expired bounce-to-PIN branch now matches the actual strings
  returned by requireFieldSession (was 'Session expired or invalid'
  which never matched). Added 'Session not found' and 'User is inactive'
  to the intercept list. The user-visible 'Your session expired. Please
  sign in again.' string is now in the dictionary (en + es).
- LangProvider moved up to WaterFieldClient. The orchestrator (which
  was sitting OUTSIDE the provider in b29caa0) now calls useLang()
  directly, so the loadHeadgates fallback error string is always
  rendered in the user's chosen language — even if they toggled
  after signing in.
- getClientLangSnapshot now memoizes the cookie value (React 19
  enforces getSnapshot purity stricter than 18).
- LanguageToggle switched from aria-pressed (toggle pattern) to
  role="radiogroup" + role="radio" + aria-checked (mutually-exclusive
  selector pattern). Per-button aria-labels dropped — the visible
  EN/ES text is read by SR as the radio name. Group label switched
  to t.common.a11yLanguage for Spanish parity.
- Notes textarea now has aria-label={t.log.notes} (was unlabeled —
  SR would announce 'Edit text' with no context).
- 1 new test: lock in that wl_lang=<script>...</script> falls through
  to the navigator (injection guard on the cookie regex).

Refs /tmp/refactor-water-log.md (review followups to Commit B)
2026-07-02 11:27:16 -06:00

306 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string>;
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<string, unknown>;
const esScreen = LABELS.es[screen] as Record<string, unknown>;
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<typeof installDomStub>;
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}=<script>alert(1)</script>; path=/`;
stub.setNav({ language: "en-US" });
expect(detectInitialLang()).toBe("en");
});
});
// ── setLangCookie ───────────────────────────────────────────────────
describe("setLangCookie()", () => {
let stub: ReturnType<typeof installDomStub>;
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);
}
});
});