feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user