@lotics/ui 20.0.1 → 20.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,335 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { resolveLocaleTag, en as enLocale, vi as viLocale } from "./locale";
3
- import {
4
- getDateLayout,
5
- getTimeLayout,
6
- timeSegmentsConfig,
7
- segmentsToTime,
8
- fieldOrder,
9
- to12h,
10
- from12h,
11
- typeDigit,
12
- incrementSegment,
13
- separatorAdvances,
14
- setHourField,
15
- withDayPeriod,
16
- displayValue,
17
- valueToSegments,
18
- segmentsToValue,
19
- isBufferEmpty,
20
- placeholderFor,
21
- } from "./date_segments";
22
-
23
- const empty = { year: null, month: null, day: null, hour: null, minute: null };
24
-
25
- describe("getDateLayout / fieldOrder", () => {
26
- it("derives m/d/y and 12h for en-US", () => {
27
- expect(fieldOrder(getDateLayout("en-US", false))).toEqual(["month", "day", "year"]);
28
- const dt = getDateLayout("en-US", true);
29
- expect(fieldOrder(dt)).toEqual(["month", "day", "year", "hour", "minute", "dayPeriod"]);
30
- expect(dt.hour12).toBe(true);
31
- });
32
-
33
- it("derives d/m/y, 24h, and time-first datetime for vi-VN", () => {
34
- expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
35
- const dt = getDateLayout("vi-VN", true);
36
- // Vietnamese leads with the time ("HH:mm dd/MM/yyyy") — we follow the locale.
37
- expect(fieldOrder(dt)).toEqual(["hour", "minute", "day", "month", "year"]);
38
- expect(dt.hour12).toBe(false);
39
- });
40
-
41
- it("derives d/m/y for en-GB and y/m/d for ja-JP", () => {
42
- expect(fieldOrder(getDateLayout("en-GB", false))).toEqual(["day", "month", "year"]);
43
- expect(fieldOrder(getDateLayout("ja-JP", false))).toEqual(["year", "month", "day"]);
44
- });
45
-
46
- it("emits literal separators between fields", () => {
47
- const literals = getDateLayout("en-US", false).segments.filter((s) => s.kind === "literal");
48
- expect(literals.length).toBeGreaterThan(0);
49
- });
50
- });
51
-
52
- describe("to12h / from12h", () => {
53
- it("maps 24h to 12h", () => {
54
- expect(to12h(0)).toEqual({ h12: 12, pm: false });
55
- expect(to12h(12)).toEqual({ h12: 12, pm: true });
56
- expect(to12h(13)).toEqual({ h12: 1, pm: true });
57
- expect(to12h(23)).toEqual({ h12: 11, pm: true });
58
- });
59
-
60
- it("maps 12h back to 24h", () => {
61
- expect(from12h(12, false)).toBe(0);
62
- expect(from12h(12, true)).toBe(12);
63
- expect(from12h(1, true)).toBe(13);
64
- expect(from12h(11, false)).toBe(11);
65
- });
66
- });
67
-
68
- describe("typeDigit", () => {
69
- it("waits for a second digit when one could still fit", () => {
70
- expect(typeDigit("month", "", "1", false)).toEqual({ text: "1", value: 1, complete: false });
71
- expect(typeDigit("day", "", "3", false)).toEqual({ text: "3", value: 3, complete: false });
72
- expect(typeDigit("minute", "", "5", false)).toEqual({ text: "5", value: 5, complete: false });
73
- });
74
-
75
- it("auto-advances when no second digit could keep it in range", () => {
76
- expect(typeDigit("month", "", "2", false)).toEqual({ text: "2", value: 2, complete: true });
77
- expect(typeDigit("day", "", "4", false)).toEqual({ text: "4", value: 4, complete: true });
78
- expect(typeDigit("minute", "", "6", false)).toEqual({ text: "6", value: 6, complete: true });
79
- expect(typeDigit("hour", "", "3", false)).toEqual({ text: "3", value: 3, complete: true });
80
- expect(typeDigit("hour", "", "2", true)).toEqual({ text: "2", value: 2, complete: true });
81
- });
82
-
83
- it("completes on the second digit", () => {
84
- expect(typeDigit("month", "1", "2", false)).toEqual({ text: "12", value: 12, complete: true });
85
- expect(typeDigit("minute", "5", "9", false)).toEqual({ text: "59", value: 59, complete: true });
86
- });
87
-
88
- it("restarts when the accumulated value overflows", () => {
89
- expect(typeDigit("month", "1", "5", false)).toEqual({ text: "5", value: 5, complete: true });
90
- expect(typeDigit("hour", "2", "5", false)).toEqual({ text: "5", value: 5, complete: true });
91
- });
92
-
93
- it("accumulates up to four digits for the year", () => {
94
- expect(typeDigit("year", "", "2", false)).toEqual({ text: "2", value: 2, complete: false });
95
- expect(typeDigit("year", "202", "6", false)).toEqual({
96
- text: "2026",
97
- value: 2026,
98
- complete: true,
99
- });
100
- expect(typeDigit("year", "2026", "7", false)).toEqual({
101
- text: "0267",
102
- value: 267,
103
- complete: true,
104
- });
105
- });
106
- });
107
-
108
- describe("typed date entry — the inline keyboard path", () => {
109
- // Simulate the segment engine's typing pipeline: each keystroke flows through
110
- // typeDigit in the locale's own field order; the buffer then resolves through
111
- // segmentsToValue. This is the outcome contract behind "type 15/03/2026 into
112
- // an inline date editor and it commits".
113
- it("a typed dd/MM/yyyy date composes the committed ISO value (vi-VN order)", () => {
114
- expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
115
- const day = typeDigit("day", typeDigit("day", "", "1", false).text, "5", false);
116
- expect(day).toEqual({ text: "15", value: 15, complete: true });
117
- const month = typeDigit("month", typeDigit("month", "", "0", false).text, "3", false);
118
- expect(month).toEqual({ text: "03", value: 3, complete: true });
119
- let yearText = "";
120
- for (const digit of ["2", "0", "2", "6"]) yearText = typeDigit("year", yearText, digit, false).text;
121
- const buffer = { year: Number(yearText), month: month.value, day: day.value, hour: null, minute: null };
122
- expect(segmentsToValue(buffer, false)).toBe("2026-03-15");
123
- });
124
-
125
- it("a single-digit d/M/yyyy entry advances on the separator and still commits", () => {
126
- // "1/1/2026": neither "1" can auto-advance (a second digit could still
127
- // fit — "12", "11") — the typed "/" is the explicit advance.
128
- const day = typeDigit("day", "", "1", false);
129
- expect(day.complete).toBe(false);
130
- expect(separatorAdvances("/", day.text !== "")).toBe(true);
131
- const month = typeDigit("month", "", "1", false);
132
- expect(month.complete).toBe(false);
133
- expect(separatorAdvances("/", month.text !== "")).toBe(true);
134
- const buffer = { year: 2026, month: month.value, day: day.value, hour: null, minute: null };
135
- expect(segmentsToValue(buffer, false)).toBe("2026-01-01");
136
- });
137
-
138
- it("separators advance only off a segment that has content", () => {
139
- for (const key of ["/", ".", "-", ",", ":", " "]) {
140
- expect(separatorAdvances(key, true)).toBe(true);
141
- expect(separatorAdvances(key, false)).toBe(false);
142
- }
143
- expect(separatorAdvances("a", true)).toBe(false);
144
- expect(separatorAdvances("ArrowDown", true)).toBe(false);
145
- });
146
-
147
- it("an impossible typed date never composes a value", () => {
148
- expect(segmentsToValue({ year: 2026, month: 2, day: 30, hour: null, minute: null }, false)).toBeNull();
149
- });
150
- });
151
-
152
- describe("incrementSegment", () => {
153
- it("wraps within range", () => {
154
- expect(incrementSegment("month", 12, 1, false)).toBe(1);
155
- expect(incrementSegment("month", 1, -1, false)).toBe(12);
156
- expect(incrementSegment("minute", 59, 1, false)).toBe(0);
157
- expect(incrementSegment("hour", 23, 1, false)).toBe(0);
158
- expect(incrementSegment("hour", 12, 1, true)).toBe(1);
159
- });
160
-
161
- it("starts from an end when empty", () => {
162
- expect(incrementSegment("day", null, 1, false)).toBe(1);
163
- expect(incrementSegment("day", null, -1, false)).toBe(31);
164
- });
165
-
166
- it("clamps the year instead of wrapping", () => {
167
- expect(incrementSegment("year", 2026, 1, false)).toBe(2027);
168
- expect(incrementSegment("year", 9999, 1, false)).toBe(9999);
169
- expect(incrementSegment("year", 1, -1, false)).toBe(1);
170
- });
171
- });
172
-
173
- describe("setHourField / withDayPeriod", () => {
174
- it("keeps AM/PM when typing the hour in 12h mode", () => {
175
- expect(setHourField({ ...empty, hour: 14 }, 9, true)).toBe(21); // 9 PM
176
- expect(setHourField({ ...empty, hour: 9 }, 11, true)).toBe(11); // 11 AM
177
- });
178
-
179
- it("returns the typed hour as-is in 24h mode", () => {
180
- expect(setHourField({ ...empty, hour: null }, 18, false)).toBe(18);
181
- });
182
-
183
- it("toggles half-day while preserving the 12h value", () => {
184
- expect(withDayPeriod({ ...empty, hour: 9 }, true)).toBe(21);
185
- expect(withDayPeriod({ ...empty, hour: 21 }, false)).toBe(9);
186
- });
187
- });
188
-
189
- describe("displayValue", () => {
190
- const en = getDateLayout("en-US", true);
191
- const vn = getDateLayout("vi-VN", true);
192
-
193
- it("renders the 12h hour and AM/PM for en-US", () => {
194
- const buffer = { year: 2026, month: 5, day: 17, hour: 14, minute: 30 };
195
- expect(displayValue("hour", buffer, en)).toBe("02");
196
- expect(displayValue("dayPeriod", buffer, en)).toBe(en.pmText);
197
- expect(displayValue("minute", buffer, en)).toBe("30");
198
- });
199
-
200
- it("renders the 24h hour for vi-VN", () => {
201
- const buffer = { year: 2026, month: 5, day: 17, hour: 14, minute: 30 };
202
- expect(displayValue("hour", buffer, vn)).toBe("14");
203
- });
204
-
205
- it("returns null for unset fields", () => {
206
- expect(displayValue("year", empty, en)).toBeNull();
207
- expect(displayValue("hour", empty, en)).toBeNull();
208
- });
209
- });
210
-
211
- describe("valueToSegments / segmentsToValue round-trip", () => {
212
- it("round-trips a date", () => {
213
- const buffer = valueToSegments("2026-05-17", false);
214
- expect(buffer).toEqual({ year: 2026, month: 5, day: 17, hour: null, minute: null });
215
- expect(segmentsToValue(buffer, false)).toBe("2026-05-17");
216
- });
217
-
218
- it("round-trips a datetime", () => {
219
- const buffer = valueToSegments("2026-05-17T14:30", true);
220
- expect(buffer).toEqual({ year: 2026, month: 5, day: 17, hour: 14, minute: 30 });
221
- expect(segmentsToValue(buffer, true)).toBe("2026-05-17T14:30");
222
- });
223
-
224
- it("returns null for incomplete buffers", () => {
225
- expect(segmentsToValue({ ...empty, year: 2026, month: 5 }, false)).toBeNull();
226
- expect(segmentsToValue({ year: 2026, month: 5, day: 17, hour: 14, minute: null }, true)).toBeNull();
227
- });
228
-
229
- it("rejects impossible calendar dates", () => {
230
- expect(segmentsToValue({ ...empty, year: 2026, month: 2, day: 30 }, false)).toBeNull();
231
- });
232
-
233
- it("yields an empty buffer for an unparseable value", () => {
234
- expect(valueToSegments("", false)).toEqual(empty);
235
- expect(isBufferEmpty(valueToSegments("garbage", false), false)).toBe(true);
236
- });
237
- });
238
-
239
- describe("placeholderFor", () => {
240
- it("gives per-type placeholders", () => {
241
- expect(placeholderFor("year")).toBe("yyyy");
242
- expect(placeholderFor("month")).toBe("mm");
243
- expect(placeholderFor("dayPeriod")).toBe("--");
244
- });
245
- });
246
-
247
- describe("locale-driven default segment order (LoticsLocaleProvider)", () => {
248
- // The provider pack carries a BCP-47 tag; a wired date field defaults its
249
- // segment order to that tag when no explicit `locale` prop is given. These
250
- // assert the end-to-end contract — provider pack → rendered part ORDER —
251
- // through the same resolveLocaleTag + getDateLayout the components compose.
252
- // Before this, the field hardcoded "en-US", so a vi-wrapped app showed
253
- // MM/DD/YYYY unless every call site threaded a `locale` prop.
254
- it("a vi provider yields dd/MM/yyyy by default (no explicit locale)", () => {
255
- const tag = resolveLocaleTag(viLocale);
256
- expect(tag).toBe("vi-VN");
257
- expect(fieldOrder(getDateLayout(tag, false))).toEqual(["day", "month", "year"]);
258
- });
259
-
260
- it("an en provider yields MM/dd/yyyy by default", () => {
261
- const tag = resolveLocaleTag(enLocale);
262
- expect(tag).toBe("en-US");
263
- expect(fieldOrder(getDateLayout(tag, false))).toEqual(["month", "day", "year"]);
264
- });
265
-
266
- it("an explicit locale prop overrides the provider pack, both directions", () => {
267
- // Under a vi provider, an author passing en-US still gets MM/dd/yyyy…
268
- expect(fieldOrder(getDateLayout(resolveLocaleTag(viLocale, "en-US"), false))).toEqual([
269
- "month",
270
- "day",
271
- "year",
272
- ]);
273
- // …and an en provider with an explicit vi-VN gets dd/MM/yyyy.
274
- expect(fieldOrder(getDateLayout(resolveLocaleTag(enLocale, "vi-VN"), false))).toEqual([
275
- "day",
276
- "month",
277
- "year",
278
- ]);
279
- });
280
- });
281
-
282
- describe("getTimeLayout", () => {
283
- it("is hour/minute(+AM/PM) only, locale-driven", () => {
284
- expect(fieldOrder(getTimeLayout("en-US"))).toEqual(["hour", "minute", "dayPeriod"]);
285
- const vn = getTimeLayout("vi-VN");
286
- expect(fieldOrder(vn)).toEqual(["hour", "minute"]);
287
- expect(vn.hour12).toBe(false);
288
- });
289
- });
290
-
291
- // -----------------------------------------------------------------------------
292
- // Time-only field. The point of the segmented time field is that 12/24-hour is
293
- // OUR locale's decision — a native `<input type="time">` takes it from the
294
- // browser's UI locale and ignores `lang`, so a Vietnamese screen on an en-US
295
- // browser rendered "01:45 PM" beside its own 24-hour text.
296
- // -----------------------------------------------------------------------------
297
-
298
- describe("timeSegmentsConfig", () => {
299
- it("derives 24-hour display from the locale, not the runtime", () => {
300
- expect(timeSegmentsConfig("vi-VN").layout.hour12).toBe(false);
301
- expect(timeSegmentsConfig("de-DE").layout.hour12).toBe(false);
302
- });
303
-
304
- it("derives 12-hour display where the locale uses one", () => {
305
- const layout = timeSegmentsConfig("en-US").layout;
306
- expect(layout.hour12).toBe(true);
307
- expect(layout.segments.some((s) => s.kind === "field" && s.type === "dayPeriod")).toBe(true);
308
- });
309
-
310
- it("keeps the value canonical 24-hour under a 12-hour locale", () => {
311
- const cfg = timeSegmentsConfig("en-US");
312
- expect(cfg.toValue(cfg.toBuffer("13:45"))).toBe("13:45");
313
- expect(cfg.toValue(cfg.toBuffer("00:30"))).toBe("00:30");
314
- });
315
-
316
- it("round-trips every hour of the day", () => {
317
- const cfg = timeSegmentsConfig("en-US");
318
- for (let h = 0; h < 24; h++) {
319
- const iso = `${String(h).padStart(2, "0")}:15`;
320
- expect(cfg.toValue(cfg.toBuffer(iso))).toBe(iso);
321
- }
322
- });
323
-
324
- it("reads an empty or unparseable time as empty", () => {
325
- const cfg = timeSegmentsConfig("vi-VN");
326
- for (const bad of ["", "banana", "25:00", "12:99"]) {
327
- expect(cfg.isEmpty(cfg.toBuffer(bad))).toBe(true);
328
- }
329
- });
330
-
331
- it("emits nothing while only half the time is typed", () => {
332
- expect(segmentsToTime({ year: null, month: null, day: null, hour: 13, minute: null })).toBeNull();
333
- expect(segmentsToTime({ year: null, month: null, day: null, hour: null, minute: 45 })).toBeNull();
334
- });
335
- });
@@ -1,49 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- FILE_BADGE_BOX,
4
- FILE_BADGE_DEFAULT_WIDTH,
5
- fileBadgeHeight,
6
- fileBadgeWidthForBox,
7
- } from "./file_badge_fit";
8
-
9
- describe("FileBadge geometry", () => {
10
- it("scales the badge with the requested width instead of pinning the default", () => {
11
- // The bug this covers: a caller's `size` was dropped and every badge rendered at
12
- // the 26x32 default. A requested width must produce a proportionally smaller badge.
13
- expect(fileBadgeHeight(FILE_BADGE_DEFAULT_WIDTH)).toBe(FILE_BADGE_BOX);
14
- expect(fileBadgeHeight(20)).toBe(25);
15
- expect(fileBadgeHeight(13)).toBe(16);
16
- expect(fileBadgeHeight(52)).toBe(64);
17
- });
18
-
19
- it("fills the compact thumbnail slot exactly at the default width", () => {
20
- // Why DEFAULT_WIDTH is 26: a default badge is exactly as tall as the 32px compact
21
- // tile it sits in. A slot of 32 must therefore resolve to the default, unchanged.
22
- expect(fileBadgeWidthForBox(FILE_BADGE_BOX)).toBe(FILE_BADGE_DEFAULT_WIDTH);
23
- });
24
-
25
- it("resolves a SMALLER slot to a smaller badge, not the default", () => {
26
- // The reported bug: a sub-32 tile still rendered the 26x32 default — wider AND taller
27
- // than its own tile. The requested slot now drives the badge.
28
- expect(fileBadgeWidthForBox(30)).toBe(24);
29
- expect(fileBadgeHeight(fileBadgeWidthForBox(30))).toBe(30);
30
- expect(fileBadgeWidthForBox(24)).toBe(19);
31
- expect(fileBadgeHeight(fileBadgeWidthForBox(24))).toBe(23);
32
- });
33
-
34
- it("never overflows its square slot, at any slot size", () => {
35
- // The badge is taller than it is wide, so height is the binding dimension and the
36
- // rounding in `fileBadgeHeight` can push a naive floor over the edge.
37
- for (let box = 8; box <= 200; box++) {
38
- const width = fileBadgeWidthForBox(box);
39
- expect(width).toBeLessThanOrEqual(box);
40
- expect(fileBadgeHeight(width)).toBeLessThanOrEqual(box);
41
- }
42
- });
43
-
44
- it("grows monotonically with the slot", () => {
45
- for (let box = 9; box <= 200; box++) {
46
- expect(fileBadgeWidthForBox(box)).toBeGreaterThanOrEqual(fileBadgeWidthForBox(box - 1));
47
- }
48
- });
49
- });
@@ -1,124 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { matchesAccept, filesFromTransfer, selectPasteSink, type FileTransferLike, type PasteSinkEntry } from "./file_intake";
3
-
4
- const file = (name: string, type: string) => new File([new Uint8Array([1])], name, { type });
5
-
6
- /** A clipboard/drag payload that surfaces its files only through `items` —
7
- * what a pasted screenshot looks like on the engines that do that. */
8
- const itemsOnly = (files: File[]): FileTransferLike => ({
9
- files: [],
10
- items: files.map((f) => ({ kind: "file" as const, getAsFile: () => f })),
11
- });
12
-
13
- describe("matchesAccept", () => {
14
- it("accepts everything when the list is absent or empty", () => {
15
- expect(matchesAccept(file("a.pdf", "application/pdf"), undefined)).toBe(true);
16
- expect(matchesAccept(file("a.pdf", "application/pdf"), "")).toBe(true);
17
- expect(matchesAccept(file("a.pdf", "application/pdf"), " , ")).toBe(true);
18
- });
19
-
20
- it("matches an exact MIME, case-insensitively", () => {
21
- expect(matchesAccept(file("a.pdf", "APPLICATION/PDF"), "application/pdf")).toBe(true);
22
- expect(matchesAccept(file("a.png", "image/png"), "application/pdf")).toBe(false);
23
- });
24
-
25
- it("matches a wildcard type", () => {
26
- expect(matchesAccept(file("a.png", "image/png"), "image/*")).toBe(true);
27
- expect(matchesAccept(file("a.pdf", "application/pdf"), "image/*")).toBe(false);
28
- });
29
-
30
- it("matches an extension pattern by filename when the MIME is unknown", () => {
31
- expect(matchesAccept(file("Report.CSV", ""), ".csv")).toBe(true);
32
- expect(matchesAccept(file("report.csvx", ""), ".csv")).toBe(false);
33
- });
34
-
35
- it("accepts a file matching ANY pattern in the list", () => {
36
- expect(matchesAccept(file("a.png", "image/png"), "application/pdf, image/*")).toBe(true);
37
- expect(matchesAccept(file("a.zip", "application/zip"), "application/pdf, image/*")).toBe(false);
38
- });
39
- });
40
-
41
- describe("filesFromTransfer", () => {
42
- it("returns [] for a missing transfer", () => {
43
- expect(filesFromTransfer(null)).toEqual([]);
44
- expect(filesFromTransfer(undefined)).toEqual([]);
45
- expect(filesFromTransfer({})).toEqual([]);
46
- });
47
-
48
- it("reads the direct file list", () => {
49
- const a = file("a.pdf", "application/pdf");
50
- expect(filesFromTransfer({ files: [a] })).toEqual([a]);
51
- });
52
-
53
- it("falls back to clipboard items when the file list is empty", () => {
54
- const shot = file("image.png", "image/png");
55
- expect(filesFromTransfer(itemsOnly([shot]))).toEqual([shot]);
56
- });
57
-
58
- it("never doubles a file present in BOTH files and items", () => {
59
- const a = file("a.png", "image/png");
60
- expect(filesFromTransfer({ files: [a], items: [{ kind: "file", getAsFile: () => a }] })).toEqual([a]);
61
- });
62
-
63
- it("skips non-file clipboard items and null getAsFile results", () => {
64
- const a = file("a.png", "image/png");
65
- const transfer: FileTransferLike = {
66
- items: [
67
- { kind: "string", getAsFile: () => null },
68
- { kind: "file", getAsFile: () => null },
69
- { kind: "file", getAsFile: () => a },
70
- ],
71
- };
72
- expect(filesFromTransfer(transfer)).toEqual([a]);
73
- });
74
-
75
- it("filters by accept", () => {
76
- const pdf = file("a.pdf", "application/pdf");
77
- const zip = file("a.zip", "application/zip");
78
- expect(filesFromTransfer({ files: [pdf, zip] }, { accept: "application/pdf" })).toEqual([pdf]);
79
- expect(filesFromTransfer(itemsOnly([pdf, zip]), { accept: "application/pdf" })).toEqual([pdf]);
80
- });
81
-
82
- it("keeps every file by default and caps at one when multiple is false", () => {
83
- const a = file("a.pdf", "application/pdf");
84
- const b = file("b.pdf", "application/pdf");
85
- expect(filesFromTransfer({ files: [a, b] })).toEqual([a, b]);
86
- expect(filesFromTransfer({ files: [a, b] }, { multiple: false })).toEqual([a]);
87
- });
88
-
89
- it("caps AFTER filtering, so a rejected first file does not eat the slot", () => {
90
- const zip = file("a.zip", "application/zip");
91
- const pdf = file("b.pdf", "application/pdf");
92
- expect(filesFromTransfer({ files: [zip, pdf] }, { accept: "application/pdf", multiple: false })).toEqual([pdf]);
93
- });
94
- });
95
-
96
- describe("selectPasteSink", () => {
97
- const entry = (sink: string, focused: boolean): PasteSinkEntry<string> => ({ sink, focused });
98
-
99
- it("returns undefined for an empty stack", () => {
100
- expect(selectPasteSink<string>([])).toBeUndefined();
101
- });
102
-
103
- it("falls back to the TOP of the stack when nothing is focused", () => {
104
- expect(selectPasteSink([entry("a", false), entry("b", false), entry("c", false)])).toBe("c");
105
- });
106
-
107
- it("routes to a focused region over the (later-mounted) top of the stack", () => {
108
- // b holds focus; c is on top but unfocused — b wins.
109
- expect(selectPasteSink([entry("a", false), entry("b", true), entry("c", false)])).toBe("b");
110
- });
111
-
112
- it("picks the TOP-MOST focused region when several are focused", () => {
113
- // Nested regions can both contain activeElement; the innermost/last wins.
114
- expect(selectPasteSink([entry("a", true), entry("b", true), entry("c", false)])).toBe("b");
115
- });
116
-
117
- it("a single region-less sink still gets the paste (byte-for-byte old behavior)", () => {
118
- expect(selectPasteSink([entry("only", false)])).toBe("only");
119
- });
120
-
121
- it("a focused region beats an unfocused one mounted after it", () => {
122
- expect(selectPasteSink([entry("focused", true), entry("later", false)])).toBe("focused");
123
- });
124
- });
@@ -1,138 +0,0 @@
1
- import { describe, expect, test } from "vitest";
2
- import { formatDate, parseDate, toISODate } from "./format_date";
3
-
4
- describe("formatDate", () => {
5
- test("formats an ISO date in the home market (dd/MM/yyyy)", () => {
6
- expect(formatDate("2026-05-22", { locale: "vi-VN" })).toBe("22/05/2026");
7
- });
8
-
9
- test("formats a Date object, not just an ISO string", () => {
10
- expect(formatDate(new Date(2026, 4, 22), { locale: "vi-VN" })).toBe("22/05/2026");
11
- });
12
-
13
- test("compact drops the year (dd/MM)", () => {
14
- expect(formatDate("2026-05-22", { locale: "vi-VN", compact: true })).toBe("22/05");
15
- });
16
-
17
- test("time prepends the 24h time, then the locale date", () => {
18
- expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", time: true })).toBe("14:30 22/05/2026");
19
- });
20
-
21
- test("time + compact is time-first and drops the year", () => {
22
- expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", time: true, compact: true })).toBe("09:05 22/05");
23
- });
24
-
25
- test("time composes with a readable style — the orthogonal axis", () => {
26
- expect(formatDate("2026-09-22T14:30", { format: "long", time: true, locale: "en-US" })).toBe("14:30 September 22, 2026");
27
- });
28
-
29
- test("medium — readable, abbreviated month", () => {
30
- expect(formatDate("2026-05-22", { format: "medium", locale: "en-US" })).toBe("May 22, 2026");
31
- // vi word output is ICU-dependent; assert the day + year are present (day-first locale).
32
- expect(formatDate("2026-05-22", { format: "medium", locale: "vi-VN" })).toMatch(/22.*2026/);
33
- });
34
-
35
- test("long — readable, full month name", () => {
36
- expect(formatDate("2026-09-22", { format: "long", locale: "en-US" })).toBe("September 22, 2026");
37
- expect(formatDate("2026-09-22", { format: "long", locale: "vi-VN" })).toMatch(/22.*2026/);
38
- });
39
-
40
- test("dayMonth — day + abbreviated month, no year", () => {
41
- expect(formatDate("2026-09-22", { format: "dayMonth", locale: "en-US" })).toBe("Sep 22");
42
- expect(formatDate("2026-09-22", { format: "dayMonth", locale: "vi-VN" })).not.toMatch(/2026/);
43
- });
44
-
45
- test("monthYear — a period label, sentence-cased", () => {
46
- expect(formatDate("2026-05-22", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
47
- // Leading character is upper-cased even where the locale lowercases the month name.
48
- const vi = formatDate("2026-05-22", { format: "monthYear", locale: "vi-VN" });
49
- expect(vi.charAt(0)).toBe(vi.charAt(0).toUpperCase());
50
- expect(vi).toMatch(/2026/);
51
- });
52
-
53
- test("date-only ISO does not drift across local timezone (wall-clock parse)", () => {
54
- // `new Date('2026-01-01')` is UTC midnight; in negative-offset zones it formats as Dec 31.
55
- expect(formatDate("2026-01-01", { locale: "vi-VN" })).toBe("01/01/2026");
56
- });
57
-
58
- test("empty input returns the empty label", () => {
59
- expect(formatDate(null)).toBe("");
60
- expect(formatDate("")).toBe("");
61
- expect(formatDate(undefined, { emptyLabel: "—" })).toBe("—");
62
- expect(formatDate("not-a-date", { emptyLabel: "—" })).toBe("—");
63
- });
64
- });
65
-
66
- describe("formatDate — reduced precision", () => {
67
- test("month value renders MM/YYYY (numeric, locale order), no fabricated day", () => {
68
- expect(formatDate("2026-05", { locale: "vi-VN" })).toBe("05/2026");
69
- expect(formatDate("2026-05", { locale: "en-US" })).toBe("05/2026");
70
- });
71
-
72
- test("year value renders just the year", () => {
73
- expect(formatDate("2026", { locale: "vi-VN" })).toBe("2026");
74
- expect(formatDate("2026", { format: "medium", locale: "en-US" })).toBe("2026");
75
- expect(formatDate("2026", { format: "monthYear", locale: "en-US" })).toBe("2026");
76
- });
77
-
78
- test("compact drops the year on a month value", () => {
79
- expect(formatDate("2026-05", { locale: "vi-VN", compact: true })).toBe("05");
80
- });
81
-
82
- test("readable styles drop the day for a month value", () => {
83
- expect(formatDate("2026-05", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
84
- expect(formatDate("2026-05", { format: "medium", locale: "en-US" })).toBe("May 2026");
85
- expect(formatDate("2026-05", { format: "long", locale: "en-US" })).toBe("May 2026");
86
- expect(formatDate("2026-05", { format: "dayMonth", locale: "en-US" })).toBe("May");
87
- });
88
-
89
- test("time is ignored for a reduced-precision value (a period has no clock)", () => {
90
- expect(formatDate("2026-05", { locale: "vi-VN", time: true })).toBe("05/2026");
91
- });
92
- });
93
-
94
- describe("parseDate", () => {
95
- test("parses an ISO datetime to local wall-clock", () => {
96
- const d = parseDate("2026-05-22T14:30");
97
- expect(d).not.toBeNull();
98
- expect(d?.getFullYear()).toBe(2026);
99
- expect(d?.getMonth()).toBe(4);
100
- expect(d?.getDate()).toBe(22);
101
- expect(d?.getHours()).toBe(14);
102
- expect(d?.getMinutes()).toBe(30);
103
- });
104
-
105
- test("passes a valid Date through and rejects an invalid one", () => {
106
- const d = new Date(2026, 0, 1);
107
- expect(parseDate(d)).toBe(d);
108
- expect(parseDate(new Date("nonsense"))).toBeNull();
109
- expect(parseDate(null)).toBeNull();
110
- expect(parseDate("")).toBeNull();
111
- });
112
-
113
- test("parses a reduced-precision value to its period start", () => {
114
- const month = parseDate("2026-05");
115
- expect(month?.getFullYear()).toBe(2026);
116
- expect(month?.getMonth()).toBe(4);
117
- expect(month?.getDate()).toBe(1);
118
- const year = parseDate("2026");
119
- expect(year?.getFullYear()).toBe(2026);
120
- expect(year?.getMonth()).toBe(0);
121
- expect(year?.getDate()).toBe(1);
122
- });
123
- });
124
-
125
- describe("toISODate", () => {
126
- test("Date or ISO value → yyyy-MM-dd", () => {
127
- expect(toISODate(new Date(2026, 4, 22))).toBe("2026-05-22");
128
- expect(toISODate("2026-05-22T14:30")).toBe("2026-05-22");
129
- expect(toISODate(null)).toBe("");
130
- });
131
-
132
- test("PRESERVES reduced precision verbatim — never fabricates a day", () => {
133
- expect(toISODate("2026-05")).toBe("2026-05");
134
- expect(toISODate("2026")).toBe("2026");
135
- // Whitespace-trimmed but otherwise unchanged.
136
- expect(toISODate(" 2026-05 ")).toBe("2026-05");
137
- });
138
- });
@@ -1,26 +0,0 @@
1
- // Pin a NEGATIVE-offset zone (UTC-5) before importing the formatter. The old
2
- // fall-through `new Date("2026-05")` parses as UTC midnight, which in a
3
- // negative-offset zone lands on the PREVIOUS month ("2026-04-30T19:00"), drifting
4
- // a month-precision value back a month. The naive part-based construction must
5
- // keep "2026-05" on May regardless of the runtime zone.
6
- process.env.TZ = "America/New_York";
7
-
8
- import { describe, expect, test } from "vitest";
9
- import { formatDate, toISODate } from "./format_date";
10
-
11
- describe("formatDate — reduced precision in a negative-offset zone", () => {
12
- test("a month value does not drift to the previous month", () => {
13
- expect(formatDate("2026-05", { locale: "en-US" })).toBe("05/2026");
14
- // January is the adversarial case — a backward drift would land in Dec 2025.
15
- expect(formatDate("2026-01", { locale: "en-US" })).toBe("01/2026");
16
- });
17
-
18
- test("a year value stays on its year", () => {
19
- expect(formatDate("2026", { locale: "en-US" })).toBe("2026");
20
- });
21
-
22
- test("toISODate preserves the partial (no drift, no fabricated day)", () => {
23
- expect(toISODate("2026-01")).toBe("2026-01");
24
- expect(toISODate("2026")).toBe("2026");
25
- });
26
- });