| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | formatDayAwareTimestamp, |
| 5 | formatElapsedDurationLabel, |
| 6 | formatExpiresInLabel, |
| 7 | formatRelativeTime, |
| 8 | formatRelativeTimeLabel, |
| 9 | formatRelativeTimeUntil, |
| 10 | formatRelativeTimeUntilLabel, |
| 11 | formatShortTimestamp, |
| 12 | formatTimestamp, |
| 13 | formatUpcomingTimestamp, |
| 14 | getRelativeTimeState, |
| 15 | resolveTimestampLocale, |
| 16 | } from "./timestampFormat"; |
| 17 | |
| 18 | describe("resolveTimestampLocale", () => { |
| 19 | it("defers to the runtime default when the host reports no locale", () => { |
| 20 | expect(resolveTimestampLocale(null)).toBeUndefined(); |
| 21 | expect(resolveTimestampLocale(undefined)).toBeUndefined(); |
| 22 | expect(resolveTimestampLocale(" ")).toBeUndefined(); |
| 23 | }); |
| 24 | |
| 25 | it("uses a BCP-47 tag reported by the host", () => { |
| 26 | expect(resolveTimestampLocale("en-GB")).toBe("en-GB"); |
| 27 | }); |
| 28 | |
| 29 | it("defers to the runtime default rather than throwing on an unusable tag", () => { |
| 30 | // The desktop bridge normalizes POSIX identifiers before reporting them, so |
| 31 | // anything Intl still rejects here falls back instead of breaking every |
| 32 | // timestamp in the UI. |
| 33 | expect(resolveTimestampLocale("not a locale")).toBeUndefined(); |
| 34 | expect(resolveTimestampLocale("en_GB")).toBeUndefined(); |
| 35 | }); |
| 36 | }); |
| 37 | |
| 38 | describe("formatShortTimestamp", () => { |
| 39 | afterEach(() => { |
| 40 | vi.unstubAllGlobals(); |
| 41 | vi.resetModules(); |
| 42 | }); |
| 43 | |
| 44 | it.each([ |
| 45 | ["en-GB", "15:44"], |
| 46 | ["en-US", "3:44 PM"], |
| 47 | ])("honors %s and the explicit hour-cycle settings", async (locale, localTime) => { |
| 48 | vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => locale } }); |
| 49 | vi.resetModules(); |
| 50 | const { formatShortTimestamp: format } = await import("./timestampFormat"); |
| 51 | const date = new Date(2026, 3, 7, 15, 44).toISOString(); |
| 52 | // ICU can separate the day period with a narrow no-break space. |
| 53 | expect(format(date, "locale").replace(/[  ]/g, " ")).toBe(localTime); |
| 54 | expect(format(date, "12-hour").replace(/[  ]/g, " ")).toMatch(/^3:44 [ap]m$/i); |
| 55 | expect(format(date, "24-hour")).toBe("15:44"); |
| 56 | }); |
| 57 | }); |
| 58 | |
| 59 | describe("resolveWeekStartsOn", () => { |
| 60 | it.each([ |
| 61 | ["en-US", 0], |
| 62 | ["en-GB", 1], |
| 63 | ["pl-PL", 1], |
| 64 | ["ar-EG", 6], |
| 65 | ])("starts the %s week on weekday %i", async (locale, weekday) => { |
| 66 | const { resolveWeekStartsOn } = await import("./timestampFormat"); |
| 67 | expect(resolveWeekStartsOn(locale)).toBe(weekday); |
| 68 | }); |
| 69 | |
| 70 | it("leaves the default to the caller for a malformed locale", async () => { |
| 71 | const { resolveWeekStartsOn } = await import("./timestampFormat"); |
| 72 | expect(resolveWeekStartsOn("not a locale")).toBeUndefined(); |
| 73 | }); |
| 74 | |
| 75 | it("follows the locale the desktop host reports", async () => { |
| 76 | vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } }); |
| 77 | vi.resetModules(); |
| 78 | const { weekStartsOn } = await import("./timestampFormat"); |
| 79 | expect(weekStartsOn).toBe(1); |
| 80 | vi.unstubAllGlobals(); |
| 81 | vi.resetModules(); |
| 82 | }); |
| 83 | }); |
| 84 | |
| 85 | describe("formatChatTimestampTooltip", () => { |
| 86 | afterEach(() => { |
| 87 | vi.restoreAllMocks(); |
| 88 | vi.resetModules(); |
| 89 | }); |
| 90 | |
| 91 | it.each(["de-DE", "it-IT"])("keeps the English date label in a %s runtime", async (locale) => { |
| 92 | const DateTimeFormat = Intl.DateTimeFormat; |
| 93 | vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locales, options) { |
| 94 | return new DateTimeFormat(locales ?? locale, options); |
| 95 | }); |
| 96 | vi.resetModules(); |
| 97 | const { formatChatTimestampTooltip: format } = await import("./timestampFormat"); |
| 98 | const date = new Date(2026, 5, 4, 14, 4).toISOString(); |
| 99 | |
| 100 | expect(format(date, "24-hour")).toBe("14:04, 4th June 2026"); |
| 101 | }); |
| 102 | }); |
| 103 | |
| 104 | describe("formatRelativeTimeUntilLabel", () => { |
| 105 | beforeEach(() => { |
| 106 | vi.useFakeTimers(); |
| 107 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); |
| 108 | }); |
| 109 | |
| 110 | afterEach(() => { |
| 111 | vi.useRealTimers(); |
| 112 | }); |
| 113 | |
| 114 | it("returns Expired when the instant is in the past", () => { |
| 115 | expect(formatRelativeTimeUntilLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); |
| 116 | }); |
| 117 | |
| 118 | it("formats seconds remaining", () => { |
| 119 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); |
| 120 | }); |
| 121 | |
| 122 | it("formats minutes remaining", () => { |
| 123 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); |
| 124 | }); |
| 125 | |
| 126 | it("formats hours remaining", () => { |
| 127 | expect(formatRelativeTimeUntilLabel("2026-04-07T18:00:00.000Z")).toBe("6h left"); |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | describe("formatExpiresInLabel", () => { |
| 132 | beforeEach(() => { |
| 133 | vi.useFakeTimers(); |
| 134 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); |
| 135 | }); |
| 136 | |
| 137 | afterEach(() => { |
| 138 | vi.useRealTimers(); |
| 139 | }); |
| 140 | |
| 141 | it("returns Expired when the instant is in the past", () => { |
| 142 | expect(formatExpiresInLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); |
| 143 | }); |
| 144 | |
| 145 | it("uses sub-minute second count", () => { |
| 146 | expect(formatExpiresInLabel("2026-04-07T12:00:45.000Z")).toBe("Expires in 45s"); |
| 147 | }); |
| 148 | |
| 149 | it("uses minutes and seconds under one hour", () => { |
| 150 | expect(formatExpiresInLabel("2026-04-07T12:04:12.000Z")).toBe("Expires in 4m 12s"); |
| 151 | expect(formatExpiresInLabel("2026-04-07T12:15:00.000Z")).toBe("Expires in 15m"); |
| 152 | }); |
| 153 | |
| 154 | it("uses hours with minute and second remainder", () => { |
| 155 | expect(formatExpiresInLabel("2026-04-07T14:02:03.000Z")).toBe("Expires in 2h 2m 3s"); |
| 156 | expect(formatExpiresInLabel("2026-04-07T18:00:00.000Z")).toBe("Expires in 6h"); |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | describe("formatDayAwareTimestamp", () => { |
| 161 | // Instants are built with the local-time Date constructor so the |
| 162 | // calendar-day boundaries hold in any test timezone or locale. |
| 163 | const iso = (y: number, monthIndex: number, d: number, h: number, mi: number) => |
| 164 | new Date(y, monthIndex, d, h, mi).toISOString(); |
| 165 | const now = new Date(2026, 7, 14, 12, 0).getTime(); |
| 166 | const time = (isoDate: string) => formatShortTimestamp(isoDate, "12-hour"); |
| 167 | |
| 168 | it("shows time only for today", () => { |
| 169 | const messageAt = iso(2026, 7, 14, 9, 30); |
| 170 | expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(time(messageAt)); |
| 171 | }); |
| 172 | |
| 173 | it("labels the previous calendar day as yesterday even when under 24h old", () => { |
| 174 | const messageAt = iso(2026, 7, 13, 23, 30); |
| 175 | const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime(); |
| 176 | expect(formatDayAwareTimestamp(messageAt, "12-hour", justPastMidnight)).toBe( |
| 177 | `yesterday at ${time(messageAt)}`, |
| 178 | ); |
| 179 | }); |
| 180 | |
| 181 | it("prefixes older same-year messages with the numeric date", () => { |
| 182 | const messageAt = iso(2026, 7, 12, 12, 34); |
| 183 | const datePart = new Intl.DateTimeFormat(undefined, { |
| 184 | month: "numeric", |
| 185 | day: "numeric", |
| 186 | }).format(new Date(messageAt)); |
| 187 | expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( |
| 188 | `${datePart} ${time(messageAt)}`, |
| 189 | ); |
| 190 | }); |
| 191 | |
| 192 | it("includes the year once the calendar year differs", () => { |
| 193 | const messageAt = iso(2025, 11, 31, 18, 0); |
| 194 | const datePart = new Intl.DateTimeFormat(undefined, { |
| 195 | month: "numeric", |
| 196 | day: "numeric", |
| 197 | year: "numeric", |
| 198 | }).format(new Date(messageAt)); |
| 199 | expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( |
| 200 | `${datePart} ${time(messageAt)}`, |
| 201 | ); |
| 202 | }); |
| 203 | |
| 204 | it("uses the host locale for both the numeric date and wall-clock time", async () => { |
| 205 | vi.stubGlobal("window", { |
| 206 | desktopBridge: { getSystemLocale: () => "en-GB" }, |
| 207 | }); |
| 208 | vi.resetModules(); |
| 209 | |
| 210 | const { formatDayAwareTimestamp: formatWithHostLocale } = await import("./timestampFormat"); |
| 211 | const messageAt = iso(2026, 7, 12, 15, 44); |
| 212 | |
| 213 | expect(formatWithHostLocale(messageAt, "locale", now)).toBe("12/08 15:44"); |
| 214 | |
| 215 | vi.unstubAllGlobals(); |
| 216 | }); |
| 217 | |
| 218 | it("returns an empty string for invalid input", () => { |
| 219 | expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe(""); |
| 220 | }); |
| 221 | }); |
| 222 | |
| 223 | describe("formatUpcomingTimestamp", () => { |
| 224 | const now = new Date(2026, 7, 14, 12, 0).getTime(); |
| 225 | |
| 226 | it.each([ |
| 227 | [14, ""], |
| 228 | [15, "tomorrow at "], |
| 229 | [13, "yesterday at "], |
| 230 | ])("keeps the reset day visible for day %i", (day, prefix) => { |
| 231 | const resetAt = new Date(2026, 7, day, 14, 30).toISOString(); |
| 232 | expect(formatUpcomingTimestamp(resetAt, "12-hour", now)).toBe( |
| 233 | `${prefix}${formatShortTimestamp(resetAt, "12-hour")}`, |
| 234 | ); |
| 235 | }); |
| 236 | |
| 237 | it("preserves the date of an older reset in the transcript", () => { |
| 238 | const resetAt = new Date(2026, 7, 12, 14, 30).toISOString(); |
| 239 | expect(formatUpcomingTimestamp(resetAt, "12-hour", now)).toBe( |
| 240 | formatDayAwareTimestamp(resetAt, "12-hour", now), |
| 241 | ); |
| 242 | }); |
| 243 | }); |
| 244 | |
| 245 | describe("invalid timestamp inputs", () => { |
| 246 | it("returns an empty timestamp instead of throwing", () => { |
| 247 | expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); |
| 248 | }); |
| 249 | |
| 250 | it("returns an empty short timestamp instead of throwing", () => { |
| 251 | expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); |
| 252 | }); |
| 253 | |
| 254 | it("returns an empty relative time label instead of a NaN label", () => { |
| 255 | expect(formatRelativeTime("not-a-date")).toBeNull(); |
| 256 | expect(formatRelativeTimeLabel("not-a-date")).toBe(""); |
| 257 | }); |
| 258 | |
| 259 | it("distinguishes missing and invalid relative time state", () => { |
| 260 | expect(getRelativeTimeState(null)).toEqual({ status: "missing" }); |
| 261 | expect(getRelativeTimeState("not-a-date")).toEqual({ status: "invalid" }); |
| 262 | }); |
| 263 | |
| 264 | it("returns an empty elapsed duration instead of a NaN label", () => { |
| 265 | expect(formatElapsedDurationLabel("not-a-date")).toBe(""); |
| 266 | }); |
| 267 | |
| 268 | it("returns an empty relative time until label instead of a NaN label", () => { |
| 269 | expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); |
| 270 | expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); |
| 271 | }); |
| 272 | |
| 273 | it("returns an empty expires-in label instead of a NaN label", () => { |
| 274 | expect(formatExpiresInLabel("not-a-date")).toBe(""); |
| 275 | }); |
| 276 | }); |
| 277 | |
| 278 | describe("getRelativeTimeState", () => { |
| 279 | beforeEach(() => { |
| 280 | vi.useFakeTimers(); |
| 281 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); |
| 282 | }); |
| 283 | |
| 284 | afterEach(() => { |
| 285 | vi.useRealTimers(); |
| 286 | }); |
| 287 | |
| 288 | it("returns relative parts for valid timestamps", () => { |
| 289 | expect(getRelativeTimeState("2026-04-07T11:45:00.000Z")).toEqual({ |
| 290 | status: "relative", |
| 291 | value: "15m", |
| 292 | suffix: "ago", |
| 293 | }); |
| 294 | }); |
| 295 | }); |
| 296 | |
| 297 | describe("formatElapsedDurationLabel", () => { |
| 298 | beforeEach(() => { |
| 299 | vi.useFakeTimers(); |
| 300 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); |
| 301 | }); |
| 302 | |
| 303 | afterEach(() => { |
| 304 | vi.useRealTimers(); |
| 305 | }); |
| 306 | |
| 307 | it("returns just now when the instant is current or in the future", () => { |
| 308 | expect(formatElapsedDurationLabel("2026-04-07T12:00:00.000Z")).toBe("just now"); |
| 309 | expect(formatElapsedDurationLabel("2026-04-07T12:01:00.000Z")).toBe("just now"); |
| 310 | }); |
| 311 | |
| 312 | it("formats seconds, minutes, hours, and days", () => { |
| 313 | expect(formatElapsedDurationLabel("2026-04-07T11:59:45.000Z")).toBe("15s"); |
| 314 | expect(formatElapsedDurationLabel("2026-04-07T11:45:00.000Z")).toBe("15m"); |
| 315 | expect(formatElapsedDurationLabel("2026-04-07T06:00:00.000Z")).toBe("6h"); |
| 316 | expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d"); |
| 317 | }); |
| 318 | }); |