Files
route-commerce/tests/unit/water-log-i18n.test.ts
Tyler 5dc9a4604f
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
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.
2026-07-06 10:32:36 -06:00

306 lines
10 KiB
TypeScript
Raw Permalink 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);
}
});
});