@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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
|
+
});
|