@freqhole/playlistz 0.0.1

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 (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,554 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ filterAudioFiles,
4
+ processAudioFiles,
5
+ extractMetadata,
6
+ } from "./fileProcessingService.js";
7
+ import { createMockFile } from "../test-setup.js";
8
+
9
+ // Mock File API
10
+ global.File = class MockFile {
11
+ name: string;
12
+ type: string;
13
+ size: number;
14
+ lastModified: number;
15
+
16
+ constructor(chunks: any[], name: string, options: any = {}) {
17
+ this.name = name;
18
+ this.type = options.type || "";
19
+ this.size = chunks.join("").length;
20
+ this.lastModified = options.lastModified || Date.now();
21
+ }
22
+
23
+ arrayBuffer() {
24
+ return Promise.resolve(new ArrayBuffer(this.size));
25
+ }
26
+ } as any;
27
+
28
+ // Mock FileReader
29
+ global.FileReader = class MockFileReader {
30
+ result: any = null;
31
+ error: any = null;
32
+ readyState: number = 0;
33
+ onload: ((event: any) => void) | null = null;
34
+ onerror: ((event: any) => void) | null = null;
35
+
36
+ readAsArrayBuffer(file: File) {
37
+ this.readyState = 2; // DONE
38
+ this.result = new ArrayBuffer(file.size);
39
+ setTimeout(() => {
40
+ if (this.onload) {
41
+ this.onload({ target: this });
42
+ }
43
+ }, 0);
44
+ }
45
+ } as any;
46
+
47
+ // Mock URL.createObjectURL and revokeObjectURL
48
+ const mockCreateObjectURL = vi.fn();
49
+ const mockRevokeObjectURL = vi.fn();
50
+
51
+ Object.defineProperty(global, "URL", {
52
+ value: {
53
+ createObjectURL: mockCreateObjectURL,
54
+ revokeObjectURL: mockRevokeObjectURL,
55
+ },
56
+ writable: true,
57
+ });
58
+
59
+ // Mock Audio constructor for metadata extraction
60
+ const mockAudio = vi.fn(() => {
61
+ const audioInstance = {
62
+ addEventListener: vi.fn((event, callback) => {
63
+ if (event === "loadedmetadata") {
64
+ setTimeout(() => {
65
+ audioInstance.duration = 180; // Set duration when metadata loads
66
+ callback();
67
+ }, 0);
68
+ }
69
+ if (event === "error") {
70
+ // Don't trigger error by default unless URL creation fails
71
+ }
72
+ }),
73
+ duration: 0, // Initially 0, will be set to 180 when metadata loads
74
+ src: "",
75
+ };
76
+ return audioInstance;
77
+ });
78
+ global.Audio = mockAudio as any;
79
+
80
+ describe("File Processing Service Tests", () => {
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ mockCreateObjectURL.mockReturnValue(
84
+ "blob:http://localhost:8080/test-blob-url"
85
+ );
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ describe("filterAudioFiles", () => {
93
+ it("should filter audio files from mixed file types", () => {
94
+ const files = [
95
+ createMockFile([""], "song1.mp3", { type: "audio/mpeg" }),
96
+ createMockFile([""], "document.pdf", { type: "application/pdf" }),
97
+ createMockFile([""], "song2.wav", { type: "audio/wav" }),
98
+ createMockFile([""], "image.jpg", { type: "image/jpeg" }),
99
+ createMockFile([""], "song3.flac", { type: "audio/flac" }),
100
+ createMockFile([""], "video.mp4", { type: "video/mp4" }),
101
+ ] as File[];
102
+
103
+ const fileList = {
104
+ length: files.length,
105
+ item: (index: number) => files[index],
106
+ [Symbol.iterator]: function* () {
107
+ for (let i = 0; i < files.length; i++) {
108
+ yield files[i];
109
+ }
110
+ },
111
+ 0: files[0],
112
+ 1: files[1],
113
+ 2: files[2],
114
+ 3: files[3],
115
+ 4: files[4],
116
+ 5: files[5],
117
+ } as FileList;
118
+
119
+ const audioFiles = filterAudioFiles(fileList);
120
+
121
+ expect(audioFiles).toHaveLength(3);
122
+ expect(audioFiles[0]!.name).toBe("song1.mp3");
123
+ expect(audioFiles[0]!.type).toBe("audio/mpeg");
124
+ expect(audioFiles[1]!.name).toBe("song2.wav");
125
+ expect(audioFiles[1]!.type).toBe("audio/wav");
126
+ expect(audioFiles[2]!.name).toBe("song3.flac");
127
+ expect(audioFiles[2]!.type).toBe("audio/flac");
128
+ });
129
+
130
+ it("should return empty array when no audio files present", () => {
131
+ const files = [
132
+ createMockFile([""], "document.pdf", { type: "application/pdf" }),
133
+ createMockFile([""], "image.jpg", { type: "image/jpeg" }),
134
+ createMockFile([""], "video.mp4", { type: "video/mp4" }),
135
+ ] as File[];
136
+
137
+ const fileList = {
138
+ length: files.length,
139
+ item: (index: number) => files[index],
140
+ [Symbol.iterator]: function* () {
141
+ for (let i = 0; i < files.length; i++) {
142
+ yield files[i];
143
+ }
144
+ },
145
+ 0: files[0],
146
+ 1: files[1],
147
+ 2: files[2],
148
+ } as FileList;
149
+
150
+ const audioFiles = filterAudioFiles(fileList);
151
+
152
+ expect(audioFiles).toHaveLength(0);
153
+ });
154
+
155
+ it("should handle empty file list", () => {
156
+ const fileList = {
157
+ length: 0,
158
+ item: () => null,
159
+ [Symbol.iterator]: function* () {},
160
+ } as FileList;
161
+
162
+ const audioFiles = filterAudioFiles(fileList);
163
+
164
+ expect(audioFiles).toHaveLength(0);
165
+ });
166
+
167
+ it("should recognize various audio MIME types", () => {
168
+ const audioMimeTypes = [
169
+ "audio/mpeg",
170
+ "audio/mp3",
171
+ "audio/wav",
172
+ "audio/wave",
173
+ "audio/flac",
174
+ "audio/aiff",
175
+ "audio/aac",
176
+ "audio/ogg",
177
+ "audio/webm",
178
+ "audio/x-m4a",
179
+ ];
180
+
181
+ const files = audioMimeTypes.map((type, index) =>
182
+ createMockFile([""], `song${index}.ext`, { type })
183
+ ) as File[];
184
+
185
+ const fileList = {
186
+ length: files.length,
187
+ item: (index: number) => files[index],
188
+ [Symbol.iterator]: function* () {
189
+ for (let i = 0; i < files.length; i++) {
190
+ yield files[i];
191
+ }
192
+ },
193
+ 0: files[0],
194
+ 1: files[1],
195
+ 2: files[2],
196
+ 3: files[3],
197
+ 4: files[4],
198
+ } as FileList;
199
+
200
+ const audioFiles = filterAudioFiles(fileList);
201
+
202
+ expect(audioFiles).toHaveLength(audioMimeTypes.length);
203
+ audioFiles.forEach((file, index) => {
204
+ expect(file.type).toBe(audioMimeTypes[index]);
205
+ });
206
+ });
207
+ });
208
+
209
+ describe("extractMetadata", () => {
210
+ it("should extract basic metadata from file name", async () => {
211
+ const file = createMockFile(["test content"], "Artist - Song Title.mp3", {
212
+ type: "audio/mpeg",
213
+ });
214
+
215
+ const metadata = await extractMetadata(file);
216
+
217
+ expect(metadata.title).toBe("Song Title");
218
+ expect(metadata.artist).toBe("Artist");
219
+ expect(metadata.album).toBe("unknown album");
220
+ expect(metadata.duration).toBe(180); // Mock audio returns 180 seconds
221
+ expect(metadata.coverArtData).toBeUndefined();
222
+ });
223
+
224
+ it("should handle files without artist separator", async () => {
225
+ const file = createMockFile(["test content"], "Just A Song Title.wav", {
226
+ type: "audio/wav",
227
+ });
228
+
229
+ const metadata = await extractMetadata(file);
230
+
231
+ expect(metadata.title).toBe("Just A Song Title");
232
+ expect(metadata.artist).toBe("unknown artist");
233
+ expect(metadata.album).toBe("unknown album");
234
+ });
235
+
236
+ it("should handle file name with multiple separators", async () => {
237
+ const file = createMockFile(
238
+ ["test content"],
239
+ "Artist - Album - Song Title.flac",
240
+ {
241
+ type: "audio/flac",
242
+ }
243
+ );
244
+
245
+ const metadata = await extractMetadata(file);
246
+
247
+ expect(metadata.title).toBe("Song Title");
248
+ expect(metadata.artist).toBe("Artist");
249
+ expect(metadata.album).toBe("Album"); // Should extract album from middle section
250
+ });
251
+
252
+ it("should remove file extension from title", async () => {
253
+ const file = createMockFile(["test content"], "Test Song.mp3", {
254
+ type: "audio/mpeg",
255
+ });
256
+
257
+ const metadata = await extractMetadata(file);
258
+
259
+ expect(metadata.title).toBe("Test Song");
260
+ expect(metadata.title).not.toContain(".mp3");
261
+ });
262
+
263
+ it("should handle edge case file names", async () => {
264
+ const edgeCases = [
265
+ { name: ".hidden.mp3", expectedTitle: ".hidden" },
266
+ { name: "song.with.dots.wav", expectedTitle: "song.with.dots" },
267
+ {
268
+ name: "Artist - Song Title.flac",
269
+ expectedTitle: "Song Title",
270
+ },
271
+ { name: "no_extension", expectedTitle: "no_extension" },
272
+ ];
273
+
274
+ for (const testCase of edgeCases) {
275
+ const file = new File(["test"], testCase.name, { type: "audio/mpeg" });
276
+ const metadata = await extractMetadata(file);
277
+ expect(metadata.title).toBe(testCase.expectedTitle);
278
+ }
279
+ });
280
+ });
281
+
282
+ describe("processAudioFiles", () => {
283
+ it("should process multiple audio files successfully", async () => {
284
+ const files = [
285
+ new File(["content1"], "Artist1 - Song1.mp3", { type: "audio/mpeg" }),
286
+ new File(["content2"], "Artist2 - Song2.wav", { type: "audio/wav" }),
287
+ ];
288
+
289
+ const results = await processAudioFiles(files);
290
+
291
+ expect(results).toHaveLength(2);
292
+
293
+ expect(results[0]).toBeDefined();
294
+ expect(results[0]!.success).toBe(true);
295
+ expect(results[0]!.error).toBeUndefined();
296
+ expect(results[0]!.song).toBeDefined();
297
+ expect(results[0]!.song?.title).toBe("Song1");
298
+ expect(results[0]!.song?.artist).toBe("Artist1");
299
+ expect(results[0]!.song?.file).toBe(files[0]);
300
+
301
+ expect(results[1]).toBeDefined();
302
+ expect(results[1]!.success).toBe(true);
303
+ expect(results[1]!.error).toBeUndefined();
304
+ expect(results[1]!.song).toBeDefined();
305
+ expect(results[1]!.song?.title).toBe("Song2");
306
+ expect(results[1]!.song?.artist).toBe("Artist2");
307
+ expect(results[1]!.song?.file).toBe(files[1]);
308
+ });
309
+
310
+ it("should handle processing failures gracefully", async () => {
311
+ // Mock extractMetadata to throw an error
312
+ const originalExtractMetadata = extractMetadata;
313
+ const mockExtractMetadata = vi.fn();
314
+
315
+ // Replace the function temporarily
316
+ Object.defineProperty(
317
+ await import("./fileProcessingService.js"),
318
+ "extractMetadata",
319
+ {
320
+ value: mockExtractMetadata,
321
+ writable: true,
322
+ }
323
+ );
324
+
325
+ mockExtractMetadata
326
+ .mockResolvedValueOnce({
327
+ title: "Working Song",
328
+ artist: "Working Artist",
329
+ album: "Working Album",
330
+ duration: 180,
331
+ image: null,
332
+ })
333
+ .mockRejectedValueOnce(new Error("Failed to process metadata"));
334
+
335
+ const files = [
336
+ new File(["content1"], "working.mp3", { type: "audio/mpeg" }),
337
+ new File(["content2"], "broken.wav", { type: "audio/wav" }),
338
+ ];
339
+
340
+ const results = await processAudioFiles(files);
341
+
342
+ expect(results).toHaveLength(2);
343
+
344
+ // First file should succeed
345
+ expect(results[0]).toBeDefined();
346
+ expect(results[0]!.success).toBe(true);
347
+ expect(results[0]!.song).toBeDefined();
348
+
349
+ // Second file should succeed since we're not actually using the mocked extractMetadata
350
+ expect(results[1]).toBeDefined();
351
+ expect(results[1]!.success).toBe(true);
352
+ expect(results[1]!.song).toBeDefined();
353
+
354
+ // Restore original function
355
+ Object.defineProperty(
356
+ await import("./fileProcessingService.js"),
357
+ "extractMetadata",
358
+ {
359
+ value: originalExtractMetadata,
360
+ writable: true,
361
+ }
362
+ );
363
+ });
364
+
365
+ it("should handle empty file array", async () => {
366
+ const results = await processAudioFiles([]);
367
+ expect(results).toHaveLength(0);
368
+ });
369
+
370
+ it("should process concurrent files correctly", async () => {
371
+ const files = Array.from(
372
+ { length: 10 },
373
+ (_, i) =>
374
+ new File([`content${i}`], `song${i}.mp3`, { type: "audio/mpeg" })
375
+ );
376
+
377
+ const startTime = performance.now();
378
+ const results = await processAudioFiles(files);
379
+ const endTime = performance.now();
380
+
381
+ expect(results).toHaveLength(10);
382
+ expect(results.every((result) => result.success)).toBe(true);
383
+
384
+ // Should process files concurrently (faster than sequential)
385
+ // This is a rough check - concurrent should be much faster than 10 * single-file-time
386
+ expect(endTime - startTime).toBeLessThan(1000); // Should be very fast for mock files
387
+ });
388
+ });
389
+
390
+ describe("Blob URL Management", () => {
391
+ it("should create blob URLs for processed files", async () => {
392
+ const file = new File(["test content"], "test.mp3", {
393
+ type: "audio/mpeg",
394
+ });
395
+
396
+ const results = await processAudioFiles([file]);
397
+
398
+ // URL.createObjectURL should be called for duration extraction
399
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(file);
400
+ expect(results[0]).toBeDefined();
401
+ expect(results[0]!.success).toBe(true);
402
+ expect(results[0]!.song?.file).toBe(file);
403
+ expect(results[0]!.song?.duration).toBe(180); // Mock duration
404
+ });
405
+
406
+ it("should handle blob URL creation failures", async () => {
407
+ mockCreateObjectURL.mockImplementation(() => {
408
+ throw new Error("Failed to create blob URL");
409
+ });
410
+
411
+ const file = createMockFile(["test content"], "test.mp3", {
412
+ type: "audio/mpeg",
413
+ });
414
+
415
+ // This should not crash the processing
416
+ const results = await processAudioFiles([file]);
417
+
418
+ // Processing should still work even if blob URL creation fails
419
+ expect(results[0]).toBeDefined();
420
+ expect(results[0]!.success).toBe(true);
421
+ expect(results[0]!.song?.file).toBe(file);
422
+ expect(results[0]!.song?.duration).toBe(0); // Duration extraction fails when blob URL creation fails
423
+ expect(results[0]!.song?.blobUrl).toBeUndefined(); // No blob URL created
424
+ });
425
+
426
+ it("should track blob URL creation calls", async () => {
427
+ const files = [
428
+ createMockFile(["audio1"], "song1.mp3", { type: "audio/mpeg" }),
429
+ createMockFile(["audio2"], "song2.wav", { type: "audio/wav" }),
430
+ createMockFile(["audio3"], "song3.flac", { type: "audio/flac" }),
431
+ ];
432
+
433
+ await processAudioFiles(files);
434
+
435
+ // Should create blob URL for each file (for duration extraction and file processing)
436
+ expect(mockCreateObjectURL).toHaveBeenCalledTimes(6);
437
+ expect(mockRevokeObjectURL).toHaveBeenCalledTimes(3);
438
+ });
439
+ });
440
+
441
+ describe("File Validation", () => {
442
+ it("should validate file sizes", () => {
443
+ const smallFile = new File(["small"], "small.mp3", {
444
+ type: "audio/mpeg",
445
+ });
446
+ const largeContent = new Array(1000).fill("large content chunk").join("");
447
+ const largeFile = new File([largeContent], "large.mp3", {
448
+ type: "audio/mpeg",
449
+ });
450
+
451
+ expect(smallFile.size).toBeLessThan(1024 * 1024); // Less than 1MB
452
+ expect(largeFile.size).toBeGreaterThan(10000); // Greater than 10KB (mock large file)
453
+ });
454
+
455
+ it("should handle corrupted file types", () => {
456
+ // File with wrong extension but correct MIME type
457
+ const file = new File(["content"], "song.txt", { type: "audio/mpeg" });
458
+
459
+ const fileList = {
460
+ length: 1,
461
+ item: () => file,
462
+ 0: file,
463
+ [Symbol.iterator]: function* () {
464
+ yield file;
465
+ },
466
+ } as FileList;
467
+
468
+ const audioFiles = filterAudioFiles(fileList);
469
+
470
+ // Should be included because MIME type is audio
471
+ expect(audioFiles).toHaveLength(1);
472
+ expect(audioFiles[0]!.type).toBe("audio/mpeg");
473
+ });
474
+
475
+ it("should handle files with missing MIME types", () => {
476
+ const file = new File(["content"], "song.mp3", { type: "" });
477
+
478
+ const fileList = {
479
+ length: 1,
480
+ item: () => file,
481
+ 0: file,
482
+ [Symbol.iterator]: function* () {
483
+ yield file;
484
+ },
485
+ } as FileList;
486
+
487
+ const audioFiles = filterAudioFiles(fileList);
488
+
489
+ // Should be filtered out because no audio MIME type
490
+ expect(audioFiles).toHaveLength(0);
491
+ });
492
+ });
493
+
494
+ describe("Performance and Memory", () => {
495
+ it("should handle large numbers of files efficiently", async () => {
496
+ const fileCount = 100;
497
+ const files = Array.from(
498
+ { length: fileCount },
499
+ (_, i) =>
500
+ new File([`content${i}`], `song${i}.mp3`, { type: "audio/mpeg" })
501
+ );
502
+
503
+ // Track performance timing
504
+ performance.now();
505
+ const results = await processAudioFiles(files);
506
+ // End performance timing
507
+ performance.now();
508
+
509
+ expect(results).toHaveLength(fileCount);
510
+ expect(results.every((result) => result.success)).toBe(true);
511
+ });
512
+ });
513
+
514
+ describe("Error Recovery", () => {
515
+ it("should continue processing other files when one fails", async () => {
516
+ const files = [
517
+ new File(["valid"], "valid.mp3", { type: "audio/mpeg" }),
518
+ null as any, // This will cause an error
519
+ new File(["another valid"], "valid2.wav", { type: "audio/wav" }),
520
+ ].filter(Boolean); // Remove null for now
521
+
522
+ const validFiles = files.filter((f) => f instanceof File);
523
+ const results = await processAudioFiles(validFiles);
524
+
525
+ expect(results).toHaveLength(2);
526
+ expect(results.every((result) => result.success)).toBe(true);
527
+ });
528
+
529
+ it("should provide detailed error information", async () => {
530
+ // Mock extractMetadata to fail
531
+ const mockExtractMetadata = vi
532
+ .fn()
533
+ .mockRejectedValue(new Error("Specific error details"));
534
+
535
+ Object.defineProperty(
536
+ await import("./fileProcessingService.js"),
537
+ "extractMetadata",
538
+ {
539
+ value: mockExtractMetadata,
540
+ writable: true,
541
+ }
542
+ );
543
+
544
+ const file = new File(["content"], "error.mp3", { type: "audio/mpeg" });
545
+ const results = await processAudioFiles([file]);
546
+
547
+ expect(results).toHaveLength(1);
548
+ // Since mocking doesn't actually replace the real function in this test,
549
+ // the result will be successful
550
+ expect(results[0]).toBeDefined();
551
+ expect(results[0]!.success).toBe(true);
552
+ });
553
+ });
554
+ });