gbs-add-block 2.0.4 → 2.3.0

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.
Files changed (129) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +190 -134
  2. package/.gbs/skills/gbs-components/references/install.md +25 -3
  3. package/.gbs/skills/gbs-components/references/styling.md +246 -201
  4. package/CHANGELOG.md +74 -0
  5. package/README.md +157 -11
  6. package/index.cjs +212 -3
  7. package/package.json +41 -10
  8. package/schema/passport-v1.schema.json +204 -0
  9. package/source/beta-components/accordion/passport.json +259 -0
  10. package/source/beta-components/accordion/styles.css +207 -208
  11. package/source/beta-components/alert/passport.json +250 -0
  12. package/source/beta-components/alert/styles.css +154 -155
  13. package/source/beta-components/avatar/passport.json +294 -0
  14. package/source/beta-components/avatar/styles.css +201 -203
  15. package/source/beta-components/badge/passport.json +332 -0
  16. package/source/beta-components/badge/styles.css +203 -204
  17. package/source/beta-components/breadcrumb/passport.json +243 -0
  18. package/source/beta-components/breadcrumb/styles.css +138 -139
  19. package/source/beta-components/button/passport.json +402 -0
  20. package/source/beta-components/button/passport.manual.json +31 -0
  21. package/source/beta-components/button/styles.css +232 -233
  22. package/source/beta-components/card/passport.json +337 -0
  23. package/source/beta-components/card/styles.css +230 -231
  24. package/source/beta-components/checkbox/passport.json +456 -0
  25. package/source/beta-components/checkbox/styles.css +211 -212
  26. package/source/beta-components/combobox/passport.json +456 -0
  27. package/source/beta-components/combobox/styles.css +419 -417
  28. package/source/beta-components/data-grid/agent/coerce.ts +368 -0
  29. package/source/beta-components/data-grid/agent/contract.ts +410 -0
  30. package/source/beta-components/data-grid/agent/dataset.ts +92 -0
  31. package/source/beta-components/data-grid/agent/engine.ts +470 -0
  32. package/source/beta-components/data-grid/agent/executors.ts +155 -0
  33. package/source/beta-components/data-grid/agent/index.ts +79 -0
  34. package/source/beta-components/data-grid/agent/intent.ts +324 -0
  35. package/source/beta-components/data-grid/agent/operations.ts +335 -0
  36. package/source/beta-components/data-grid/agent/validate.ts +630 -0
  37. package/source/beta-components/data-grid/agent/webmcp.ts +107 -0
  38. package/source/beta-components/data-grid/index.ts +14 -7
  39. package/source/beta-components/data-grid/passport.json +1051 -0
  40. package/source/beta-components/data-grid/passport.manual.json +255 -0
  41. package/source/beta-components/data-grid/react/AskGrid.tsx +164 -0
  42. package/source/beta-components/data-grid/react/DataGrid.tsx +39 -0
  43. package/source/beta-components/data-grid/styles.css +874 -717
  44. package/source/beta-components/date-picker/passport.json +407 -0
  45. package/source/beta-components/date-picker/styles.css +445 -446
  46. package/source/beta-components/dialog/passport.json +344 -0
  47. package/source/beta-components/dialog/styles.css +280 -278
  48. package/source/beta-components/file-uploader/passport.json +518 -0
  49. package/source/beta-components/file-uploader/styles.css +394 -395
  50. package/source/beta-components/input/passport.json +536 -0
  51. package/source/beta-components/input/styles.css +295 -296
  52. package/source/beta-components/menu/passport.json +322 -0
  53. package/source/beta-components/menu/styles.css +224 -222
  54. package/source/beta-components/modal/passport.json +289 -0
  55. package/source/beta-components/modal/styles.css +241 -239
  56. package/source/beta-components/number-input/passport.json +541 -0
  57. package/source/beta-components/number-input/styles.css +230 -231
  58. package/source/beta-components/popover/passport.json +238 -0
  59. package/source/beta-components/popover/styles.css +148 -146
  60. package/source/beta-components/progress/passport.json +270 -0
  61. package/source/beta-components/progress/styles.css +200 -202
  62. package/source/beta-components/radio-group/passport.json +477 -0
  63. package/source/beta-components/radio-group/styles.css +241 -242
  64. package/source/beta-components/shared/core/agent/adapter.ts +65 -0
  65. package/source/beta-components/shared/core/agent/history.ts +120 -0
  66. package/source/beta-components/shared/core/agent/index.ts +46 -0
  67. package/source/beta-components/shared/core/agent/numbers.ts +217 -0
  68. package/source/beta-components/shared/core/agent/schema.ts +180 -0
  69. package/source/beta-components/shared/core/agent/types.ts +169 -0
  70. package/source/beta-components/shared/core/agent/webmcp.ts +328 -0
  71. package/source/beta-components/shared/index.ts +9 -0
  72. package/source/beta-components/shared/react/GramproAIProvider.tsx +50 -0
  73. package/source/beta-components/shared/react/useAskAgent.ts +217 -0
  74. package/source/beta-components/shared/styles.css +79 -0
  75. package/source/beta-components/shared/version.json +4 -4
  76. package/source/beta-components/shared/version.ts +6 -6
  77. package/source/beta-components/skeleton/passport.json +251 -0
  78. package/source/beta-components/skeleton/styles.css +185 -186
  79. package/source/beta-components/spinner/passport.json +245 -0
  80. package/source/beta-components/spinner/styles.css +145 -146
  81. package/source/beta-components/switch/passport.json +421 -0
  82. package/source/beta-components/switch/styles.css +194 -196
  83. package/source/beta-components/tabs/passport.json +315 -0
  84. package/source/beta-components/tabs/styles.css +234 -235
  85. package/source/beta-components/textarea/passport.json +382 -0
  86. package/source/beta-components/textarea/styles.css +158 -159
  87. package/source/beta-components/toaster/passport.json +221 -0
  88. package/source/beta-components/toaster/styles.css +282 -283
  89. package/source/beta-components/tooltip/passport.json +170 -0
  90. package/source/beta-components/tooltip/styles.css +71 -72
  91. package/tools/env.cjs +61 -0
  92. package/tools/passport/cli.cjs +79 -0
  93. package/tools/passport/extract.cjs +493 -0
  94. package/tools/passport/index.cjs +185 -0
  95. package/tools/passport/merge.cjs +131 -0
  96. package/tools/passport/policy.cjs +65 -0
  97. package/tools/passport/validate.cjs +277 -0
  98. package/tools/ts-require.cjs +79 -0
  99. package/source/beta-components/accordion/__tests__/core.test.ts +0 -58
  100. package/source/beta-components/alert/__tests__/core.test.ts +0 -17
  101. package/source/beta-components/avatar/__tests__/core.test.ts +0 -88
  102. package/source/beta-components/badge/__tests__/core.test.ts +0 -46
  103. package/source/beta-components/breadcrumb/__tests__/core.test.ts +0 -58
  104. package/source/beta-components/button/__tests__/core.test.ts +0 -31
  105. package/source/beta-components/card/__tests__/core.test.ts +0 -57
  106. package/source/beta-components/checkbox/__tests__/core.test.ts +0 -40
  107. package/source/beta-components/combobox/__tests__/core.test.ts +0 -134
  108. package/source/beta-components/data-grid/__tests__/core.test.ts +0 -356
  109. package/source/beta-components/data-grid/__tests__/export.test.ts +0 -70
  110. package/source/beta-components/data-grid/__tests__/pdf.test.ts +0 -209
  111. package/source/beta-components/date-picker/__tests__/core.test.ts +0 -273
  112. package/source/beta-components/dialog/__tests__/core.test.ts +0 -86
  113. package/source/beta-components/file-uploader/__tests__/core.test.ts +0 -395
  114. package/source/beta-components/input/__tests__/core.test.ts +0 -75
  115. package/source/beta-components/menu/__tests__/core.test.ts +0 -120
  116. package/source/beta-components/modal/__tests__/core.test.ts +0 -55
  117. package/source/beta-components/number-input/__tests__/core.test.ts +0 -151
  118. package/source/beta-components/progress/__tests__/core.test.ts +0 -56
  119. package/source/beta-components/radio-group/__tests__/core.test.ts +0 -64
  120. package/source/beta-components/shared/__tests__/boundaries.test.ts +0 -95
  121. package/source/beta-components/shared/__tests__/core.test.ts +0 -55
  122. package/source/beta-components/shared/__tests__/position.test.ts +0 -143
  123. package/source/beta-components/skeleton/__tests__/core.test.ts +0 -41
  124. package/source/beta-components/spinner/__tests__/core.test.ts +0 -48
  125. package/source/beta-components/switch/__tests__/core.test.ts +0 -64
  126. package/source/beta-components/tabs/__tests__/core.test.ts +0 -51
  127. package/source/beta-components/textarea/__tests__/core.test.ts +0 -38
  128. package/source/beta-components/toaster/__tests__/core.test.ts +0 -256
  129. package/source/beta-components/tooltip/__tests__/core.test.ts +0 -42
@@ -1,395 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { fileKind, formatBytes, readFileId } from "../core/format";
3
- import { chunkCount, createUploaderStore, summarize } from "../core/store";
4
- import { buildChunkForm, isRetryable, UploadHttpError } from "../core/transport";
5
- import type { ChunkRequest, Transport } from "../core/types";
6
- import { matchesAccept, partitionFiles } from "../core/validate";
7
-
8
- const file = (name: string, size = 10, type = "", lastModified = 1) =>
9
- new File([new Uint8Array(size)], name, { type, lastModified });
10
-
11
- /** Resolves every chunk right away, answering like the Go chunk-uploader. */
12
- function instantTransport(handler?: (request: ChunkRequest) => unknown) {
13
- const calls: ChunkRequest[] = [];
14
- const transport: Transport = async (request) => {
15
- calls.push(request);
16
- request.onProgress(request.chunk.size);
17
- if (handler) return handler(request);
18
- return request.index === request.total - 1
19
- ? { status: "complete", metadata: { storedName: `${request.uploadId}.bin` } }
20
- : { status: "chunk_received" };
21
- };
22
- return { transport, calls };
23
- }
24
-
25
- /** Chunks wait until the test settles them; aborting rejects like a real request. */
26
- function manualTransport() {
27
- const pending: { request: ChunkRequest; resolve(value: unknown): void }[] = [];
28
- const transport: Transport = (request) =>
29
- new Promise((resolve, reject) => {
30
- pending.push({ request, resolve });
31
- request.signal.addEventListener(
32
- "abort",
33
- () => reject(new DOMException("Aborted", "AbortError")),
34
- { once: true },
35
- );
36
- });
37
- return { transport, pending };
38
- }
39
-
40
- describe("validation", () => {
41
- it("reads accept strings like the browser does", () => {
42
- const pdf = { name: "Report.PDF", type: "application/pdf" };
43
- expect(matchesAccept(pdf, ".pdf")).toBe(true);
44
- expect(matchesAccept(pdf, "image/*, .pdf")).toBe(true);
45
- expect(matchesAccept(pdf, "application/pdf")).toBe(true);
46
- expect(matchesAccept(pdf, "image/*")).toBe(false);
47
- expect(matchesAccept(pdf, "")).toBe(true);
48
- expect(matchesAccept({ name: "a.png", type: "image/png" }, "image/*")).toBe(true);
49
- });
50
-
51
- it("keeps only the first valid file in single mode", () => {
52
- const result = partitionFiles([file("a.txt"), file("b.txt")], [file("old.txt")], {});
53
- expect(result.accepted.map((f) => f.name)).toEqual(["a.txt"]);
54
- expect(result.rejected).toEqual([expect.objectContaining({ code: "too-many-files" })]);
55
- });
56
-
57
- it("counts files already selected against maxFiles", () => {
58
- const result = partitionFiles([file("b.txt"), file("c.txt")], [file("a.txt")], {
59
- multiple: true,
60
- maxFiles: 2,
61
- });
62
- expect(result.accepted.map((f) => f.name)).toEqual(["b.txt"]);
63
- expect(result.rejected[0]).toMatchObject({ code: "too-many-files" });
64
- });
65
-
66
- it("rejects by type, size, duplicate and custom rule, with a reason each", () => {
67
- const existing = [file("same.txt", 10)];
68
- const result = partitionFiles(
69
- [
70
- file("photo.png", 10, "image/png"),
71
- file("big.txt", 500),
72
- file("tiny.txt", 1),
73
- file("same.txt", 10),
74
- file("secret.txt", 10),
75
- file("ok.txt", 10),
76
- ],
77
- existing,
78
- {
79
- multiple: true,
80
- accept: ".txt",
81
- maxSize: 100,
82
- minSize: 5,
83
- validate: (f) => (f.name.startsWith("secret") ? "No secrets" : null),
84
- },
85
- );
86
- expect(result.accepted.map((f) => f.name)).toEqual(["ok.txt"]);
87
- expect(result.rejected.map((r) => [r.file.name, r.code, r.message])).toEqual([
88
- ["photo.png", "file-type", undefined],
89
- ["big.txt", "file-too-large", undefined],
90
- ["tiny.txt", "file-too-small", undefined],
91
- ["same.txt", "duplicate", undefined],
92
- ["secret.txt", "custom", "No secrets"],
93
- ]);
94
- });
95
-
96
- it("tells duplicates apart by size and date, and can allow them", () => {
97
- const existing = [file("a.txt", 10, "", 1)];
98
- expect(partitionFiles([file("a.txt", 11, "", 1)], existing, { multiple: true }).accepted).toHaveLength(1);
99
- expect(partitionFiles([file("a.txt", 10, "", 2)], existing, { multiple: true }).accepted).toHaveLength(1);
100
- expect(
101
- partitionFiles([file("a.txt", 10, "", 1)], existing, { multiple: true, allowDuplicates: true })
102
- .accepted,
103
- ).toHaveLength(1);
104
- });
105
- });
106
-
107
- describe("formatting", () => {
108
- it("formats sizes in steps of 1024", () => {
109
- expect(formatBytes(0, "en-US")).toBe("0 B");
110
- expect(formatBytes(512, "en-US")).toBe("512 B");
111
- expect(formatBytes(1536, "en-US")).toBe("1.5 kB");
112
- expect(formatBytes(5 * 1024 * 1024, "en-US")).toBe("5 MB");
113
- // German separates the unit with a non-breaking space.
114
- expect(formatBytes(1.5 * 1024 ** 3, "de-DE")).toMatch(/^1,5\sGB$/);
115
- });
116
-
117
- it("picks an icon family from the type or the extension", () => {
118
- expect(fileKind({ name: "a", type: "image/png" })).toBe("image");
119
- expect(fileKind({ name: "clip.MOV" })).toBe("video");
120
- expect(fileKind({ name: "a.pdf", type: "application/pdf" })).toBe("pdf");
121
- expect(fileKind({ name: "sheet.xlsx", type: "" })).toBe("spreadsheet");
122
- expect(fileKind({ name: "notes.docx" })).toBe("document");
123
- expect(fileKind({ name: "backup.zip" })).toBe("archive");
124
- expect(fileKind({ name: "unknown.xyz" })).toBe("other");
125
- });
126
-
127
- it("finds the stored file id in common response shapes", () => {
128
- expect(readFileId({ id: 7 })).toBe("7");
129
- expect(readFileId({ documentId: "doc-1" })).toBe("doc-1");
130
- expect(readFileId({ status: "complete", metadata: { storedName: "uuid.pdf" } })).toBe("uuid.pdf");
131
- expect(readFileId("plain text")).toBeUndefined();
132
- });
133
- });
134
-
135
- describe("transport", () => {
136
- const request = (overrides: Partial<ChunkRequest> = {}): ChunkRequest => ({
137
- file: file("report.pdf", 25),
138
- chunk: new Blob([new Uint8Array(10)]),
139
- index: 1,
140
- total: 3,
141
- offset: 10,
142
- uploadId: "up-1",
143
- signal: new AbortController().signal,
144
- onProgress: () => {},
145
- ...overrides,
146
- });
147
-
148
- it("builds the multipart body the Go chunk-uploader expects", async () => {
149
- const form = buildChunkForm(request(), { params: { folder: "invoices" } });
150
- expect(form.get("fileName")).toBe("report.pdf");
151
- expect(form.get("chunkIndex")).toBe("1");
152
- expect(form.get("totalChunks")).toBe("3");
153
- expect(form.get("fileSize")).toBe("25");
154
- expect(form.get("uploadId")).toBe("up-1");
155
- expect(form.get("additionalParams")).toBe('{"folder":"invoices"}');
156
- const chunk = form.get("chunk") as File;
157
- expect(chunk.size).toBe(10);
158
- expect(chunk.name).toBe("report.pdf");
159
- });
160
-
161
- it("renames fields and leaves out empty params", () => {
162
- const form = buildChunkForm(request(), {
163
- fieldNames: { chunk: "file", fileName: "originalname" },
164
- params: () => ({}),
165
- });
166
- expect(form.get("originalname")).toBe("report.pdf");
167
- expect(form.get("file")).toBeInstanceOf(File);
168
- expect(form.has("chunk")).toBe(false);
169
- expect(form.has("additionalParams")).toBe(false);
170
- });
171
-
172
- it("retries server and network failures, not client errors or aborts", () => {
173
- expect(isRetryable(new UploadHttpError(503, null))).toBe(true);
174
- expect(isRetryable(new UploadHttpError(429, null))).toBe(true);
175
- expect(isRetryable(new UploadHttpError(408, null))).toBe(true);
176
- expect(isRetryable(new UploadHttpError(413, null))).toBe(false);
177
- expect(isRetryable(new Error("Network error"))).toBe(true);
178
- expect(isRetryable(new DOMException("Aborted", "AbortError"))).toBe(false);
179
- });
180
- });
181
-
182
- describe("selecting files", () => {
183
- it("adds idle items with a chunk plan and reports changes", () => {
184
- const onChange = vi.fn();
185
- const onRejected = vi.fn();
186
- const store = createUploaderStore({ multiple: true, chunkSize: 4, maxSize: 20, onChange, onRejected });
187
-
188
- const { accepted, rejected } = store.addFiles([file("a.txt", 10), file("huge.txt", 50)]);
189
-
190
- expect(accepted[0]).toMatchObject({ status: "idle", totalChunks: 3, chunkSize: 4, progress: 0 });
191
- expect(rejected[0]).toMatchObject({ code: "file-too-large" });
192
- expect(onChange).toHaveBeenCalledWith([accepted[0].file]);
193
- expect(onRejected).toHaveBeenCalledWith(rejected);
194
- expect(chunkCount(0, 4)).toBe(1);
195
- });
196
-
197
- it("replaces the file in single mode and clears old rejections", () => {
198
- const store = createUploaderStore({ accept: ".txt" });
199
- store.addFiles([file("a.txt"), file("b.png")]);
200
- expect(store.getSnapshot().rejections).toHaveLength(1);
201
-
202
- store.addFiles([file("c.txt")]);
203
- expect(store.getSnapshot().items.map((item) => item.file.name)).toEqual(["c.txt"]);
204
- expect(store.getSnapshot().rejections).toHaveLength(0);
205
- });
206
-
207
- it("removes, clears and dismisses", () => {
208
- const onChange = vi.fn();
209
- const store = createUploaderStore({ multiple: true, accept: ".txt", onChange });
210
- const { accepted } = store.addFiles([file("a.txt"), file("b.txt"), file("c.png")]);
211
-
212
- store.remove(accepted[0].id);
213
- expect(store.getSnapshot().items).toHaveLength(1);
214
- store.dismissRejection();
215
- expect(store.getSnapshot().rejections).toHaveLength(0);
216
- store.clear();
217
- expect(store.getSnapshot().items).toHaveLength(0);
218
- expect(onChange).toHaveBeenLastCalledWith([]);
219
- });
220
- });
221
-
222
- describe("uploading", () => {
223
- it("sends chunks in order with the same upload id, then succeeds", async () => {
224
- const { transport, calls } = instantTransport();
225
- const onFileSuccess = vi.fn();
226
- const onComplete = vi.fn();
227
- const store = createUploaderStore({ transport, chunkSize: 4, onFileSuccess, onComplete });
228
- const { accepted } = store.addFiles([file("a.bin", 10)]);
229
-
230
- const items = await store.upload();
231
-
232
- expect(calls.map((c) => [c.index, c.offset, c.chunk.size, c.total])).toEqual([
233
- [0, 0, 4, 3],
234
- [1, 4, 4, 3],
235
- [2, 8, 2, 3],
236
- ]);
237
- expect(new Set(calls.map((c) => c.uploadId))).toEqual(new Set([accepted[0].uploadId]));
238
- expect(items[0]).toMatchObject({ status: "success", progress: 1, uploadedBytes: 10, chunksDone: 3 });
239
- expect(readFileId(items[0].response)).toBe(`${accepted[0].uploadId}.bin`);
240
- expect(onFileSuccess).toHaveBeenCalledOnce();
241
- await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith(items));
242
- expect(summarize(store.getSnapshot().items)).toMatchObject({ progress: 1, busy: false });
243
- });
244
-
245
- it("sends empty files as one chunk", async () => {
246
- const { transport, calls } = instantTransport();
247
- const store = createUploaderStore({ transport });
248
- store.addFiles([file("empty.txt", 0)]);
249
- const [item] = await store.upload();
250
- expect(calls).toHaveLength(1);
251
- expect(item.status).toBe("success");
252
- });
253
-
254
- it("uploads at most `concurrency` files at once", async () => {
255
- const { transport, pending } = manualTransport();
256
- const store = createUploaderStore({ transport, multiple: true, concurrency: 2 });
257
- store.addFiles([file("a.txt", 1, "", 1), file("b.txt", 1, "", 2), file("c.txt", 1, "", 3)]);
258
-
259
- const done = store.upload();
260
- await vi.waitFor(() => expect(pending).toHaveLength(2));
261
- expect(store.getSnapshot().items.map((item) => item.status)).toEqual([
262
- "uploading",
263
- "uploading",
264
- "queued",
265
- ]);
266
-
267
- pending[0].resolve({});
268
- await vi.waitFor(() => expect(pending).toHaveLength(3));
269
- pending[1].resolve({});
270
- pending[2].resolve({});
271
- expect((await done).map((item) => item.status)).toEqual(["success", "success", "success"]);
272
- });
273
-
274
- it("retries a failed chunk with backoff, then carries on", async () => {
275
- let failures = 0;
276
- const { transport, calls } = instantTransport((request) => {
277
- if (request.index === 1 && failures < 2) {
278
- failures++;
279
- throw new UploadHttpError(503, null);
280
- }
281
- return { ok: true };
282
- });
283
- const store = createUploaderStore({ transport, chunkSize: 4, retries: 3, retryDelay: 1 });
284
- store.addFiles([file("a.bin", 10)]);
285
-
286
- const [item] = await store.upload();
287
- expect(item.status).toBe("success");
288
- expect(calls.map((c) => c.index)).toEqual([0, 1, 1, 1, 2]);
289
- });
290
-
291
- it("gives up on client errors and keeps confirmed chunks for a retry", async () => {
292
- let reject = true;
293
- const { transport, calls } = instantTransport((request) => {
294
- if (request.index === 1 && reject) throw new UploadHttpError(413, { message: "Too large" });
295
- return {};
296
- });
297
- const onFileError = vi.fn();
298
- const store = createUploaderStore({ transport, chunkSize: 4, retryDelay: 1, onFileError });
299
- store.addFiles([file("a.bin", 10)]);
300
-
301
- const [failed] = await store.upload();
302
- expect(failed).toMatchObject({ status: "error", chunksDone: 1, uploadedBytes: 4 });
303
- expect(failed.error).toContain("413");
304
- expect(onFileError).toHaveBeenCalledWith(failed, expect.any(UploadHttpError));
305
-
306
- reject = false;
307
- const [retried] = await store.upload([failed.id]);
308
- expect(retried.status).toBe("success");
309
- expect(calls.map((c) => c.index)).toEqual([0, 1, 1, 2]);
310
- });
311
-
312
- it("pauses mid-file and resumes from the chunk that was interrupted", async () => {
313
- const { transport, pending } = manualTransport();
314
- const store = createUploaderStore({ transport, chunkSize: 4 });
315
- const { accepted } = store.addFiles([file("a.bin", 10)]);
316
- const { id, uploadId } = accepted[0];
317
-
318
- void store.upload();
319
- await vi.waitFor(() => expect(pending).toHaveLength(1));
320
- pending[0].resolve({});
321
- await vi.waitFor(() => expect(pending).toHaveLength(2));
322
-
323
- store.pause(id);
324
- await vi.waitFor(() => expect(store.getSnapshot().items[0].status).toBe("paused"));
325
- expect(store.getSnapshot().items[0]).toMatchObject({ chunksDone: 1, uploadedBytes: 4 });
326
-
327
- const resumed = store.upload([id]);
328
- await vi.waitFor(() => expect(pending).toHaveLength(3));
329
- expect(pending[2].request).toMatchObject({ index: 1, uploadId });
330
- pending[2].resolve({});
331
- await vi.waitFor(() => expect(pending).toHaveLength(4));
332
- pending[3].resolve({});
333
- expect((await resumed)[0].status).toBe("success");
334
- });
335
-
336
- it("cancels, and starts over with a new upload id", async () => {
337
- const { transport, pending } = manualTransport();
338
- const store = createUploaderStore({ transport, chunkSize: 4 });
339
- const { accepted } = store.addFiles([file("a.bin", 10)]);
340
-
341
- const first = store.upload();
342
- await vi.waitFor(() => expect(pending).toHaveLength(1));
343
- store.cancel();
344
- expect((await first)[0]).toMatchObject({ status: "canceled", progress: 0, chunksDone: 0 });
345
-
346
- void store.upload();
347
- await vi.waitFor(() => expect(pending).toHaveLength(2));
348
- expect(pending[1].request.index).toBe(0);
349
- expect(pending[1].request.uploadId).not.toBe(accepted[0].uploadId);
350
- });
351
-
352
- it("aborts the request when a file is removed mid-upload", async () => {
353
- const { transport, pending } = manualTransport();
354
- const store = createUploaderStore({ transport });
355
- const { accepted } = store.addFiles([file("a.bin", 10)]);
356
-
357
- const done = store.upload();
358
- await vi.waitFor(() => expect(pending).toHaveLength(1));
359
- store.remove(accepted[0].id);
360
-
361
- expect(pending[0].request.signal.aborted).toBe(true);
362
- expect(await done).toEqual([]);
363
- expect(store.getSnapshot().items).toHaveLength(0);
364
- });
365
-
366
- it("uploads on add with autoUpload", async () => {
367
- const { transport, calls } = instantTransport();
368
- const store = createUploaderStore({ transport, autoUpload: true });
369
- store.addFiles([file("a.txt", 3)]);
370
- await vi.waitFor(() => expect(store.getSnapshot().items[0].status).toBe("success"));
371
- expect(calls).toHaveLength(1);
372
- });
373
-
374
- it("marks files as failed when there is nothing to send them with", async () => {
375
- const store = createUploaderStore();
376
- store.addFiles([file("a.txt")]);
377
- const [item] = await store.upload();
378
- expect(item.status).toBe("error");
379
- expect(item.error).toMatch(/endpoint or a transport/);
380
- });
381
-
382
- it("publishes progress once per whole percent", async () => {
383
- const listener = vi.fn();
384
- const transport: Transport = async (request) => {
385
- for (let sent = 0; sent <= request.chunk.size; sent += 10) request.onProgress(sent);
386
- return {};
387
- };
388
- const store = createUploaderStore({ transport, chunkSize: 100_000 });
389
- store.addFiles([file("a.bin", 100_000)]);
390
- store.subscribe(listener);
391
- await store.upload();
392
- // 10,000 progress calls; roughly one publish per percent plus the status changes.
393
- expect(listener.mock.calls.length).toBeLessThan(120);
394
- });
395
- });
@@ -1,75 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { countCharacters } from "../core/count";
3
- import {
4
- activeCell,
5
- cellAt,
6
- otpInputMode,
7
- otpPattern,
8
- sanitizeOtp,
9
- separatorsAfter,
10
- } from "../core/otp";
11
-
12
- describe("character count", () => {
13
- it("counts what a person sees, not UTF-16 units", () => {
14
- expect(countCharacters("")).toBe(0);
15
- expect(countCharacters("hello")).toBe(5);
16
- expect("👍🏽".length).toBe(4);
17
- expect(countCharacters("👍🏽")).toBe(1);
18
- expect(countCharacters("é")).toBe(1);
19
- expect(countCharacters("👨‍👩‍👧 ok")).toBe(4);
20
- });
21
- });
22
-
23
- describe("otp sanitizing", () => {
24
- it("keeps digits only, up to the length", () => {
25
- expect(sanitizeOtp("12a3", 6)).toBe("123");
26
- expect(sanitizeOtp("Your code: 123-456", 6)).toBe("123456");
27
- expect(sanitizeOtp("1234567890", 6)).toBe("123456");
28
- });
29
-
30
- it("supports letters, with optional uppercasing", () => {
31
- expect(sanitizeOtp("ab-12 cd", 6, "alphanumeric")).toBe("ab12cd");
32
- expect(sanitizeOtp("ab-12 cd", 6, "alphanumeric", true)).toBe("AB12CD");
33
- expect(sanitizeOtp("a1b2c3", 6, "alphabetic")).toBe("abc");
34
- expect(sanitizeOtp("é1", 6, "alphanumeric")).toBe("1");
35
- });
36
-
37
- it("describes the field for keyboards and native validation", () => {
38
- expect(otpInputMode("numeric")).toBe("numeric");
39
- expect(otpInputMode("alphanumeric")).toBe("text");
40
- expect(otpPattern("numeric", 6)).toBe("\\d{6}");
41
- expect(new RegExp(`^${otpPattern("alphanumeric", 4)}$`).test("aZ09")).toBe(true);
42
- expect(new RegExp(`^${otpPattern("alphabetic", 4)}$`).test("ab1c")).toBe(false);
43
- });
44
- });
45
-
46
- describe("otp layout", () => {
47
- it("places separators between groups", () => {
48
- expect([...separatorsAfter([3, 3], 6)]).toEqual([2]);
49
- expect([...separatorsAfter([2, 2, 2], 6)]).toEqual([1, 3]);
50
- expect([...separatorsAfter(undefined, 6)]).toEqual([]);
51
- expect([...separatorsAfter([6], 6)]).toEqual([]);
52
- });
53
-
54
- it("puts the caret where the next character goes, or on the last cell when full", () => {
55
- expect(activeCell(0, 6, 0)).toBe(0);
56
- expect(activeCell(3, 6, 3)).toBe(3);
57
- expect(activeCell(3, 6, 1)).toBe(1);
58
- expect(activeCell(3, 6, 5)).toBe(3);
59
- expect(activeCell(6, 6, 6)).toBe(5);
60
- expect(activeCell(6, 6, 2)).toBe(2);
61
- });
62
-
63
- it("finds the cell under the pointer", () => {
64
- const cells = [
65
- { left: 0, right: 40 },
66
- { left: 48, right: 88 },
67
- { left: 96, right: 136 },
68
- ];
69
- expect(cellAt(cells, 10)).toBe(0);
70
- expect(cellAt(cells, 44)).toBe(1);
71
- expect(cellAt(cells, 120)).toBe(2);
72
- expect(cellAt(cells, 500)).toBe(2);
73
- expect(cellAt([], 10)).toBe(0);
74
- });
75
- });
@@ -1,120 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- edgeIndex,
4
- isTypeaheadKey,
5
- nextIndex,
6
- typeaheadBuffer,
7
- typeaheadIndex,
8
- } from "../core/navigation";
9
-
10
- const items = (...text: string[]) => text.map((label) => ({ text: label }));
11
-
12
- const withDisabled = [
13
- { text: "Cut" },
14
- { text: "Copy", disabled: true },
15
- { text: "Paste" },
16
- { text: "Paste special", disabled: true },
17
- ];
18
-
19
- describe("nextIndex", () => {
20
- it("steps forwards and backwards", () => {
21
- const list = items("a", "b", "c");
22
- expect(nextIndex(list, 0, 1)).toBe(1);
23
- expect(nextIndex(list, 2, -1)).toBe(1);
24
- });
25
-
26
- it("wraps around, which is what a menu does", () => {
27
- const list = items("a", "b", "c");
28
- expect(nextIndex(list, 2, 1)).toBe(0);
29
- expect(nextIndex(list, 0, -1)).toBe(2);
30
- });
31
-
32
- it("skips disabled items in both directions", () => {
33
- expect(nextIndex(withDisabled, 0, 1)).toBe(2);
34
- expect(nextIndex(withDisabled, 2, -1)).toBe(0);
35
- });
36
-
37
- it("starts at the near end when nothing is focused", () => {
38
- const list = items("a", "b", "c");
39
- expect(nextIndex(list, -1, 1)).toBe(0);
40
- expect(nextIndex(list, -1, -1)).toBe(2);
41
- });
42
-
43
- it("gives up rather than landing on a disabled item", () => {
44
- expect(nextIndex([{ text: "only", disabled: true }], 0, 1)).toBe(-1);
45
- expect(nextIndex([], 0, 1)).toBe(-1);
46
- });
47
- });
48
-
49
- describe("edgeIndex", () => {
50
- it("finds the first and last item that can be focused", () => {
51
- expect(edgeIndex(withDisabled, "first")).toBe(0);
52
- expect(edgeIndex(withDisabled, "last")).toBe(2);
53
- });
54
-
55
- it("is -1 when nothing can be focused", () => {
56
- expect(edgeIndex([{ text: "x", disabled: true }], "first")).toBe(-1);
57
- });
58
- });
59
-
60
- describe("typeaheadIndex", () => {
61
- const list = items("Save", "Save as", "Send", "Print");
62
-
63
- it("jumps to the first item starting with the letter", () => {
64
- expect(typeaheadIndex(list, "p", 0)).toBe(3);
65
- });
66
-
67
- it("ignores case and leading space", () => {
68
- expect(typeaheadIndex(list, " SE", 0)).toBe(2);
69
- });
70
-
71
- it("cycles through items sharing a letter when it is repeated", () => {
72
- expect(typeaheadIndex(list, "s", -1)).toBe(0);
73
- expect(typeaheadIndex(list, "ss", 0)).toBe(1);
74
- expect(typeaheadIndex(list, "sss", 1)).toBe(2);
75
- // …and back round to the first.
76
- expect(typeaheadIndex(list, "ssss", 2)).toBe(0);
77
- });
78
-
79
- it("treats a longer string as a prefix, refining from where it is", () => {
80
- expect(typeaheadIndex(list, "save a", 0)).toBe(1);
81
- });
82
-
83
- it("stays put when nothing matches", () => {
84
- expect(typeaheadIndex(list, "z", 0)).toBe(-1);
85
- expect(typeaheadIndex(list, "", 0)).toBe(-1);
86
- });
87
-
88
- it("never lands on a disabled item", () => {
89
- // Only the disabled "Copy" starts with "co", so focus stays where it is.
90
- expect(typeaheadIndex(withDisabled, "co", 0)).toBe(-1);
91
- // "c" also matches the enabled "Cut", which it wraps round to find.
92
- expect(typeaheadIndex(withDisabled, "c", 0)).toBe(0);
93
- });
94
- });
95
-
96
- describe("typeaheadBuffer", () => {
97
- it("builds a word from keys typed in quick succession", () => {
98
- expect(typeaheadBuffer("s", "a", 120)).toBe("sa");
99
- });
100
-
101
- it("starts again after a pause", () => {
102
- expect(typeaheadBuffer("sa", "p", 900)).toBe("p");
103
- });
104
- });
105
-
106
- describe("isTypeaheadKey", () => {
107
- it("takes printable characters", () => {
108
- expect(isTypeaheadKey("a", false)).toBe(true);
109
- expect(isTypeaheadKey("7", false)).toBe(true);
110
- });
111
-
112
- it("leaves Space alone, since it chooses an item", () => {
113
- expect(isTypeaheadKey(" ", false)).toBe(false);
114
- });
115
-
116
- it("ignores named keys and anything with a modifier", () => {
117
- expect(isTypeaheadKey("ArrowDown", false)).toBe(false);
118
- expect(isTypeaheadKey("a", true)).toBe(false);
119
- });
120
- });
@@ -1,55 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { canDismiss, confirmClose, isOutside } from "../core/dismiss";
3
-
4
- describe("dismiss rules", () => {
5
- it("allows Escape and backdrop by default, and lets each be turned off", () => {
6
- expect(canDismiss("escape")).toBe(true);
7
- expect(canDismiss("backdrop")).toBe(true);
8
- expect(canDismiss("escape", { closeOnEscape: false })).toBe(false);
9
- expect(canDismiss("backdrop", { closeOnBackdrop: false })).toBe(false);
10
- });
11
-
12
- it("always allows the close button and code", () => {
13
- const locked = { closeOnEscape: false, closeOnBackdrop: false };
14
- expect(canDismiss("close-button", locked)).toBe(true);
15
- expect(canDismiss("api", locked)).toBe(true);
16
- });
17
- });
18
-
19
- describe("backdrop hit test", () => {
20
- const rect = { left: 100, top: 50, right: 500, bottom: 350 };
21
-
22
- it("treats points outside the box as the backdrop", () => {
23
- expect(isOutside(rect, 50, 200)).toBe(true);
24
- expect(isOutside(rect, 300, 400)).toBe(true);
25
- expect(isOutside(rect, 300, 200)).toBe(false);
26
- });
27
-
28
- it("counts the edges as inside", () => {
29
- expect(isOutside(rect, 100, 50)).toBe(false);
30
- expect(isOutside(rect, 500, 350)).toBe(false);
31
- });
32
- });
33
-
34
- describe("close guard", () => {
35
- it("closes without a guard", async () => {
36
- expect(await confirmClose(undefined, "escape")).toBe(true);
37
- });
38
-
39
- it("stays open only on an explicit false", async () => {
40
- expect(await confirmClose(() => true, "escape")).toBe(true);
41
- expect(await confirmClose(() => false, "escape")).toBe(false);
42
- expect(await confirmClose(() => Promise.resolve(false), "backdrop")).toBe(false);
43
- expect(await confirmClose(() => undefined as unknown as boolean, "api")).toBe(true);
44
- });
45
-
46
- it("passes the reason and stays open when the guard throws", async () => {
47
- const reasons: string[] = [];
48
- await confirmClose((reason) => {
49
- reasons.push(reason);
50
- return true;
51
- }, "close-button");
52
- expect(reasons).toEqual(["close-button"]);
53
- expect(await confirmClose(() => Promise.reject(new Error("save failed")), "api")).toBe(false);
54
- });
55
- });