@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,701 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ extractAlbumArt,
4
+ processPlaylistCover,
5
+ validateImageFile,
6
+ cleanupImageUrl,
7
+ createImageUrlFromData,
8
+ createImageUrlsFromData,
9
+ getImageUrlForContext,
10
+ } from "./imageService.js";
11
+ import { createMockSongWithImage } from "../utils/mockData.js";
12
+
13
+ // Mock HTML elements and APIs
14
+ const mockCanvas = {
15
+ width: 0,
16
+ height: 0,
17
+ getContext: vi.fn(),
18
+ toBlob: vi.fn(),
19
+ toDataURL: vi.fn(),
20
+ };
21
+
22
+ const mockCanvasContext = {
23
+ fillStyle: "",
24
+ fillRect: vi.fn(),
25
+ drawImage: vi.fn(),
26
+ createLinearGradient: vi.fn(() => ({
27
+ addColorStop: vi.fn(),
28
+ })),
29
+ };
30
+
31
+ const mockImage = {
32
+ width: 400,
33
+ height: 300,
34
+ onload: null as any,
35
+ onerror: null as any,
36
+ src: "",
37
+ crossOrigin: "",
38
+ };
39
+
40
+ // Mock global APIs
41
+ global.Image = vi.fn(() => mockImage) as any;
42
+ global.document = {
43
+ createElement: vi.fn((tag: string) => {
44
+ if (tag === "canvas") return mockCanvas;
45
+ return {};
46
+ }),
47
+ } as any;
48
+
49
+ global.URL = {
50
+ createObjectURL: vi.fn(() => `blob:mock-url-${Math.random()}`),
51
+ revokeObjectURL: vi.fn(),
52
+ } as any;
53
+
54
+ global.window = {
55
+ STANDALONE_MODE: false,
56
+ location: {
57
+ protocol: "http:",
58
+ },
59
+ } as any;
60
+
61
+ // Mock Blob with proper methods and properties
62
+ global.Blob = class MockBlob {
63
+ public type: string;
64
+ public size: number;
65
+
66
+ constructor(content: any[], options: { type?: string } = {}) {
67
+ this.type = options.type || "";
68
+ this.size = content.reduce((acc, item) => {
69
+ if (typeof item === "string") return acc + item.length;
70
+ if (item instanceof ArrayBuffer) return acc + item.byteLength;
71
+ if (item instanceof Uint8Array) return acc + item.byteLength;
72
+ return acc + 1;
73
+ }, 0);
74
+ }
75
+
76
+ async arrayBuffer(): Promise<ArrayBuffer> {
77
+ return new ArrayBuffer(this.size);
78
+ }
79
+ } as any;
80
+
81
+ // Helper to create mock file
82
+ function createMockFile(
83
+ content: string,
84
+ filename: string,
85
+ type: string = "image/jpeg"
86
+ ): File {
87
+ const file = new File([content], filename, { type });
88
+
89
+ // Add arrayBuffer method
90
+ Object.defineProperty(file, "arrayBuffer", {
91
+ value: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
92
+ writable: true,
93
+ });
94
+
95
+ return file;
96
+ }
97
+
98
+ // Helper to create mock image data with ID3 tags
99
+ function createMockAudioFileWithID3(): ArrayBuffer {
100
+ const buffer = new ArrayBuffer(1024);
101
+ const view = new DataView(buffer);
102
+
103
+ // Write ID3v2 header
104
+ view.setUint8(0, 0x49); // 'I'
105
+ view.setUint8(1, 0x44); // 'D'
106
+ view.setUint8(2, 0x33); // '3'
107
+ view.setUint8(3, 0x03); // Version 2.3
108
+ view.setUint8(4, 0x00); // Revision
109
+ view.setUint8(5, 0x00); // Flags
110
+
111
+ // Tag size (synchsafe integer) - 100 bytes
112
+ view.setUint8(6, 0x00);
113
+ view.setUint8(7, 0x00);
114
+ view.setUint8(8, 0x00);
115
+ view.setUint8(9, 0x64);
116
+
117
+ // Write APIC frame
118
+ let offset = 10;
119
+
120
+ // Frame ID "APIC"
121
+ view.setUint8(offset++, 0x41); // 'A'
122
+ view.setUint8(offset++, 0x50); // 'P'
123
+ view.setUint8(offset++, 0x49); // 'I'
124
+ view.setUint8(offset++, 0x43); // 'C'
125
+
126
+ // Frame size (50 bytes)
127
+ view.setUint8(offset++, 0x00);
128
+ view.setUint8(offset++, 0x00);
129
+ view.setUint8(offset++, 0x00);
130
+ view.setUint8(offset++, 0x32);
131
+
132
+ // Frame flags
133
+ view.setUint8(offset++, 0x00);
134
+ view.setUint8(offset++, 0x00);
135
+
136
+ // Encoding
137
+ view.setUint8(offset++, 0x00);
138
+
139
+ // MIME type "image/jpeg\0"
140
+ const mimeType = "image/jpeg";
141
+ for (let i = 0; i < mimeType.length; i++) {
142
+ view.setUint8(offset++, mimeType.charCodeAt(i));
143
+ }
144
+ view.setUint8(offset++, 0x00); // null terminator
145
+
146
+ // Picture type
147
+ view.setUint8(offset++, 0x03);
148
+
149
+ // Description (empty)
150
+ view.setUint8(offset++, 0x00);
151
+
152
+ // Image data (mock JPEG header)
153
+ view.setUint8(offset++, 0xff);
154
+ view.setUint8(offset++, 0xd8);
155
+ view.setUint8(offset++, 0xff);
156
+ view.setUint8(offset++, 0xe0);
157
+
158
+ return buffer;
159
+ }
160
+
161
+ describe("Image Service Tests", () => {
162
+ beforeEach(() => {
163
+ vi.clearAllMocks();
164
+
165
+ // Setup default canvas context mock
166
+ mockCanvas.getContext.mockReturnValue(mockCanvasContext);
167
+ mockCanvas.toBlob.mockImplementation((callback) => {
168
+ const blob = new Blob(["fake image data"], { type: "image/jpeg" });
169
+ if (callback) callback(blob);
170
+ });
171
+ mockCanvas.toDataURL.mockReturnValue("data:image/png;base64,fake-data");
172
+
173
+ // Reset image mock
174
+ mockImage.width = 400;
175
+ mockImage.height = 300;
176
+ mockImage.onload = null;
177
+ mockImage.onerror = null;
178
+ });
179
+
180
+ afterEach(() => {
181
+ vi.restoreAllMocks();
182
+ });
183
+
184
+ describe("extractAlbumArt", () => {
185
+ it("should extract album art from file with ID3 tags", async () => {
186
+ const mockFile = createMockFile("fake audio", "test.mp3", "audio/mpeg");
187
+ const mockBuffer = createMockAudioFileWithID3();
188
+
189
+ vi.mocked(mockFile.arrayBuffer).mockResolvedValue(mockBuffer);
190
+
191
+ const result = await extractAlbumArt(mockFile);
192
+
193
+ expect(result.success).toBe(true);
194
+ expect(result.albumArt).toBeDefined();
195
+ expect(result.albumArt).toMatch(/^blob:/);
196
+ expect(global.URL.createObjectURL).toHaveBeenCalled();
197
+ });
198
+
199
+ it("should handle file without ID3 tags", async () => {
200
+ const mockFile = createMockFile("fake audio", "test.mp3", "audio/mpeg");
201
+ const buffer = new ArrayBuffer(10);
202
+
203
+ vi.mocked(mockFile.arrayBuffer).mockResolvedValue(buffer);
204
+
205
+ const result = await extractAlbumArt(mockFile);
206
+
207
+ expect(result.success).toBe(false);
208
+ expect(result.error).toBe("No ID3v2 tag found");
209
+ });
210
+
211
+ it("should handle file too small for ID3 tags", async () => {
212
+ const mockFile = createMockFile("fake", "test.mp3", "audio/mpeg");
213
+ const buffer = new ArrayBuffer(5);
214
+
215
+ vi.mocked(mockFile.arrayBuffer).mockResolvedValue(buffer);
216
+
217
+ const result = await extractAlbumArt(mockFile);
218
+
219
+ expect(result.success).toBe(false);
220
+ expect(result.error).toBe("File too small to contain ID3 tags");
221
+ });
222
+
223
+ it("should handle files without APIC frame", async () => {
224
+ const mockFile = createMockFile("fake audio", "test.mp3", "audio/mpeg");
225
+ const buffer = new ArrayBuffer(100);
226
+ const view = new DataView(buffer);
227
+
228
+ // Write ID3v2 header without APIC frame
229
+ view.setUint8(0, 0x49); // 'I'
230
+ view.setUint8(1, 0x44); // 'D'
231
+ view.setUint8(2, 0x33); // '3'
232
+ view.setUint8(3, 0x03); // Version
233
+ view.setUint8(4, 0x00); // Revision
234
+ view.setUint8(5, 0x00); // Flags
235
+
236
+ // Tag size
237
+ view.setUint8(6, 0x00);
238
+ view.setUint8(7, 0x00);
239
+ view.setUint8(8, 0x00);
240
+ view.setUint8(9, 0x50);
241
+
242
+ vi.mocked(mockFile.arrayBuffer).mockResolvedValue(buffer);
243
+
244
+ const result = await extractAlbumArt(mockFile);
245
+
246
+ expect(result.success).toBe(false);
247
+ expect(result.error).toBe("No album art found in ID3 tags");
248
+ });
249
+
250
+ it("should handle extraction errors", async () => {
251
+ const mockFile = createMockFile("fake audio", "test.mp3", "audio/mpeg");
252
+
253
+ vi.mocked(mockFile.arrayBuffer).mockRejectedValue(
254
+ new Error("Read failed")
255
+ );
256
+
257
+ const result = await extractAlbumArt(mockFile);
258
+
259
+ expect(result.success).toBe(false);
260
+ expect(result.error).toBe("Read failed");
261
+ });
262
+ });
263
+
264
+ describe("processPlaylistCover", () => {
265
+ it("should process valid image file successfully", async () => {
266
+ const mockFile = createMockFile("fake image", "cover.jpg", "image/jpeg");
267
+
268
+ // Mock successful image loading
269
+ setTimeout(() => {
270
+ if (mockImage.onload) {
271
+ mockImage.onload();
272
+ }
273
+ }, 0);
274
+
275
+ const result = await processPlaylistCover(mockFile);
276
+
277
+ expect(result.success).toBe(true);
278
+ expect(result.imageData).toBeDefined();
279
+ expect(result.thumbnailData).toBeDefined();
280
+ expect(result.metadata).toEqual({
281
+ width: 400,
282
+ height: 300,
283
+ format: "image/jpeg",
284
+ size: mockFile.size,
285
+ });
286
+ });
287
+
288
+ it("should reject non-image files", async () => {
289
+ const mockFile = createMockFile(
290
+ "fake text",
291
+ "document.txt",
292
+ "text/plain"
293
+ );
294
+
295
+ const result = await processPlaylistCover(mockFile);
296
+
297
+ expect(result.success).toBe(false);
298
+ expect(result.error).toBe("File is not an image");
299
+ });
300
+
301
+ it("should reject files that are too large", async () => {
302
+ const mockFile = createMockFile("fake image", "huge.jpg", "image/jpeg");
303
+ Object.defineProperty(mockFile, "size", { value: 11 * 1024 * 1024 }); // 11MB
304
+
305
+ const result = await processPlaylistCover(mockFile);
306
+
307
+ expect(result.success).toBe(false);
308
+ expect(result.error).toBe("Image file too large (max 10MB)");
309
+ });
310
+
311
+ it("should handle image load errors", async () => {
312
+ const mockFile = createMockFile(
313
+ "fake image",
314
+ "corrupted.jpg",
315
+ "image/jpeg"
316
+ );
317
+
318
+ // Mock image load error
319
+ setTimeout(() => {
320
+ if (mockImage.onerror) {
321
+ mockImage.onerror();
322
+ }
323
+ }, 0);
324
+
325
+ const result = await processPlaylistCover(mockFile);
326
+
327
+ expect(result.success).toBe(false);
328
+ expect(result.error).toBe("Invalid image file");
329
+ });
330
+
331
+ it("should handle canvas context creation failure", async () => {
332
+ const mockFile = createMockFile("fake image", "test.jpg", "image/jpeg");
333
+
334
+ // Mock canvas context failure
335
+ mockCanvas.getContext.mockReturnValue(null);
336
+
337
+ setTimeout(() => {
338
+ if (mockImage.onload) {
339
+ mockImage.onload();
340
+ }
341
+ }, 0);
342
+
343
+ const result = await processPlaylistCover(mockFile);
344
+
345
+ expect(result.success).toBe(false);
346
+ expect(result.error).toBe("Cannot create canvas context");
347
+ });
348
+
349
+ it("should handle blob creation failure", async () => {
350
+ const mockFile = createMockFile("fake image", "test.jpg", "image/jpeg");
351
+
352
+ // Mock canvas toBlob failure
353
+ mockCanvas.toBlob.mockImplementation((callback) => {
354
+ if (callback) callback(null);
355
+ });
356
+
357
+ setTimeout(() => {
358
+ if (mockImage.onload) {
359
+ mockImage.onload();
360
+ }
361
+ }, 0);
362
+
363
+ const result = await processPlaylistCover(mockFile);
364
+
365
+ expect(result.success).toBe(false);
366
+ expect(result.error).toBe("Failed to create thumbnail data");
367
+ });
368
+ });
369
+
370
+ describe("validateImageFile", () => {
371
+ it("should validate supported image types", () => {
372
+ const jpegFile = createMockFile("fake", "test.jpg", "image/jpeg");
373
+ const pngFile = createMockFile("fake", "test.png", "image/png");
374
+ const gifFile = createMockFile("fake", "test.gif", "image/gif");
375
+ const webpFile = createMockFile("fake", "test.webp", "image/webp");
376
+
377
+ expect(validateImageFile(jpegFile)).toEqual({ valid: true });
378
+ expect(validateImageFile(pngFile)).toEqual({ valid: true });
379
+ expect(validateImageFile(gifFile)).toEqual({ valid: true });
380
+ expect(validateImageFile(webpFile)).toEqual({ valid: true });
381
+ });
382
+
383
+ it("should reject unsupported file types", () => {
384
+ const textFile = createMockFile("fake", "test.txt", "text/plain");
385
+ const bmpFile = createMockFile("fake", "test.bmp", "image/bmp");
386
+
387
+ expect(validateImageFile(textFile)).toEqual({
388
+ valid: false,
389
+ error: "Unsupported image format. Use JPEG, PNG, GIF, or WebP.",
390
+ });
391
+
392
+ expect(validateImageFile(bmpFile)).toEqual({
393
+ valid: false,
394
+ error: "Unsupported image format. Use JPEG, PNG, GIF, or WebP.",
395
+ });
396
+ });
397
+
398
+ it("should reject files that are too large", () => {
399
+ const largeFile = createMockFile("fake", "huge.jpg", "image/jpeg");
400
+ Object.defineProperty(largeFile, "size", { value: 11 * 1024 * 1024 }); // 11MB
401
+
402
+ expect(validateImageFile(largeFile)).toEqual({
403
+ valid: false,
404
+ error: "Image file too large. Maximum size is 10MB.",
405
+ });
406
+ });
407
+
408
+ it("should accept files at the size limit", () => {
409
+ const maxSizeFile = createMockFile("fake", "max.jpg", "image/jpeg");
410
+ Object.defineProperty(maxSizeFile, "size", { value: 10 * 1024 * 1024 }); // 10MB
411
+
412
+ expect(validateImageFile(maxSizeFile)).toEqual({ valid: true });
413
+ });
414
+ });
415
+
416
+ describe("cleanupImageUrl", () => {
417
+ it("should revoke blob URLs", () => {
418
+ const blobUrl = "blob:http://localhost/fake-url";
419
+
420
+ cleanupImageUrl(blobUrl);
421
+
422
+ expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(blobUrl);
423
+ });
424
+
425
+ it("should not revoke non-blob URLs", () => {
426
+ const httpUrl = "http://example.com/image.jpg";
427
+ const dataUrl = "data:image/jpeg;base64,fake-data";
428
+
429
+ cleanupImageUrl(httpUrl);
430
+ cleanupImageUrl(dataUrl);
431
+
432
+ expect(global.URL.revokeObjectURL).not.toHaveBeenCalled();
433
+ });
434
+ });
435
+
436
+ describe("createImageUrlFromData", () => {
437
+ it("should create blob URL from image data", () => {
438
+ const imageData = new ArrayBuffer(8);
439
+ const mimeType = "image/png";
440
+
441
+ const url = createImageUrlFromData(imageData, mimeType);
442
+
443
+ expect(url).toMatch(/^blob:/);
444
+ expect(global.URL.createObjectURL).toHaveBeenCalled();
445
+
446
+ // Verify blob was created with correct type
447
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
448
+ expect(calls).toHaveLength(1);
449
+ const blob = calls[0]![0] as Blob;
450
+ expect(blob.type).toBe(mimeType);
451
+ });
452
+
453
+ it("should use default JPEG type when not specified", () => {
454
+ const imageData = new ArrayBuffer(8);
455
+
456
+ createImageUrlFromData(imageData);
457
+
458
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
459
+ expect(calls).toHaveLength(1);
460
+ const blob = calls[0]![0] as Blob;
461
+ expect(blob.type).toBe("image/jpeg");
462
+ });
463
+ });
464
+
465
+ describe("createImageUrlsFromData", () => {
466
+ it("should create URLs for both thumbnail and full-size images", () => {
467
+ const thumbnailData = new ArrayBuffer(4);
468
+ const fullSizeData = new ArrayBuffer(8);
469
+ const mimeType = "image/png";
470
+
471
+ const result = createImageUrlsFromData(
472
+ thumbnailData,
473
+ fullSizeData,
474
+ mimeType
475
+ );
476
+
477
+ expect(result.thumbnailUrl).toMatch(/^blob:/);
478
+ expect(result.fullSizeUrl).toMatch(/^blob:/);
479
+ expect(global.URL.createObjectURL).toHaveBeenCalledTimes(2);
480
+
481
+ // Verify both blobs were created with correct type
482
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
483
+ expect(calls).toHaveLength(2);
484
+ expect((calls[0]![0] as Blob).type).toBe(mimeType);
485
+ expect((calls[1]![0] as Blob).type).toBe(mimeType);
486
+ });
487
+ });
488
+
489
+ describe("getImageUrlForContext", () => {
490
+ const thumbnailData = new ArrayBuffer(4);
491
+ const fullSizeData = new ArrayBuffer(8);
492
+ const mimeType = "image/jpeg";
493
+
494
+ it("should return full-size image for background context", () => {
495
+ const mockItem = createMockSongWithImage({
496
+ thumbnailData,
497
+ imageData: fullSizeData,
498
+ imageType: mimeType,
499
+ });
500
+ const url = getImageUrlForContext(mockItem, "background");
501
+
502
+ expect(url).toMatch(/^blob:/);
503
+ // Should prefer full-size for background
504
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
505
+ expect(calls.length).toBeGreaterThan(0);
506
+ const blob = calls[0]![0] as Blob;
507
+ expect(blob.size).toBe(8); // full-size data size
508
+ });
509
+
510
+ it("should return full-size image for modal context", () => {
511
+ const mockItem = createMockSongWithImage({
512
+ thumbnailData,
513
+ imageData: fullSizeData,
514
+ imageType: mimeType,
515
+ });
516
+ const url = getImageUrlForContext(mockItem, "modal");
517
+
518
+ expect(url).toMatch(/^blob:/);
519
+ // Should prefer full-size for modal
520
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
521
+ expect(calls.length).toBeGreaterThan(0);
522
+ const blob = calls[0]![0] as Blob;
523
+ expect(blob.size).toBe(8); // full-size data size
524
+ });
525
+
526
+ it("should return thumbnail for thumbnail context", () => {
527
+ const mockItem = createMockSongWithImage({
528
+ thumbnailData,
529
+ imageData: fullSizeData,
530
+ imageType: mimeType,
531
+ });
532
+ const url = getImageUrlForContext(mockItem, "thumbnail");
533
+
534
+ expect(url).toMatch(/^blob:/);
535
+ // Should prefer thumbnail for thumbnail context
536
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
537
+ expect(calls.length).toBeGreaterThan(0);
538
+ const blob = calls[0]![0] as Blob;
539
+ expect(blob.size).toBe(4); // thumbnail data size
540
+ });
541
+
542
+ it("should fallback to thumbnail when no full-size image available", () => {
543
+ const mockItem = createMockSongWithImage({
544
+ thumbnailData,
545
+ imageData: undefined,
546
+ imageType: mimeType,
547
+ });
548
+ const url = getImageUrlForContext(mockItem, "background");
549
+
550
+ expect(url).toMatch(/^blob:/);
551
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
552
+ expect(calls.length).toBeGreaterThan(0);
553
+ const blob = calls[0]![0] as Blob;
554
+ expect(blob.size).toBe(4); // thumbnail data size
555
+ });
556
+
557
+ it("should fallback to full-size when no thumbnail available", () => {
558
+ const mockItem = createMockSongWithImage({
559
+ thumbnailData: undefined,
560
+ imageData: fullSizeData,
561
+ imageType: mimeType,
562
+ });
563
+ const url = getImageUrlForContext(mockItem, "thumbnail");
564
+
565
+ expect(url).toMatch(/^blob:/);
566
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
567
+ expect(calls.length).toBeGreaterThan(0);
568
+ const blob = calls[0]![0] as Blob;
569
+ expect(blob.size).toBe(8); // full-size data size
570
+ });
571
+
572
+ it("should return null when no image data available", () => {
573
+ const mockItem = createMockSongWithImage({
574
+ thumbnailData: undefined,
575
+ imageData: undefined,
576
+ imageType: undefined,
577
+ });
578
+ const url = getImageUrlForContext(mockItem);
579
+
580
+ expect(url).toBeNull();
581
+ expect(global.URL.createObjectURL).not.toHaveBeenCalled();
582
+ });
583
+
584
+ it("should return null when imageType is missing", () => {
585
+ const mockItem = createMockSongWithImage({
586
+ thumbnailData: undefined,
587
+ imageData: undefined,
588
+ imageType: mimeType,
589
+ });
590
+ const url = getImageUrlForContext(mockItem);
591
+
592
+ expect(url).toBeNull();
593
+ expect(global.URL.createObjectURL).not.toHaveBeenCalled();
594
+ });
595
+
596
+ it("should default to thumbnail context when not specified", () => {
597
+ const mockItem = createMockSongWithImage({
598
+ thumbnailData,
599
+ imageData: fullSizeData,
600
+ imageType: mimeType,
601
+ });
602
+ const url = getImageUrlForContext(mockItem);
603
+
604
+ expect(url).toMatch(/^blob:/);
605
+ const calls = vi.mocked(global.URL.createObjectURL).mock.calls;
606
+ expect(calls.length).toBeGreaterThan(0);
607
+ const blob = calls[0]![0] as Blob;
608
+ expect(blob.size).toBe(4); // thumbnail data size
609
+ });
610
+ });
611
+
612
+ describe("Error Handling", () => {
613
+ it("should handle unexpected errors in extractAlbumArt", async () => {
614
+ const mockFile = createMockFile("fake", "test.mp3", "audio/mpeg");
615
+
616
+ // Mock unexpected error
617
+ vi.mocked(mockFile.arrayBuffer).mockImplementation(() => {
618
+ throw new Error("Unexpected error");
619
+ });
620
+
621
+ const result = await extractAlbumArt(mockFile);
622
+
623
+ expect(result.success).toBe(false);
624
+ expect(result.error).toBe("Unexpected error");
625
+ });
626
+
627
+ it("should handle unexpected errors in processPlaylistCover", async () => {
628
+ const mockFile = createMockFile("fake", "test.jpg", "image/jpeg");
629
+
630
+ // Mock unexpected error during array buffer reading
631
+ vi.mocked(mockFile.arrayBuffer).mockRejectedValue(
632
+ new Error("Buffer read failed")
633
+ );
634
+
635
+ const result = await processPlaylistCover(mockFile);
636
+
637
+ expect(result.success).toBe(false);
638
+ expect(result.error).toBe("Buffer read failed");
639
+ });
640
+ });
641
+
642
+ describe("Edge Cases", () => {
643
+ it("should handle very small images", async () => {
644
+ const mockFile = createMockFile("tiny", "tiny.jpg", "image/jpeg");
645
+
646
+ // Mock tiny image dimensions
647
+ mockImage.width = 50;
648
+ mockImage.height = 50;
649
+
650
+ setTimeout(() => {
651
+ if (mockImage.onload) {
652
+ mockImage.onload();
653
+ }
654
+ }, 0);
655
+
656
+ const result = await processPlaylistCover(mockFile);
657
+
658
+ expect(result.success).toBe(true);
659
+ expect(result.metadata?.width).toBe(50);
660
+ expect(result.metadata?.height).toBe(50);
661
+ });
662
+
663
+ it("should handle square images", async () => {
664
+ const mockFile = createMockFile("square", "square.jpg", "image/jpeg");
665
+
666
+ mockImage.width = 300;
667
+ mockImage.height = 300;
668
+
669
+ setTimeout(() => {
670
+ if (mockImage.onload) {
671
+ mockImage.onload();
672
+ }
673
+ }, 0);
674
+
675
+ const result = await processPlaylistCover(mockFile);
676
+
677
+ expect(result.success).toBe(true);
678
+ expect(result.metadata?.width).toBe(300);
679
+ expect(result.metadata?.height).toBe(300);
680
+ });
681
+
682
+ it("should handle landscape images", async () => {
683
+ const mockFile = createMockFile("landscape", "wide.jpg", "image/jpeg");
684
+
685
+ mockImage.width = 800;
686
+ mockImage.height = 400;
687
+
688
+ setTimeout(() => {
689
+ if (mockImage.onload) {
690
+ mockImage.onload();
691
+ }
692
+ }, 0);
693
+
694
+ const result = await processPlaylistCover(mockFile);
695
+
696
+ expect(result.success).toBe(true);
697
+ expect(result.metadata?.width).toBe(800);
698
+ expect(result.metadata?.height).toBe(400);
699
+ });
700
+ });
701
+ });