feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s

- '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)
This commit is contained in:
Tyler
2026-07-02 11:18:23 -06:00
parent b29caa0f30
commit 7795263944
9 changed files with 613 additions and 111 deletions
+296
View File
@@ -0,0 +1,296 @@
/**
* 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);
}
});
});