1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
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
18describe("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
38describe("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
59describe("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
85describe("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
104describe("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
131describe("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
160describe("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
223describe("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
245describe("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
278describe("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
297describe("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});