5dc9a4604f
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Water log mobile field experience (PIN → Headgates → Log → Success): * Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it was a stale reference (page metadata + admin Today tab caption). Constants already say Tuxedo Corn; this just aligns the stragglers. * Tab bar (Headgates | Time): pill pair → real UISegmentedControl. MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses the same ResizeObserver-measure + spring-timing pattern as the existing SegmentedControl — feels like one widget at two sizes (this one at 32pt, the unit selector at 44pt). * Liquid-glass chrome (recipe applied 5×, mirrored on the bottom submit bar): linear-gradient background + blur(24px) + saturate(180%) + inset top highlight + inset bottom hairline. TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky submit bar, FieldPinScreen + SuccessScreen brand strips. * ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the screen's signature. A live "blip" indicator under the 44pt measurement input that ties the form to its real domain (a worker reading a real gauge) instead of being a decorative gauge trophy. Track + optional colored zones (red·green·red) + thumb that slides on Apple's spring curve + color-coded zone label. Mirrors Apple's Volume Limit / Screen Time sliders. * i18n: 9 new zone labels per language (zoneBelowNormal, zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin, zoneReading, zoneTypeToLog, meterNormalRange + ES twins). Legacy normalRange/alertAbove/alertBelow signatures widened to accept pre-formatted strings so integer units render clean. * Updated water-log-i18n unit test for the wider signatures. All 27 tests pass; tsc + eslint clean.
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
});
|
||
}); |