mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import {
|
|
hourCycleOptions,
|
|
makeFormatters,
|
|
parseLocaleFromAcceptLanguage,
|
|
resolveIntlLocale,
|
|
} from "../format-locale";
|
|
|
|
describe("resolveIntlLocale", () => {
|
|
it("maps short locales to BCP-47 tags", () => {
|
|
expect(resolveIntlLocale("de")).toBe("de-DE");
|
|
expect(resolveIntlLocale("en")).toBe("en-US");
|
|
});
|
|
});
|
|
|
|
describe("parseLocaleFromAcceptLanguage", () => {
|
|
it("returns en for null / empty", () => {
|
|
expect(parseLocaleFromAcceptLanguage(null)).toBe("en");
|
|
expect(parseLocaleFromAcceptLanguage("")).toBe("en");
|
|
});
|
|
|
|
it("returns de for primary German tag", () => {
|
|
expect(parseLocaleFromAcceptLanguage("de-DE,en;q=0.9")).toBe("de");
|
|
expect(parseLocaleFromAcceptLanguage("de")).toBe("de");
|
|
expect(parseLocaleFromAcceptLanguage("de-CH")).toBe("de");
|
|
});
|
|
|
|
it("returns en for English or unrecognised locales", () => {
|
|
expect(parseLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en");
|
|
expect(parseLocaleFromAcceptLanguage("ja-JP")).toBe("en");
|
|
expect(parseLocaleFromAcceptLanguage("*")).toBe("en");
|
|
});
|
|
|
|
// v1.4.25 — the four AI-initial locales added in this release.
|
|
// Each maps via the same primary-tag prefix match the DE branch uses.
|
|
it("returns the matching tag for FR / ES / IT / PL", () => {
|
|
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("fr");
|
|
expect(parseLocaleFromAcceptLanguage("es-ES")).toBe("es");
|
|
expect(parseLocaleFromAcceptLanguage("es-MX")).toBe("es");
|
|
expect(parseLocaleFromAcceptLanguage("it-IT")).toBe("it");
|
|
expect(parseLocaleFromAcceptLanguage("pl-PL")).toBe("pl");
|
|
expect(parseLocaleFromAcceptLanguage("pl")).toBe("pl");
|
|
});
|
|
});
|
|
|
|
describe("makeFormatters", () => {
|
|
const deFmt = makeFormatters("de", undefined, "AUTO", "AUTO");
|
|
const enFmt = makeFormatters("en", undefined, "AUTO", "AUTO");
|
|
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
|
|
|
|
it("formats numbers with regional decimal separators", () => {
|
|
expect(deFmt.number(70.5, 1)).toMatch(/70,5/);
|
|
expect(enFmt.number(70.5, 1)).toMatch(/70\.5/);
|
|
});
|
|
|
|
it("formats integers with thousands separators", () => {
|
|
expect(deFmt.integer(1234567)).toMatch(/1[.\u202f]234[.\u202f]567/);
|
|
expect(enFmt.integer(1234567)).toBe("1,234,567");
|
|
});
|
|
|
|
it("formats percent", () => {
|
|
expect(deFmt.percent(0.835, 1)).toMatch(/83,5\s*%/);
|
|
expect(enFmt.percent(0.835, 1)).toMatch(/83\.5%/);
|
|
});
|
|
|
|
it("formats date in Europe/Berlin", () => {
|
|
// Both should include day/month/year; locale-specific order differs.
|
|
expect(deFmt.date(sample)).toContain("2026");
|
|
expect(enFmt.date(sample)).toContain("2026");
|
|
});
|
|
|
|
// v1.15.20 — the hour cycle follows the locale convention by default
|
|
// (AUTO). Pre-v1.15.20 both locales were pinned to 24h via `hour12:
|
|
// false`; the explicit preference replaced the pin.
|
|
it("formats time per the locale convention under AUTO", () => {
|
|
expect(deFmt.time(sample)).toBe("16:30");
|
|
expect(enFmt.time(sample)).toBe("04:30 PM");
|
|
});
|
|
|
|
describe("hour-cycle preference (v1.15.20)", () => {
|
|
it("AUTO follows the locale default for time + dateTime", () => {
|
|
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
|
|
const en = makeFormatters("en", undefined, "AUTO", "AUTO");
|
|
expect(de.time(sample)).toBe("16:30");
|
|
expect(en.time(sample)).toBe("04:30 PM");
|
|
expect(de.dateTime(sample)).toBe("18.04.2026, 16:30");
|
|
expect(en.dateTime(sample)).toBe("04/18/2026, 04:30 PM");
|
|
});
|
|
|
|
it("H12 forces AM/PM regardless of locale", () => {
|
|
const de = makeFormatters("de", undefined, "H12", "AUTO");
|
|
const en = makeFormatters("en", undefined, "H12", "AUTO");
|
|
expect(de.time(sample)).toContain("PM");
|
|
expect(en.time(sample)).toBe("04:30 PM");
|
|
expect(de.dateTime(sample)).toContain("PM");
|
|
});
|
|
|
|
it("H24 forces the 24-hour clock regardless of locale", () => {
|
|
const de = makeFormatters("de", undefined, "H24", "AUTO");
|
|
const en = makeFormatters("en", undefined, "H24", "AUTO");
|
|
expect(de.time(sample)).toBe("16:30");
|
|
expect(en.time(sample)).toBe("16:30");
|
|
expect(en.dateTime(sample)).toBe("04/18/2026, 16:30");
|
|
});
|
|
|
|
it("H24 renders midnight as 00, never 24 (h23 cycle)", () => {
|
|
const midnight = new Date("2026-04-18T22:30:00Z"); // 00:30 Berlin CEST
|
|
const en = makeFormatters("en", undefined, "H24", "AUTO");
|
|
expect(en.time(midnight)).toBe("00:30");
|
|
});
|
|
|
|
it("does not affect date-only formatters", () => {
|
|
const auto = makeFormatters("en", undefined, "AUTO", "AUTO");
|
|
const h24 = makeFormatters("en", undefined, "H24", "AUTO");
|
|
expect(auto.date(sample)).toBe(h24.date(sample));
|
|
expect(auto.dateShort(sample)).toBe(h24.dateShort(sample));
|
|
});
|
|
});
|
|
|
|
// v1.4.25 — formatters accept a per-user timezone override.
|
|
it("renders time in the user's tz when userTz is passed", () => {
|
|
const tokyo = makeFormatters("en", "Asia/Tokyo", "H24", "AUTO");
|
|
// 14:30 UTC = 23:30 Tokyo
|
|
expect(tokyo.time(sample)).toBe("23:30");
|
|
});
|
|
|
|
it("renders time in Europe/Berlin when userTz is empty", () => {
|
|
const fallback = makeFormatters("en", "", "H24", "AUTO");
|
|
expect(fallback.time(sample)).toBe("16:30");
|
|
});
|
|
|
|
it("renders date in the user's tz when userTz is passed", () => {
|
|
// 23:30 UTC on May 10 = 01:30 May 11 Tokyo
|
|
const lateUtc = new Date("2026-05-10T23:30:00Z");
|
|
const tokyo = makeFormatters("en", "Asia/Tokyo", "AUTO", "AUTO");
|
|
expect(tokyo.date(lateUtc)).toMatch(/2026/);
|
|
// The narrow check: day-of-month should be 11, not 10.
|
|
expect(tokyo.date(lateUtc)).toMatch(/11/);
|
|
});
|
|
|
|
// ── Issue #490 — profile-timezone display matrix ──────────────────────
|
|
//
|
|
// The client formatters now receive the mirrored profile zone. Pin the
|
|
// three matrix zones (east + DST, west of Berlin + DST, east no-DST)
|
|
// and the poison values that must NEVER reach `Intl.DateTimeFormat`
|
|
// unguarded (RangeError there would white-screen every date render).
|
|
describe("profile-timezone matrix (#490)", () => {
|
|
// sample = 2026-04-18T14:30:00Z (see above).
|
|
it("renders Pacific/Auckland (UTC+12, NZST after the April DST end)", () => {
|
|
const fmt = makeFormatters("en", "Pacific/Auckland", "H24", "AUTO");
|
|
expect(fmt.time(sample)).toBe("02:30"); // next local day
|
|
expect(fmt.date(sample)).toBe("04/19/2026");
|
|
});
|
|
|
|
it("renders America/New_York (west of Berlin, EDT)", () => {
|
|
const fmt = makeFormatters("en", "America/New_York", "H24", "AUTO");
|
|
expect(fmt.time(sample)).toBe("10:30");
|
|
expect(fmt.date(sample)).toBe("04/18/2026");
|
|
});
|
|
|
|
it("renders Asia/Manila (UTC+8, no DST)", () => {
|
|
const fmt = makeFormatters("en", "Asia/Manila", "H24", "AUTO");
|
|
expect(fmt.time(sample)).toBe("22:30");
|
|
expect(fmt.date(sample)).toBe("04/18/2026");
|
|
});
|
|
|
|
it.each([["Mars/Olympus"], ["garbage"], [""], [undefined]])(
|
|
"poison zone %s never throws and falls back to Berlin",
|
|
(zone) => {
|
|
const fmt = makeFormatters(
|
|
"en",
|
|
zone as string | undefined,
|
|
"H24",
|
|
"AUTO",
|
|
);
|
|
expect(fmt.time(sample)).toBe("16:30");
|
|
expect(fmt.date(sample)).toBe("04/18/2026");
|
|
expect(fmt.dateTime(sample)).toBe("04/18/2026, 16:30");
|
|
expect(fmt.dateWithWeekday(sample)).toContain("18");
|
|
expect(fmt.monthShort(sample)).toBe("Apr");
|
|
},
|
|
);
|
|
|
|
// The 23:30-Manila boundary pin: the rendered day label must equal the
|
|
// server's profile-tz day key for the same instant — the exact split
|
|
// #490 reported (Manila-grouped data under Berlin labels).
|
|
it("renders the same calendar day the server day-keys (Asia/Manila)", () => {
|
|
// YMD preference → the date renders as the ISO day key itself.
|
|
const manila = makeFormatters("en", "Asia/Manila", "H24", "YMD");
|
|
// 15:30 UTC = 23:30 Manila, still Jul 14 in Manila.
|
|
const lateManilaEvening = new Date("2026-07-14T15:30:00Z");
|
|
expect(manila.date(lateManilaEvening)).toBe("2026-07-14");
|
|
// 17:30 UTC = 01:30 Jul 15 Manila — Berlin (19:30 Jul 14) would
|
|
// label the previous day; the profile zone must win.
|
|
const pastManilaMidnight = new Date("2026-07-14T17:30:00Z");
|
|
expect(manila.date(pastManilaMidnight)).toBe("2026-07-15");
|
|
expect(manila.time(pastManilaMidnight)).toBe("01:30");
|
|
});
|
|
|
|
// Berlin DST pins — the zone maths must follow the IANA rules at the
|
|
// instant, never a cached offset.
|
|
it("renders across the Berlin 2026-03-29 spring-forward", () => {
|
|
const fmt = makeFormatters("de", "Europe/Berlin", "H24", "AUTO");
|
|
// 00:30 UTC = 01:30 CET (before the 02:00→03:00 jump).
|
|
expect(fmt.time(new Date("2026-03-29T00:30:00Z"))).toBe("01:30");
|
|
// 01:30 UTC = 03:30 CEST (the 02:xx hour does not exist).
|
|
expect(fmt.time(new Date("2026-03-29T01:30:00Z"))).toBe("03:30");
|
|
expect(fmt.date(new Date("2026-03-29T01:30:00Z"))).toBe("29.03.2026");
|
|
});
|
|
|
|
it("renders across the Berlin 2026-10-25 fall-back (doubled hour)", () => {
|
|
const fmt = makeFormatters("de", "Europe/Berlin", "H24", "AUTO");
|
|
// 00:30 UTC = 02:30 CEST (first pass through the doubled hour).
|
|
expect(fmt.time(new Date("2026-10-25T00:30:00Z"))).toBe("02:30");
|
|
// 01:30 UTC = 02:30 CET (second pass).
|
|
expect(fmt.time(new Date("2026-10-25T01:30:00Z"))).toBe("02:30");
|
|
expect(fmt.date(new Date("2026-10-25T01:30:00Z"))).toBe("25.10.2026");
|
|
});
|
|
});
|
|
|
|
// Issue #66 (date-format sweep) — `dateShortSmart` / `dateWithWeekdaySmart`
|
|
// add the year only when it differs from "now"'s year, so a chart axis,
|
|
// history list, or timeline never reads a bare "19.02." for a reading
|
|
// from a prior year and mistakes it for this year's. "Now" is faked so
|
|
// the boundary is deterministic regardless of the day this suite runs.
|
|
describe("conditional-year formatters (#66)", () => {
|
|
const RIGHT_NOW = new Date("2026-07-10T12:00:00Z");
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(RIGHT_NOW);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("dateShortSmart omits the year for a date in the current year", () => {
|
|
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
|
|
const en = makeFormatters("en", undefined, "AUTO", "AUTO");
|
|
const thisYear = new Date("2026-02-19T10:00:00Z");
|
|
expect(de.dateShortSmart(thisYear)).toBe("19.02.");
|
|
expect(en.dateShortSmart(thisYear)).not.toContain("2026");
|
|
});
|
|
|
|
it("dateShortSmart includes the year for a date from a prior year", () => {
|
|
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
|
|
const en = makeFormatters("en", undefined, "AUTO", "AUTO");
|
|
// A December-of-last-year date — the exact regression this guards.
|
|
const lastDecember = new Date("2025-12-16T10:00:00Z");
|
|
expect(de.dateShortSmart(lastDecember)).toBe("16.12.2025");
|
|
expect(en.dateShortSmart(lastDecember)).toContain("2025");
|
|
});
|
|
|
|
it("dateWithWeekdaySmart mirrors the same boundary", () => {
|
|
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
|
|
const thisYear = new Date("2026-02-19T10:00:00Z");
|
|
const lastDecember = new Date("2025-12-16T10:00:00Z");
|
|
expect(de.dateWithWeekdaySmart(thisYear)).not.toContain("2026");
|
|
expect(de.dateWithWeekdaySmart(lastDecember)).toContain("2025");
|
|
});
|
|
|
|
it("compares the year in the formatter's own timezone, not the host's", () => {
|
|
// 2026-01-01T00:30Z is still 2025-12-31 in America/New_York (UTC-5) —
|
|
// the profile-tz formatter must agree with what it actually prints,
|
|
// not with a UTC or host-local read of "now"/the value.
|
|
const nyFmt = makeFormatters("en", "America/New_York", "AUTO", "AUTO");
|
|
const newYearUtcEve = new Date("2026-01-01T00:30:00Z");
|
|
// RIGHT_NOW (2026-07-10) is year 2026 in New York too, so a value
|
|
// that prints as 2025 in New York must carry the year.
|
|
expect(nyFmt.dateShortSmart(newYearUtcEve)).toContain("2025");
|
|
});
|
|
});
|
|
});
|
|
|
|
// v1.25.3 — the time-format preference must reach every hour/minute renderer,
|
|
// not only the `makeFormatters().time` path. Some surfaces build their own
|
|
// `Intl.DateTimeFormat` because they render in the browser timezone (chart
|
|
// axes) or combine weekday + time in one label (workout list/detail). They now
|
|
// spread `hourCycleOptions(preference)` into the options. These tests pin both
|
|
// the helper contract and the exact call-site pattern, so a future renderer
|
|
// that drops the spread (re-introducing AM/PM under H24) fails here.
|
|
describe("hourCycleOptions", () => {
|
|
it("AUTO contributes nothing (locale default applies)", () => {
|
|
expect(hourCycleOptions("AUTO")).toEqual({});
|
|
});
|
|
|
|
it("H12 forces a 12-hour clock", () => {
|
|
expect(hourCycleOptions("H12")).toEqual({ hour12: true });
|
|
});
|
|
|
|
it("H24 forces the 24-hour h23 cycle", () => {
|
|
expect(hourCycleOptions("H24")).toEqual({ hourCycle: "h23" });
|
|
});
|
|
|
|
// The shared call-site shape used by sleep-hypnogram, workout-list and
|
|
// workout-detail: an `en-US` formatter (AM/PM by default) with hour+minute.
|
|
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
|
|
const buildLabel = (preference: Parameters<typeof hourCycleOptions>[0]) =>
|
|
new Intl.DateTimeFormat("en-US", {
|
|
timeZone: "Europe/Berlin",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
...hourCycleOptions(preference),
|
|
}).format(sample);
|
|
|
|
it("an en-US hour/minute formatter drops AM/PM under H24", () => {
|
|
const label = buildLabel("H24");
|
|
expect(label).not.toMatch(/[AP]M/i);
|
|
expect(label).toBe("16:30");
|
|
});
|
|
|
|
it("an en-US hour/minute formatter keeps AM/PM under H12", () => {
|
|
expect(buildLabel("H12")).toMatch(/PM/);
|
|
});
|
|
|
|
it("an en-US hour/minute formatter follows the locale (AM/PM) under AUTO", () => {
|
|
expect(buildLabel("AUTO")).toMatch(/PM/);
|
|
});
|
|
});
|