Files
route-commerce/tests/unit/water-log-i18n.test.ts
T
Tyler 7795263944
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
- 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft
  with integer-only validation: submit button greys out and the keyboard
  raises the numeric keypad (no decimal) when the unit is selected.
- Language toggle in the headgate list header (next to logout) flips
  every screen between English and Spanish. Choice is persisted to the
  existing 'wl_lang' cookie for 1y, so the user's last selection
  sticks across logout/relogin.
- i18n strings lifted into shared src/lib/water-log/i18n.ts (single
  source of truth, en/es parity enforced by tests). All mobile screens
  (Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error
  in the orchestrator consume the dictionary. Brand strip, aria-labels,
  segmented control labels, success recap, and submit-button labels
  all go through getT()/formatUnit()/displayUnit().
- 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es
  dict parity, holes catalog + hoyos translation, all the
  detectInitialLang() branches (SSR/cookie/navigator), setLangCookie
  read/write, label interpolation helpers, and the unit catalog.

Refs /tmp/refactor-water-log.md (Commit B plan)
2026-07-02 11:18:23 -06:00

296 lines
9.8 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");
});
});
// ── 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);
}
});
});