/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { backfillClockLabelColors, buildClocksRowAriaLabel, buildLocalizedTimeZoneMap, buildNextClockZones, decorateDefaultZones, formatDateTimeAttr, formatTime, getCityAbbreviation, getCityFromTimeZone, getClockFormDerivedState, getDefaultTimeZones, getLocalizedTimeZoneName, getTimeZoneAbbreviation, isValidTimeZone, isValidPaletteName, parseClockZonesPref, removeClockZoneAtIndex, shouldUse12HourTimeFormat, } from "content-src/components/Widgets/Clocks/ClocksHelpers"; const originalResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; function withLocalTz(tz, fn) { const stubbed = Intl.DateTimeFormat.prototype.resolvedOptions; Intl.DateTimeFormat.prototype.resolvedOptions = function () { const opts = originalResolvedOptions.call(this); return { ...opts, timeZone: tz }; }; try { fn(); } finally { Intl.DateTimeFormat.prototype.resolvedOptions = stubbed; } } describe("getDefaultTimeZones", () => { it("returns IANA zone strings without decoration", () => { withLocalTz("Europe/Paris", () => { const result = getDefaultTimeZones(); expect(result).toEqual([ "Europe/Paris", "Europe/Berlin", "Australia/Sydney", "America/New_York", ]); expect(result.every(z => typeof z === "string")).toBe(true); }); }); it("dedupes when the local TZ matches a fixed zone (Berlin)", () => { withLocalTz("Europe/Berlin", () => { expect(getDefaultTimeZones()).toEqual([ "Europe/Berlin", "Australia/Sydney", "America/New_York", "America/Los_Angeles", ]); }); }); it("dedupes when the local TZ matches a fixed zone (New York)", () => { withLocalTz("America/New_York", () => { expect(getDefaultTimeZones()).toEqual([ "America/New_York", "Europe/Berlin", "Australia/Sydney", "America/Los_Angeles", ]); }); }); it("always returns exactly four zones", () => { withLocalTz("Asia/Tokyo", () => { expect(getDefaultTimeZones()).toHaveLength(4); }); withLocalTz("Europe/Berlin", () => { expect(getDefaultTimeZones()).toHaveLength(4); }); }); it("falls back to the fixed set when Intl throws", () => { const original = Intl.DateTimeFormat; Intl.DateTimeFormat = function () { throw new Error("Intl unavailable"); }; try { const result = getDefaultTimeZones(); expect(result).toEqual([ "Europe/Berlin", "Australia/Sydney", "America/New_York", "America/Los_Angeles", ]); } finally { Intl.DateTimeFormat = original; } }); }); describe("decorateDefaultZones", () => { it("returns null label and labelColor for all zones", () => { const decorated = decorateDefaultZones([ "Europe/Berlin", "Australia/Sydney", ]); expect(decorated).toHaveLength(2); expect(decorated[0]).toEqual({ timeZone: "Europe/Berlin", label: null, labelColor: null, }); expect(decorated[1]).toEqual({ timeZone: "Australia/Sydney", label: null, labelColor: null, }); }); it("returns an empty array when given an empty array", () => { expect(decorateDefaultZones([])).toEqual([]); }); }); describe("backfillClockLabelColors", () => { it("adds colors only for labeled clocks that are missing one", () => { const randomStub = jest.spyOn(Math, "random").mockReturnValue(0); try { expect( backfillClockLabelColors([ { timeZone: "America/New_York", city: "Boston", label: "Office", labelColor: null, }, { timeZone: "Europe/Berlin", city: "Berlin", label: "Home", labelColor: "purple", }, { timeZone: "Asia/Tokyo", city: "Tokyo", label: null, labelColor: null, }, ]) ).toEqual([ { timeZone: "America/New_York", city: "Boston", label: "Office", labelColor: "cyan", }, { timeZone: "Europe/Berlin", city: "Berlin", label: "Home", labelColor: "purple", }, { timeZone: "Asia/Tokyo", city: "Tokyo", label: null, labelColor: null, }, ]); } finally { randomStub.mockRestore(); } }); }); describe("isValidTimeZone", () => { it("accepts valid IANA time zones and rejects invalid values", () => { expect(isValidTimeZone("Europe/Berlin")).toBe(true); expect(isValidTimeZone("Invalid/Zone")).toBe(false); expect(isValidTimeZone("")).toBe(false); expect(isValidTimeZone(null)).toBe(false); }); }); describe("getClockFormDerivedState", () => { const supportedTimeZones = [ "Europe/Berlin", "Australia/Sydney", "America/New_York", ]; it("resolves a selected time zone and enables save when a slot is open", () => { expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "Ber", clockSelectedTimeZone: "Europe/Berlin", isEditingClock: false, supportedTimeZones, }) ).toMatchObject({ canAddSelectedClock: true, resolvedClockTimeZone: "Europe/Berlin", showLocationDropdown: false, }); }); it("resolves exact city queries without requiring a selected result", () => { expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "new york", clockSelectedTimeZone: "", isEditingClock: false, supportedTimeZones, }) ).toMatchObject({ canAddSelectedClock: true, resolvedClockTimeZone: "America/New_York", showLocationDropdown: true, }); }); it("shows filtered results for unresolved partial queries", () => { expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "syd", clockSelectedTimeZone: "", isEditingClock: false, supportedTimeZones, }) ).toMatchObject({ canAddSelectedClock: false, filteredTimeZones: ["Australia/Sydney"], resolvedClockTimeZone: "", showLocationDropdown: true, }); }); it("keeps the dropdown open with no filtered results for an unmatched query", () => { expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "zzz", clockSelectedTimeZone: "", isEditingClock: false, supportedTimeZones, }) ).toMatchObject({ canAddSelectedClock: false, filteredTimeZones: [], resolvedClockTimeZone: "", showLocationDropdown: true, }); }); it("allows edits even when no add slots are open", () => { expect( getClockFormDerivedState({ canAddClock: false, clockSearchQuery: "Berlin", clockSelectedTimeZone: "", isEditingClock: true, supportedTimeZones, }).canAddSelectedClock ).toBe(true); }); it("filters by the localized zone name when a map is provided", () => { const localizedTimeZoneMap = new Map([ ["Europe/Berlin", "Central European Time"], ["Australia/Sydney", "Eastern Australia Time"], ["America/New_York", "Eastern Time"], ]); expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "central european", clockSelectedTimeZone: "", isEditingClock: false, localizedTimeZoneMap, supportedTimeZones, }).filteredTimeZones ).toEqual(["Europe/Berlin"]); }); it("resolves an exact-match localized name when only one zone produces it", () => { const localizedTimeZoneMap = new Map([ ["America/New_York", "Eastern Time"], ]); expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "Eastern Time", clockSelectedTimeZone: "", isEditingClock: false, localizedTimeZoneMap, supportedTimeZones, }) ).toMatchObject({ resolvedClockTimeZone: "America/New_York", canAddSelectedClock: true, }); }); it("does not auto-resolve an ambiguous localized name; dropdown stays open", () => { const supportedWithDuplicates = [ "America/Detroit", "America/New_York", "America/Toronto", ]; const localizedTimeZoneMap = new Map([ ["America/Detroit", "Eastern Time"], ["America/New_York", "Eastern Time"], ["America/Toronto", "Eastern Time"], ]); expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "Eastern Time", clockSelectedTimeZone: "", isEditingClock: false, localizedTimeZoneMap, supportedTimeZones: supportedWithDuplicates, }) ).toMatchObject({ resolvedClockTimeZone: "", showLocationDropdown: true, canAddSelectedClock: false, }); }); it("still resolves the exact IANA id or city even if the localized name is ambiguous", () => { const supportedWithDuplicates = [ "America/Detroit", "America/New_York", "America/Toronto", ]; const localizedTimeZoneMap = new Map([ ["America/Detroit", "Eastern Time"], ["America/New_York", "Eastern Time"], ["America/Toronto", "Eastern Time"], ]); expect( getClockFormDerivedState({ canAddClock: true, clockSearchQuery: "new york", clockSelectedTimeZone: "", isEditingClock: false, localizedTimeZoneMap, supportedTimeZones: supportedWithDuplicates, }).resolvedClockTimeZone ).toBe("America/New_York"); }); }); describe("getLocalizedTimeZoneName", () => { // Single-result Intl.DateTimeFormat mock for unit tests that only // need one call per assertion; takes the parts[] array directly. const withMockedIntl = (parts, fn) => { const Original = Intl.DateTimeFormat; Intl.DateTimeFormat = function () { return { formatToParts: () => parts }; }; try { fn(); } finally { Intl.DateTimeFormat = Original; } }; it("returns the timeZoneName part from Intl.DateTimeFormat.formatToParts", () => { withMockedIntl( [ { type: "literal", value: "12:34 " }, { type: "timeZoneName", value: "Eastern Time" }, ], () => { expect(getLocalizedTimeZoneName("America/New_York", "en-US")).toBe( "Eastern Time" ); } ); }); it("falls back to the IANA id when no timeZoneName part is present", () => { withMockedIntl([{ type: "literal", value: "noise" }], () => { expect(getLocalizedTimeZoneName("Europe/Berlin", "en-US")).toBe( "Europe/Berlin" ); }); }); it("falls back to the IANA id when Intl.DateTimeFormat throws", () => { const Original = Intl.DateTimeFormat; Intl.DateTimeFormat = function () { throw new Error("unsupported option"); }; try { expect(getLocalizedTimeZoneName("America/New_York", "en-US")).toBe( "America/New_York" ); } finally { Intl.DateTimeFormat = Original; } }); }); describe("buildLocalizedTimeZoneMap", () => { it("returns a Map keyed by the input zones", () => { const map = buildLocalizedTimeZoneMap( ["Europe/Berlin", "America/New_York"], "en-US" ); expect(map).toBeInstanceOf(Map); expect(map.size).toBe(2); expect(map.has("Europe/Berlin")).toBe(true); expect(map.has("America/New_York")).toBe(true); }); it("returns an empty map for an empty list", () => { expect(buildLocalizedTimeZoneMap([], "en-US").size).toBe(0); }); }); describe("buildNextClockZones", () => { const berlin = { timeZone: "Europe/Berlin", label: null, labelColor: null }; const sydney = { timeZone: "Australia/Sydney", label: null, labelColor: null, }; const tokyo = { timeZone: "Asia/Tokyo", label: null, labelColor: null }; it("appends a new zone when editingClockIndex is null", () => { expect(buildNextClockZones([berlin, sydney], null, tokyo)).toEqual([ berlin, sydney, tokyo, ]); }); it("replaces the zone at the given index", () => { expect(buildNextClockZones([berlin, sydney], 0, tokyo)).toEqual([ tokyo, sydney, ]); expect(buildNextClockZones([berlin, sydney], 1, tokyo)).toEqual([ berlin, tokyo, ]); }); it("does not mutate the original array", () => { const zones = [berlin, sydney]; buildNextClockZones(zones, null, tokyo); expect(zones).toHaveLength(2); }); }); describe("removeClockZoneAtIndex", () => { const berlin = { timeZone: "Europe/Berlin", label: null, labelColor: null }; const sydney = { timeZone: "Australia/Sydney", label: null, labelColor: null, }; const tokyo = { timeZone: "Asia/Tokyo", label: null, labelColor: null }; it("removes the element at the given index", () => { expect(removeClockZoneAtIndex([berlin, sydney, tokyo], 0)).toEqual([ sydney, tokyo, ]); expect(removeClockZoneAtIndex([berlin, sydney, tokyo], 1)).toEqual([ berlin, tokyo, ]); expect(removeClockZoneAtIndex([berlin, sydney, tokyo], 2)).toEqual([ berlin, sydney, ]); }); it("does not mutate the original array", () => { const zones = [berlin, sydney]; removeClockZoneAtIndex(zones, 0); expect(zones).toHaveLength(2); }); }); describe("parseClockZonesPref", () => { it("preserves stored clock order, labels, colors, and duplicate zones", () => { const prefValue = JSON.stringify([ { timeZone: "America/New_York", city: "Boston", label: "Office", labelColor: "cyan", }, { timeZone: "America/New_York", label: "Family", labelColor: "green", }, { timeZone: "Asia/Tokyo", label: "", labelColor: "not-a-palette", }, ]); expect(parseClockZonesPref(prefValue)).toEqual([ { timeZone: "America/New_York", city: "Boston", label: "Office", labelColor: "cyan", }, { timeZone: "America/New_York", label: "Family", labelColor: "green", }, { timeZone: "Asia/Tokyo", label: null, labelColor: null, }, ]); }); it("accepts string time zone entries", () => { expect(parseClockZonesPref(JSON.stringify(["Europe/Berlin"]))).toEqual([ { timeZone: "Europe/Berlin", label: null, labelColor: null, }, ]); }); it("drops invalid entries and caps the result at four clocks", () => { const prefValue = JSON.stringify([ { timeZone: "Invalid/NotAZone" }, { timeZone: "Europe/Berlin" }, { timeZone: "Australia/Sydney" }, { timeZone: "America/New_York" }, { timeZone: "America/Los_Angeles" }, { timeZone: "Asia/Tokyo" }, ]); expect(parseClockZonesPref(prefValue).map(clock => clock.timeZone)).toEqual( [ "Europe/Berlin", "Australia/Sydney", "America/New_York", "America/Los_Angeles", ] ); }); it("returns null for missing, malformed, or empty pref data", () => { expect(parseClockZonesPref("")).toBeNull(); expect(parseClockZonesPref("{")).toBeNull(); expect( parseClockZonesPref(JSON.stringify({ timeZone: "Europe/Berlin" })) ).toBeNull(); expect(parseClockZonesPref(JSON.stringify([{ timeZone: "" }]))).toBeNull(); }); }); describe("shouldUse12HourTimeFormat", () => { it("returns true when prefValue is '12'", () => { expect( shouldUse12HourTimeFormat({ prefValue: "12", locale: "en-GB" }) ).toBe(true); }); it("returns false when prefValue is '24'", () => { expect( shouldUse12HourTimeFormat({ prefValue: "24", locale: "en-US" }) ).toBe(false); }); it("falls back to locale default when prefValue is empty", () => { expect(shouldUse12HourTimeFormat({ prefValue: "", locale: "en-US" })).toBe( true ); expect(shouldUse12HourTimeFormat({ prefValue: "", locale: "en-GB" })).toBe( false ); }); it("treats undefined prefValue the same as empty", () => { expect( shouldUse12HourTimeFormat({ prefValue: undefined, locale: "en-US" }) ).toBe(true); }); }); describe("getCityFromTimeZone", () => { it("returns the last IANA segment with underscores as spaces", () => { expect(getCityFromTimeZone("America/Los_Angeles")).toBe("Los Angeles"); expect(getCityFromTimeZone("Europe/Berlin")).toBe("Berlin"); expect(getCityFromTimeZone("Asia/Tokyo")).toBe("Tokyo"); }); it("handles single-segment and empty inputs", () => { expect(getCityFromTimeZone("UTC")).toBe("UTC"); expect(getCityFromTimeZone("")).toBe(""); expect(getCityFromTimeZone(null)).toBe(""); expect(getCityFromTimeZone(undefined)).toBe(""); }); }); describe("getCityAbbreviation", () => { it("returns the IATA code for cities in the curated map", () => { expect(getCityAbbreviation("New York")).toBe("NYC"); expect(getCityAbbreviation("Los Angeles")).toBe("LAX"); expect(getCityAbbreviation("Tokyo")).toBe("TYO"); expect(getCityAbbreviation("Hong Kong")).toBe("HKG"); expect(getCityAbbreviation("Zurich")).toBe("ZRH"); // Regression: Seoul's IATA *city* code is SEL; ICN is Incheon airport // only. Mapping to ICN would display the airport code for a Seoul clock. expect(getCityAbbreviation("Seoul")).toBe("SEL"); }); it("falls back to first-3-chars-uppercased for unknown cities", () => { expect(getCityAbbreviation("Berlin")).toBe("BER"); expect(getCityAbbreviation("Paris")).toBe("PAR"); expect(getCityAbbreviation("Sydney")).toBe("SYD"); expect(getCityAbbreviation("Ulaanbaatar")).toBe("ULA"); }); it("strips whitespace from multi-word cities before slicing", () => { // Ensures multi-word IANA cities not in the map don't emit a trailing // space (e.g. "St Johns" would slice to "ST " without this guard). expect(getCityAbbreviation("St Johns")).toBe("STJ"); expect(getCityAbbreviation("Addis Ababa")).toBe("ADD"); expect(getCityAbbreviation("Cape Town")).toBe("CAP"); }); it("resolves both legacy and canonical IANA city names to the same IATA code", () => { // tzdata renamed Kiev->Kyiv, Calcutta->Kolkata, Saigon->Ho Chi Minh; // depending on OS tzdata version, either spelling can surface from // Intl.DateTimeFormat().resolvedOptions().timeZone, so the map has both. expect(getCityAbbreviation("Kiev")).toBe("IEV"); expect(getCityAbbreviation("Kyiv")).toBe("IEV"); expect(getCityAbbreviation("Calcutta")).toBe("CCU"); expect(getCityAbbreviation("Kolkata")).toBe("CCU"); expect(getCityAbbreviation("Saigon")).toBe("SGN"); expect(getCityAbbreviation("Ho Chi Minh")).toBe("SGN"); }); it("handles short and empty inputs", () => { expect(getCityAbbreviation("Rio")).toBe("RIO"); expect(getCityAbbreviation("NY")).toBe("NY"); expect(getCityAbbreviation("")).toBe(""); expect(getCityAbbreviation(null)).toBe(""); }); }); describe("isValidPaletteName", () => { it("accepts all known palette names", () => { const knownNames = [ "cyan", "green", "yellow", "purple", "red", "orange", "blue", "pink", "violet", "neutral", ]; knownNames.forEach(name => { expect(isValidPaletteName(name)).toBe(true); }); }); it("rejects unknown, malformed, or non-string inputs", () => { expect(isValidPaletteName("not-a-palette")).toBe(false); expect(isValidPaletteName("violet extra-class")).toBe(false); expect(isValidPaletteName("")).toBe(false); expect(isValidPaletteName(null)).toBe(false); expect(isValidPaletteName(undefined)).toBe(false); expect(isValidPaletteName(42)).toBe(false); }); }); describe("getTimeZoneAbbreviation", () => { it("falls back to the zone id for invalid zones", () => { const bogus = "Invalid/NotAZone"; expect(getTimeZoneAbbreviation(bogus, "en-US")).toBe(bogus); }); it("honours the provided date for DST-observing zones", () => { // Berlin is CET (winter) and CEST (summer). ICU versions may render // either the literal "CET/CEST" or "GMT+1/GMT+2"; accept both. const winter = new Date("2026-01-15T12:00:00Z"); const summer = new Date("2026-07-15T12:00:00Z"); expect(getTimeZoneAbbreviation("Europe/Berlin", "en-US", winter)).toMatch( /CET|GMT\+1/ ); expect(getTimeZoneAbbreviation("Europe/Berlin", "en-US", summer)).toMatch( /CEST|GMT\+2/ ); }); }); describe("formatDateTimeAttr", () => { it("formats the datetime value in the clock time zone", () => { expect( formatDateTimeAttr(new Date("2026-04-20T13:44:00Z"), "Asia/Tokyo") ).toBe("2026-04-20T22:44"); }); it("falls back to an ISO string when the time zone cannot be formatted", () => { const date = new Date("2026-04-20T13:44:00Z"); expect(formatDateTimeAttr(date, "Invalid/Zone")).toBe(date.toISOString()); }); }); describe("formatTime", () => { it("honours an explicit hour12=false override", () => { const date = new Date("2026-04-20T13:44:00Z"); // en-US defaults to 12h; forcing 24h should produce "15" (Berlin = +2). const result = formatTime(date, "Europe/Berlin", "en-US", false); expect(result).toMatch(/^15/); expect(result).not.toMatch(/AM|PM/i); }); it("honours an explicit hour12=true override", () => { const date = new Date("2026-04-20T13:44:00Z"); // en-GB defaults to 24h; forcing 12h should produce an AM/PM string. const result = formatTime(date, "Europe/Berlin", "en-GB", true); expect(result).toMatch(/AM|PM|am|pm/i); }); it("returns an empty string on invalid time zone", () => { const date = new Date("2026-04-20T13:44:00Z"); expect(formatTime(date, "Invalid/NotAZone", "en-US")).toBe(""); }); }); describe("buildClocksRowAriaLabel", () => { it("joins city, TZ, and time when all three are present", () => { expect(buildClocksRowAriaLabel("Berlin", "CET", "14:44")).toBe( "Berlin, CET, 14:44" ); }); it("drops only the time field when empty (pre-tick render)", () => { expect(buildClocksRowAriaLabel("Berlin", "CET", "")).toBe("Berlin, CET"); }); it("includes the label when present", () => { expect(buildClocksRowAriaLabel("Berlin", "CET", "14:44", "Home")).toBe( "Home, Berlin, CET, 14:44" ); }); });