@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,1025 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createMockSong } from "../utils/mockData.js";
|
|
3
|
+
|
|
4
|
+
// mock dependencies using factory pattern
|
|
5
|
+
vi.mock("./indexedDBService.js", () => ({
|
|
6
|
+
saveSetting: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
loadSetting: vi.fn().mockResolvedValue(null),
|
|
8
|
+
DB_NAME: "musicPlaylistDB",
|
|
9
|
+
PLAYLISTS_STORE: "playlists",
|
|
10
|
+
SONGS_STORE: "songs",
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./automergeRepo.js", () => ({
|
|
14
|
+
createPlaylistDoc: vi.fn().mockReturnValue({
|
|
15
|
+
docId: "automerge:test123",
|
|
16
|
+
handle: {
|
|
17
|
+
change: vi.fn(),
|
|
18
|
+
doc: vi.fn().mockReturnValue({}),
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
findPlaylistDoc: vi.fn().mockReturnValue({
|
|
22
|
+
change: vi.fn(),
|
|
23
|
+
doc: vi.fn().mockReturnValue({}),
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("./docIndexService.js", () => ({
|
|
28
|
+
addDocIndexEntry: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("./playlistDocService.js", () => ({
|
|
32
|
+
docToPlaylist: vi.fn().mockReturnValue({
|
|
33
|
+
id: "automerge:test123",
|
|
34
|
+
title: "test",
|
|
35
|
+
songIds: [],
|
|
36
|
+
createdAt: 0,
|
|
37
|
+
updatedAt: 0,
|
|
38
|
+
}),
|
|
39
|
+
setSongCoverImage: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
setPlaylistCoverImage: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
getSongsForPlaylist: vi.fn().mockResolvedValue([]),
|
|
42
|
+
getSongById: vi.fn().mockResolvedValue(null),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("@freqhole/api-client/playlistz", () => ({
|
|
46
|
+
emptyPlaylistDoc: vi.fn().mockReturnValue({}),
|
|
47
|
+
upsertSong: vi.fn(),
|
|
48
|
+
setMetadata: vi.fn(),
|
|
49
|
+
parsePlaylistDoc: vi.fn().mockReturnValue({
|
|
50
|
+
title: "",
|
|
51
|
+
order: [],
|
|
52
|
+
songs: {},
|
|
53
|
+
images: [],
|
|
54
|
+
description: "",
|
|
55
|
+
}),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock("@freqhole/api-client/storage", () => ({
|
|
59
|
+
getBlobMetadata: vi.fn().mockResolvedValue(null),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock("./streamingAudioService.js", () => ({
|
|
63
|
+
downloadSongIfNeeded: vi.fn().mockResolvedValue(true),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock("./songReactivity.js", () => ({
|
|
67
|
+
triggerSongUpdateWithOptions: vi.fn(),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// import after mocks are set up
|
|
71
|
+
import {
|
|
72
|
+
standaloneLoadingProgress,
|
|
73
|
+
setStandaloneLoadingProgress,
|
|
74
|
+
initializeStandalonePlaylist,
|
|
75
|
+
initializeAllStandalonePlaylists,
|
|
76
|
+
loadStandaloneSongAudioData,
|
|
77
|
+
songNeedsAudioData,
|
|
78
|
+
clearStandaloneLoadingProgress,
|
|
79
|
+
registerStandalonePath,
|
|
80
|
+
clearStandaloneRegistry,
|
|
81
|
+
} from "./standaloneService.js";
|
|
82
|
+
import { loadSetting, saveSetting } from "./indexedDBService.js";
|
|
83
|
+
import { createPlaylistDoc, findPlaylistDoc } from "./automergeRepo.js";
|
|
84
|
+
import { addDocIndexEntry } from "./docIndexService.js";
|
|
85
|
+
import { getSongsForPlaylist, getSongById } from "./playlistDocService.js";
|
|
86
|
+
import { getBlobMetadata } from "@freqhole/api-client/storage";
|
|
87
|
+
import { downloadSongIfNeeded } from "./streamingAudioService.js";
|
|
88
|
+
|
|
89
|
+
// mock solid-js
|
|
90
|
+
vi.mock("solid-js", () => {
|
|
91
|
+
let currentProgress: any = null;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
createSignal: vi.fn(() => [
|
|
95
|
+
() => currentProgress,
|
|
96
|
+
(value: any) => {
|
|
97
|
+
currentProgress = value;
|
|
98
|
+
},
|
|
99
|
+
]),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// mock global objects
|
|
104
|
+
global.fetch = vi.fn();
|
|
105
|
+
|
|
106
|
+
describe("Standalone Service", () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
clearStandaloneRegistry();
|
|
110
|
+
|
|
111
|
+
// default: no existing setting (first boot)
|
|
112
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
113
|
+
vi.mocked(saveSetting).mockResolvedValue(undefined);
|
|
114
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue([]);
|
|
115
|
+
vi.mocked(getSongById).mockResolvedValue(null);
|
|
116
|
+
vi.mocked(getBlobMetadata).mockResolvedValue(null);
|
|
117
|
+
vi.mocked(downloadSongIfNeeded).mockResolvedValue(true);
|
|
118
|
+
vi.mocked(createPlaylistDoc).mockReturnValue({
|
|
119
|
+
docId: "automerge:test123",
|
|
120
|
+
handle: { change: vi.fn(), doc: vi.fn().mockReturnValue({}) },
|
|
121
|
+
} as any);
|
|
122
|
+
vi.mocked(findPlaylistDoc).mockReturnValue({
|
|
123
|
+
change: vi.fn(),
|
|
124
|
+
doc: vi.fn().mockReturnValue({}),
|
|
125
|
+
} as any);
|
|
126
|
+
vi.mocked(addDocIndexEntry).mockResolvedValue(undefined);
|
|
127
|
+
|
|
128
|
+
// reset progress
|
|
129
|
+
setStandaloneLoadingProgress(null);
|
|
130
|
+
|
|
131
|
+
// default window location to https
|
|
132
|
+
Object.defineProperty(window, "location", {
|
|
133
|
+
value: { protocol: "https:", origin: "https://localhost:3000" },
|
|
134
|
+
writable: true,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
vi.restoreAllMocks();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("Loading Progress Management", () => {
|
|
143
|
+
it("should initialize with null progress", () => {
|
|
144
|
+
expect(standaloneLoadingProgress()).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should update loading progress", () => {
|
|
148
|
+
const progress = {
|
|
149
|
+
current: 5,
|
|
150
|
+
total: 10,
|
|
151
|
+
currentSong: "Song Title",
|
|
152
|
+
phase: "updating" as const,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
setStandaloneLoadingProgress(progress);
|
|
156
|
+
expect(standaloneLoadingProgress()).toEqual(progress);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should clear loading progress", () => {
|
|
160
|
+
setStandaloneLoadingProgress({
|
|
161
|
+
current: 5,
|
|
162
|
+
total: 10,
|
|
163
|
+
currentSong: "Song Title",
|
|
164
|
+
phase: "updating",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
setStandaloneLoadingProgress(null);
|
|
168
|
+
expect(standaloneLoadingProgress()).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should handle different loading phases", () => {
|
|
172
|
+
const phases = [
|
|
173
|
+
"initializing",
|
|
174
|
+
"checking",
|
|
175
|
+
"updating",
|
|
176
|
+
"complete",
|
|
177
|
+
"reloading",
|
|
178
|
+
] as const;
|
|
179
|
+
|
|
180
|
+
phases.forEach((phase) => {
|
|
181
|
+
setStandaloneLoadingProgress({
|
|
182
|
+
current: 1,
|
|
183
|
+
total: 1,
|
|
184
|
+
currentSong: "Test Song",
|
|
185
|
+
phase,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(standaloneLoadingProgress()?.phase).toBe(phase);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("initializeStandalonePlaylist", () => {
|
|
194
|
+
let mockPlaylistData: any;
|
|
195
|
+
let mockCallbacks: any;
|
|
196
|
+
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
mockPlaylistData = {
|
|
199
|
+
playlist: {
|
|
200
|
+
id: "standalone-playlist",
|
|
201
|
+
title: "Standalone Playlist",
|
|
202
|
+
description: "A test playlist",
|
|
203
|
+
rev: 1,
|
|
204
|
+
},
|
|
205
|
+
songs: [
|
|
206
|
+
{
|
|
207
|
+
id: "song1",
|
|
208
|
+
title: "Song One",
|
|
209
|
+
artist: "Artist One",
|
|
210
|
+
album: "Album One",
|
|
211
|
+
duration: 180,
|
|
212
|
+
originalFilename: "song1.mp3",
|
|
213
|
+
fileSize: 1000000,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "song2",
|
|
217
|
+
title: "Song Two",
|
|
218
|
+
artist: "Artist Two",
|
|
219
|
+
album: "Album Two",
|
|
220
|
+
duration: 240,
|
|
221
|
+
originalFilename: "song2.mp3",
|
|
222
|
+
fileSize: 1500000,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
mockCallbacks = {
|
|
228
|
+
setSelectedPlaylist: vi.fn(),
|
|
229
|
+
setPlaylistSongs: vi.fn(),
|
|
230
|
+
setSidebarCollapsed: vi.fn(),
|
|
231
|
+
setError: vi.fn(),
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should initialize standalone playlist successfully on first boot", async () => {
|
|
236
|
+
// first boot: no existing setting
|
|
237
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
238
|
+
|
|
239
|
+
await initializeStandalonePlaylist(mockPlaylistData, mockCallbacks);
|
|
240
|
+
|
|
241
|
+
expect(loadSetting).toHaveBeenCalledWith("standalone:standalone-playlist");
|
|
242
|
+
expect(createPlaylistDoc).toHaveBeenCalled();
|
|
243
|
+
expect(addDocIndexEntry).toHaveBeenCalled();
|
|
244
|
+
expect(saveSetting).toHaveBeenCalledWith(
|
|
245
|
+
"standalone:standalone-playlist",
|
|
246
|
+
expect.objectContaining({ rev: 1, docId: "automerge:test123" })
|
|
247
|
+
);
|
|
248
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
249
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("should use existing doc when rev is unchanged", async () => {
|
|
253
|
+
vi.mocked(loadSetting).mockResolvedValue({
|
|
254
|
+
rev: 1,
|
|
255
|
+
docId: "automerge:existing",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await initializeStandalonePlaylist(mockPlaylistData, mockCallbacks);
|
|
259
|
+
|
|
260
|
+
expect(createPlaylistDoc).not.toHaveBeenCalled();
|
|
261
|
+
expect(saveSetting).not.toHaveBeenCalled();
|
|
262
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
263
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should update existing doc when rev increases", async () => {
|
|
267
|
+
vi.mocked(loadSetting).mockResolvedValue({
|
|
268
|
+
rev: 0,
|
|
269
|
+
docId: "automerge:existing",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await initializeStandalonePlaylist(mockPlaylistData, mockCallbacks);
|
|
273
|
+
|
|
274
|
+
expect(createPlaylistDoc).not.toHaveBeenCalled();
|
|
275
|
+
expect(saveSetting).toHaveBeenCalledWith(
|
|
276
|
+
"standalone:standalone-playlist",
|
|
277
|
+
expect.objectContaining({ rev: 1, docId: "automerge:existing" })
|
|
278
|
+
);
|
|
279
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
280
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should handle database errors gracefully", async () => {
|
|
284
|
+
vi.mocked(loadSetting).mockRejectedValue(
|
|
285
|
+
new Error("Database setup failed")
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
await initializeStandalonePlaylist(mockPlaylistData, mockCallbacks);
|
|
289
|
+
|
|
290
|
+
expect(mockCallbacks.setError).toHaveBeenCalledWith(
|
|
291
|
+
expect.stringContaining("failed to load")
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should handle invalid playlist data", async () => {
|
|
296
|
+
const invalidData = {} as any;
|
|
297
|
+
|
|
298
|
+
await initializeStandalonePlaylist(invalidData, mockCallbacks);
|
|
299
|
+
|
|
300
|
+
expect(mockCallbacks.setError).toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("should handle missing callbacks gracefully", async () => {
|
|
304
|
+
const partialCallbacks = {
|
|
305
|
+
setSelectedPlaylist: vi.fn(),
|
|
306
|
+
// missing other callbacks
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
await expect(
|
|
310
|
+
initializeStandalonePlaylist(mockPlaylistData, partialCallbacks as any)
|
|
311
|
+
).rejects.toThrow("callbacks.setError is not a function");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should populate songs with standaloneFilePath", async () => {
|
|
315
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
316
|
+
|
|
317
|
+
await initializeStandalonePlaylist(mockPlaylistData, mockCallbacks);
|
|
318
|
+
|
|
319
|
+
const calledSongs = mockCallbacks.setPlaylistSongs.mock.calls[0][0];
|
|
320
|
+
expect(calledSongs[0].standaloneFilePath).toBe("data/song1.mp3");
|
|
321
|
+
expect(calledSongs[1].standaloneFilePath).toBe("data/song2.mp3");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("initializeAllStandalonePlaylists", () => {
|
|
326
|
+
it("should call initializeStandalonePlaylist for each entry", async () => {
|
|
327
|
+
const mockCallbacks = {
|
|
328
|
+
setSelectedPlaylist: vi.fn(),
|
|
329
|
+
setPlaylistSongs: vi.fn(),
|
|
330
|
+
setSidebarCollapsed: vi.fn(),
|
|
331
|
+
setError: vi.fn(),
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
335
|
+
|
|
336
|
+
const entry = (id: string) => ({
|
|
337
|
+
playlist: {
|
|
338
|
+
id,
|
|
339
|
+
title: `playlist ${id}`,
|
|
340
|
+
description: "test",
|
|
341
|
+
rev: 1,
|
|
342
|
+
},
|
|
343
|
+
songs: [
|
|
344
|
+
{
|
|
345
|
+
id: `${id}-song1`,
|
|
346
|
+
title: "song one",
|
|
347
|
+
artist: "artist",
|
|
348
|
+
album: "album",
|
|
349
|
+
duration: 180,
|
|
350
|
+
originalFilename: "song1.mp3",
|
|
351
|
+
fileSize: 1000000,
|
|
352
|
+
sha: "abc123",
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await initializeAllStandalonePlaylists(
|
|
358
|
+
[entry("pl-a"), entry("pl-b")],
|
|
359
|
+
mockCallbacks
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// each playlist entry triggers setSelectedPlaylist and setPlaylistSongs
|
|
363
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalledTimes(2);
|
|
364
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalledTimes(2);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("loadStandaloneSongAudioData", () => {
|
|
369
|
+
beforeEach(() => {
|
|
370
|
+
clearStandaloneRegistry();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("should return true for file:// protocol", async () => {
|
|
374
|
+
Object.defineProperty(window, "location", {
|
|
375
|
+
value: { protocol: "file:" },
|
|
376
|
+
writable: true,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const result = await loadStandaloneSongAudioData("any-song");
|
|
380
|
+
expect(result).toBe(true);
|
|
381
|
+
expect(downloadSongIfNeeded).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("should return false when song has no registered path", async () => {
|
|
385
|
+
const consoleSpy = vi
|
|
386
|
+
.spyOn(console, "error")
|
|
387
|
+
.mockImplementation(() => {});
|
|
388
|
+
|
|
389
|
+
const result = await loadStandaloneSongAudioData("unregistered-song");
|
|
390
|
+
|
|
391
|
+
expect(result).toBe(false);
|
|
392
|
+
expect(downloadSongIfNeeded).not.toHaveBeenCalled();
|
|
393
|
+
consoleSpy.mockRestore();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should load audio data for registered song successfully", async () => {
|
|
397
|
+
registerStandalonePath("test-song", "data/test.mp3");
|
|
398
|
+
vi.mocked(getSongById).mockResolvedValue(
|
|
399
|
+
createMockSong({ id: "test-song", sha: "abc123" })
|
|
400
|
+
);
|
|
401
|
+
vi.mocked(downloadSongIfNeeded).mockResolvedValue(true);
|
|
402
|
+
|
|
403
|
+
const result = await loadStandaloneSongAudioData("test-song");
|
|
404
|
+
|
|
405
|
+
expect(downloadSongIfNeeded).toHaveBeenCalled();
|
|
406
|
+
expect(result).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("should handle fetch errors gracefully", async () => {
|
|
410
|
+
registerStandalonePath("test-song", "data/test.mp3");
|
|
411
|
+
vi.mocked(downloadSongIfNeeded).mockRejectedValue(
|
|
412
|
+
new Error("Network error")
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const result = await loadStandaloneSongAudioData("test-song");
|
|
416
|
+
|
|
417
|
+
expect(result).toBe(false);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("should handle database errors", async () => {
|
|
421
|
+
registerStandalonePath("test-song", "data/test.mp3");
|
|
422
|
+
vi.mocked(getSongById).mockRejectedValue(new Error("Database error"));
|
|
423
|
+
|
|
424
|
+
const result = await loadStandaloneSongAudioData("test-song");
|
|
425
|
+
|
|
426
|
+
expect(result).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("should skip loading for file:// protocol without checking path", async () => {
|
|
430
|
+
Object.defineProperty(window, "location", {
|
|
431
|
+
value: { protocol: "file:" },
|
|
432
|
+
writable: true,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const result = await loadStandaloneSongAudioData("file-protocol-song");
|
|
436
|
+
|
|
437
|
+
expect(result).toBe(true);
|
|
438
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("should build minimal song object when getSongById returns null", async () => {
|
|
442
|
+
registerStandalonePath("orphan-song", "data/orphan.mp3");
|
|
443
|
+
vi.mocked(getSongById).mockResolvedValue(null);
|
|
444
|
+
vi.mocked(downloadSongIfNeeded).mockResolvedValue(true);
|
|
445
|
+
|
|
446
|
+
const result = await loadStandaloneSongAudioData("orphan-song");
|
|
447
|
+
|
|
448
|
+
expect(downloadSongIfNeeded).toHaveBeenCalledWith(
|
|
449
|
+
expect.objectContaining({ id: "orphan-song" }),
|
|
450
|
+
"data/orphan.mp3"
|
|
451
|
+
);
|
|
452
|
+
expect(result).toBe(true);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("songNeedsAudioData", () => {
|
|
457
|
+
it("should return false for file:// protocol", async () => {
|
|
458
|
+
Object.defineProperty(window, "location", {
|
|
459
|
+
value: { protocol: "file:" },
|
|
460
|
+
writable: true,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const mockSong = createMockSong({
|
|
464
|
+
id: "test-song",
|
|
465
|
+
sha: "some-sha",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const result = await songNeedsAudioData(mockSong);
|
|
469
|
+
|
|
470
|
+
expect(result).toBe(false);
|
|
471
|
+
expect(getBlobMetadata).not.toHaveBeenCalled();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("should return true for song without sha", async () => {
|
|
475
|
+
const mockSong = createMockSong({
|
|
476
|
+
id: "test-song",
|
|
477
|
+
sha: undefined,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const result = await songNeedsAudioData(mockSong as any);
|
|
481
|
+
|
|
482
|
+
expect(result).toBe(true);
|
|
483
|
+
expect(getBlobMetadata).not.toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("should return false for song with blob in store", async () => {
|
|
487
|
+
vi.mocked(getBlobMetadata).mockResolvedValue({ size: 1000 } as any);
|
|
488
|
+
|
|
489
|
+
const mockSong = createMockSong({
|
|
490
|
+
id: "test-song",
|
|
491
|
+
sha: "sha-in-store",
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const result = await songNeedsAudioData(mockSong);
|
|
495
|
+
|
|
496
|
+
expect(getBlobMetadata).toHaveBeenCalledWith("sha-in-store");
|
|
497
|
+
expect(result).toBe(false);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("should return true for song not in blob store", async () => {
|
|
501
|
+
vi.mocked(getBlobMetadata).mockResolvedValue(null);
|
|
502
|
+
|
|
503
|
+
const mockSong = createMockSong({
|
|
504
|
+
id: "test-song",
|
|
505
|
+
sha: "missing-sha",
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const result = await songNeedsAudioData(mockSong);
|
|
509
|
+
|
|
510
|
+
expect(getBlobMetadata).toHaveBeenCalledWith("missing-sha");
|
|
511
|
+
expect(result).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("should return true on blob store error", async () => {
|
|
515
|
+
vi.mocked(getBlobMetadata).mockRejectedValue(
|
|
516
|
+
new Error("storage error")
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const mockSong = createMockSong({
|
|
520
|
+
id: "test-song",
|
|
521
|
+
sha: "error-sha",
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const result = await songNeedsAudioData(mockSong);
|
|
525
|
+
|
|
526
|
+
expect(result).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("should use sha256 field as fallback when sha is absent", async () => {
|
|
530
|
+
vi.mocked(getBlobMetadata).mockResolvedValue(null);
|
|
531
|
+
|
|
532
|
+
const mockSong = {
|
|
533
|
+
...createMockSong({ id: "test-song" }),
|
|
534
|
+
sha: undefined,
|
|
535
|
+
sha256: "fallback-sha",
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const result = await songNeedsAudioData(mockSong as any);
|
|
539
|
+
|
|
540
|
+
expect(getBlobMetadata).toHaveBeenCalledWith("fallback-sha");
|
|
541
|
+
expect(result).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("clearStandaloneLoadingProgress", () => {
|
|
546
|
+
it("should clear loading progress", () => {
|
|
547
|
+
setStandaloneLoadingProgress({
|
|
548
|
+
current: 5,
|
|
549
|
+
total: 10,
|
|
550
|
+
currentSong: "Test Song",
|
|
551
|
+
phase: "updating",
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
expect(standaloneLoadingProgress()).not.toBeNull();
|
|
555
|
+
|
|
556
|
+
clearStandaloneLoadingProgress();
|
|
557
|
+
|
|
558
|
+
expect(standaloneLoadingProgress()).toBeNull();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("should handle clearing when already null", () => {
|
|
562
|
+
setStandaloneLoadingProgress(null);
|
|
563
|
+
expect(standaloneLoadingProgress()).toBeNull();
|
|
564
|
+
|
|
565
|
+
expect(() => {
|
|
566
|
+
clearStandaloneLoadingProgress();
|
|
567
|
+
}).not.toThrow();
|
|
568
|
+
|
|
569
|
+
expect(standaloneLoadingProgress()).toBeNull();
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
describe("Integration with other services", () => {
|
|
574
|
+
it("should properly integrate with doc creation on first boot", async () => {
|
|
575
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
576
|
+
|
|
577
|
+
const mockCallbacks = {
|
|
578
|
+
setSelectedPlaylist: vi.fn(),
|
|
579
|
+
setPlaylistSongs: vi.fn(),
|
|
580
|
+
setSidebarCollapsed: vi.fn(),
|
|
581
|
+
setError: vi.fn(),
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const playlistData = {
|
|
585
|
+
playlist: {
|
|
586
|
+
id: "integration-pl",
|
|
587
|
+
title: "integration playlist",
|
|
588
|
+
description: "test",
|
|
589
|
+
rev: 0,
|
|
590
|
+
},
|
|
591
|
+
songs: [
|
|
592
|
+
{
|
|
593
|
+
id: "integration-song",
|
|
594
|
+
title: "integration song",
|
|
595
|
+
artist: "artist",
|
|
596
|
+
album: "album",
|
|
597
|
+
duration: 180,
|
|
598
|
+
originalFilename: "integration.mp3",
|
|
599
|
+
fileSize: 1000000,
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
await initializeStandalonePlaylist(playlistData, mockCallbacks);
|
|
605
|
+
|
|
606
|
+
expect(createPlaylistDoc).toHaveBeenCalled();
|
|
607
|
+
expect(getSongsForPlaylist).toHaveBeenCalledWith("automerge:test123");
|
|
608
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("should handle progress updates correctly", () => {
|
|
612
|
+
const progressStates = [
|
|
613
|
+
{ phase: "initializing", current: 0, total: 5 },
|
|
614
|
+
{ phase: "checking", current: 1, total: 5 },
|
|
615
|
+
{ phase: "updating", current: 3, total: 5 },
|
|
616
|
+
{ phase: "complete", current: 5, total: 5 },
|
|
617
|
+
] as const;
|
|
618
|
+
|
|
619
|
+
progressStates.forEach((state) => {
|
|
620
|
+
setStandaloneLoadingProgress({
|
|
621
|
+
...state,
|
|
622
|
+
currentSong: "Test Song",
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const currentProgress = standaloneLoadingProgress();
|
|
626
|
+
expect(currentProgress?.phase).toBe(state.phase);
|
|
627
|
+
expect(currentProgress?.current).toBe(state.current);
|
|
628
|
+
expect(currentProgress?.total).toBe(state.total);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe("Error Handling and Edge Cases", () => {
|
|
634
|
+
it("should handle malformed playlist data", async () => {
|
|
635
|
+
const malformedData = {
|
|
636
|
+
songs: [],
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const mockCallbacks = {
|
|
640
|
+
setSelectedPlaylist: vi.fn(),
|
|
641
|
+
setPlaylistSongs: vi.fn(),
|
|
642
|
+
setSidebarCollapsed: vi.fn(),
|
|
643
|
+
setError: vi.fn(),
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
await initializeStandalonePlaylist(malformedData as any, mockCallbacks);
|
|
647
|
+
|
|
648
|
+
expect(mockCallbacks.setError).toHaveBeenCalled();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("should handle very large song datasets", async () => {
|
|
652
|
+
registerStandalonePath("large-song", "data/large.mp3");
|
|
653
|
+
vi.mocked(downloadSongIfNeeded).mockResolvedValue(true);
|
|
654
|
+
|
|
655
|
+
const result = await loadStandaloneSongAudioData("large-song");
|
|
656
|
+
|
|
657
|
+
expect(result).toBe(true);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("should handle network timeouts gracefully", async () => {
|
|
661
|
+
registerStandalonePath("timeout-song", "data/timeout.mp3");
|
|
662
|
+
vi.mocked(downloadSongIfNeeded).mockRejectedValue(
|
|
663
|
+
new Error("Network timeout")
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const result = await loadStandaloneSongAudioData("timeout-song");
|
|
667
|
+
|
|
668
|
+
expect(result).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("should handle concurrent song loading", async () => {
|
|
672
|
+
const songIds = ["song1", "song2", "song3"];
|
|
673
|
+
songIds.forEach((id) => registerStandalonePath(id, `data/${id}.mp3`));
|
|
674
|
+
vi.mocked(downloadSongIfNeeded).mockResolvedValue(true);
|
|
675
|
+
|
|
676
|
+
const promises = songIds.map((songId) =>
|
|
677
|
+
loadStandaloneSongAudioData(songId)
|
|
678
|
+
);
|
|
679
|
+
const results = await Promise.all(promises);
|
|
680
|
+
|
|
681
|
+
expect(results.every((result) => result === true)).toBe(true);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe("Performance Considerations", () => {
|
|
686
|
+
it("should handle rapid progress updates efficiently", async () => {
|
|
687
|
+
const startTime = performance.now();
|
|
688
|
+
|
|
689
|
+
for (let i = 0; i < 1000; i++) {
|
|
690
|
+
setStandaloneLoadingProgress({
|
|
691
|
+
current: i,
|
|
692
|
+
total: 1000,
|
|
693
|
+
currentSong: `Song ${i}`,
|
|
694
|
+
phase: "updating",
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const endTime = performance.now();
|
|
699
|
+
const duration = endTime - startTime;
|
|
700
|
+
|
|
701
|
+
expect(duration).toBeLessThan(50);
|
|
702
|
+
expect(standaloneLoadingProgress()?.current).toBe(999);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("should handle audio data checking efficiently", async () => {
|
|
706
|
+
vi.mocked(getBlobMetadata).mockResolvedValue(null);
|
|
707
|
+
|
|
708
|
+
const startTime = performance.now();
|
|
709
|
+
|
|
710
|
+
const songs = Array.from({ length: 100 }, (_, i) =>
|
|
711
|
+
createMockSong({ id: `song${i}`, title: `song ${i}`, sha: `sha-${i}` })
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const promises = songs.map((song) => songNeedsAudioData(song));
|
|
715
|
+
await Promise.all(promises);
|
|
716
|
+
|
|
717
|
+
const endTime = performance.now();
|
|
718
|
+
const duration = endTime - startTime;
|
|
719
|
+
|
|
720
|
+
expect(duration).toBeLessThan(200);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
describe("Advanced Playlist Scenarios", () => {
|
|
725
|
+
describe("initializeStandalonePlaylist with existing data", () => {
|
|
726
|
+
it("should handle playlist revision updates", async () => {
|
|
727
|
+
vi.mocked(loadSetting).mockResolvedValue({ rev: 1, docId: "automerge:old" });
|
|
728
|
+
|
|
729
|
+
const playlistData = {
|
|
730
|
+
playlist: {
|
|
731
|
+
id: "existing-playlist",
|
|
732
|
+
title: "updated playlist",
|
|
733
|
+
description: "test description",
|
|
734
|
+
rev: 2,
|
|
735
|
+
},
|
|
736
|
+
songs: [
|
|
737
|
+
{
|
|
738
|
+
id: "existing-song",
|
|
739
|
+
title: "updated song",
|
|
740
|
+
artist: "test artist",
|
|
741
|
+
album: "test album",
|
|
742
|
+
duration: 180,
|
|
743
|
+
sha: "new-sha",
|
|
744
|
+
originalFilename: "updated.mp3",
|
|
745
|
+
fileSize: 1024,
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const mockCallbacks = {
|
|
751
|
+
setSelectedPlaylist: vi.fn(),
|
|
752
|
+
setPlaylistSongs: vi.fn(),
|
|
753
|
+
setSidebarCollapsed: vi.fn(),
|
|
754
|
+
setError: vi.fn(),
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
await initializeStandalonePlaylist(playlistData, mockCallbacks);
|
|
758
|
+
|
|
759
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
760
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
761
|
+
expect(saveSetting).toHaveBeenCalledWith(
|
|
762
|
+
"standalone:existing-playlist",
|
|
763
|
+
expect.objectContaining({ rev: 2, docId: "automerge:old" })
|
|
764
|
+
);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("should skip update when revision is same", async () => {
|
|
768
|
+
vi.mocked(loadSetting).mockResolvedValue({ rev: 1, docId: "automerge:same" });
|
|
769
|
+
|
|
770
|
+
const playlistData = {
|
|
771
|
+
playlist: {
|
|
772
|
+
id: "same-rev-playlist",
|
|
773
|
+
title: "same rev playlist",
|
|
774
|
+
rev: 1,
|
|
775
|
+
},
|
|
776
|
+
songs: [
|
|
777
|
+
{
|
|
778
|
+
id: "song1",
|
|
779
|
+
title: "song one",
|
|
780
|
+
artist: "test artist",
|
|
781
|
+
album: "test album",
|
|
782
|
+
duration: 180,
|
|
783
|
+
originalFilename: "song1.mp3",
|
|
784
|
+
fileSize: 1024,
|
|
785
|
+
sha: "same-sha",
|
|
786
|
+
},
|
|
787
|
+
],
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const mockCallbacks = {
|
|
791
|
+
setSelectedPlaylist: vi.fn(),
|
|
792
|
+
setPlaylistSongs: vi.fn(),
|
|
793
|
+
setSidebarCollapsed: vi.fn(),
|
|
794
|
+
setError: vi.fn(),
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
await initializeStandalonePlaylist(playlistData, mockCallbacks);
|
|
798
|
+
|
|
799
|
+
expect(createPlaylistDoc).not.toHaveBeenCalled();
|
|
800
|
+
expect(saveSetting).not.toHaveBeenCalled();
|
|
801
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
802
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("should create new playlist when none exists", async () => {
|
|
806
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
807
|
+
|
|
808
|
+
const playlistData = {
|
|
809
|
+
playlist: {
|
|
810
|
+
id: "brand-new-playlist",
|
|
811
|
+
title: "Brand New Playlist",
|
|
812
|
+
description: "A completely new playlist",
|
|
813
|
+
rev: 0,
|
|
814
|
+
},
|
|
815
|
+
songs: [
|
|
816
|
+
{
|
|
817
|
+
id: "new-song",
|
|
818
|
+
title: "new song",
|
|
819
|
+
artist: "test artist",
|
|
820
|
+
album: "test album",
|
|
821
|
+
duration: 180,
|
|
822
|
+
originalFilename: "new.mp3",
|
|
823
|
+
fileSize: 1024,
|
|
824
|
+
sha: "new-sha",
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
const mockCallbacks = {
|
|
830
|
+
setSelectedPlaylist: vi.fn(),
|
|
831
|
+
setPlaylistSongs: vi.fn(),
|
|
832
|
+
setSidebarCollapsed: vi.fn(),
|
|
833
|
+
setError: vi.fn(),
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
await initializeStandalonePlaylist(playlistData, mockCallbacks);
|
|
837
|
+
|
|
838
|
+
expect(createPlaylistDoc).toHaveBeenCalled();
|
|
839
|
+
expect(mockCallbacks.setSelectedPlaylist).toHaveBeenCalled();
|
|
840
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
describe("loadStandaloneSongAudioData edge cases", () => {
|
|
845
|
+
it("should skip loading for file:// protocol", async () => {
|
|
846
|
+
Object.defineProperty(window, "location", {
|
|
847
|
+
value: { protocol: "file:" },
|
|
848
|
+
writable: true,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const result = await loadStandaloneSongAudioData("file-protocol-song");
|
|
852
|
+
|
|
853
|
+
expect(result).toBe(true);
|
|
854
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("should return false when song has no registered standalone path", async () => {
|
|
858
|
+
const consoleSpy = vi
|
|
859
|
+
.spyOn(console, "error")
|
|
860
|
+
.mockImplementation(() => {});
|
|
861
|
+
|
|
862
|
+
const result = await loadStandaloneSongAudioData("no-path-song");
|
|
863
|
+
|
|
864
|
+
expect(result).toBe(false);
|
|
865
|
+
consoleSpy.mockRestore();
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
describe("songNeedsAudioData advanced scenarios", () => {
|
|
870
|
+
it("should return true for song with zero-length sha", async () => {
|
|
871
|
+
vi.mocked(getBlobMetadata).mockResolvedValue(null);
|
|
872
|
+
|
|
873
|
+
const mockSong = createMockSong({ id: "zero-sha-song", sha: "" });
|
|
874
|
+
|
|
875
|
+
// empty string is falsy -> treated as no sha
|
|
876
|
+
const result = await songNeedsAudioData(mockSong as any);
|
|
877
|
+
|
|
878
|
+
expect(result).toBe(true);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("should return false when blob metadata exists", async () => {
|
|
882
|
+
vi.mocked(getBlobMetadata).mockResolvedValue({ size: 5000 } as any);
|
|
883
|
+
|
|
884
|
+
const mockSong = createMockSong({ id: "valid-audio-song", sha: "valid-sha" });
|
|
885
|
+
|
|
886
|
+
const result = await songNeedsAudioData(mockSong);
|
|
887
|
+
|
|
888
|
+
expect(result).toBe(false);
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
describe("Background Image Loading", () => {
|
|
894
|
+
it("should schedule image loading after playlist initialization", async () => {
|
|
895
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
896
|
+
|
|
897
|
+
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
|
898
|
+
|
|
899
|
+
const playlistData = {
|
|
900
|
+
playlist: {
|
|
901
|
+
id: "image-playlist",
|
|
902
|
+
title: "image playlist",
|
|
903
|
+
imageExtension: ".jpg",
|
|
904
|
+
imageMimeType: "image/jpeg",
|
|
905
|
+
},
|
|
906
|
+
songs: [
|
|
907
|
+
{
|
|
908
|
+
id: "image-song",
|
|
909
|
+
title: "image song",
|
|
910
|
+
artist: "test artist",
|
|
911
|
+
album: "test album",
|
|
912
|
+
duration: 180,
|
|
913
|
+
fileSize: 1024,
|
|
914
|
+
sha: "test-sha",
|
|
915
|
+
imageExtension: ".jpg",
|
|
916
|
+
imageMimeType: "image/jpeg",
|
|
917
|
+
originalFilename: "song.mp3",
|
|
918
|
+
},
|
|
919
|
+
],
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const mockCallbacks = {
|
|
923
|
+
setSelectedPlaylist: vi.fn(),
|
|
924
|
+
setPlaylistSongs: vi.fn(),
|
|
925
|
+
setSidebarCollapsed: vi.fn(),
|
|
926
|
+
setError: vi.fn(),
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
await initializeStandalonePlaylist(playlistData, mockCallbacks);
|
|
930
|
+
|
|
931
|
+
expect(setTimeoutSpy).toHaveBeenCalled();
|
|
932
|
+
setTimeoutSpy.mockRestore();
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe("Progress Management Edge Cases", () => {
|
|
937
|
+
it("should handle rapid progress updates", () => {
|
|
938
|
+
for (let i = 0; i < 100; i++) {
|
|
939
|
+
setStandaloneLoadingProgress({
|
|
940
|
+
current: i,
|
|
941
|
+
total: 100,
|
|
942
|
+
currentSong: `Song ${i}`,
|
|
943
|
+
phase: "updating",
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const finalProgress = standaloneLoadingProgress();
|
|
948
|
+
expect(finalProgress?.current).toBe(99);
|
|
949
|
+
expect(finalProgress?.total).toBe(100);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("should handle null progress updates", () => {
|
|
953
|
+
setStandaloneLoadingProgress({
|
|
954
|
+
current: 50,
|
|
955
|
+
total: 100,
|
|
956
|
+
currentSong: "Test Song",
|
|
957
|
+
phase: "updating",
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
expect(standaloneLoadingProgress()).not.toBeNull();
|
|
961
|
+
|
|
962
|
+
setStandaloneLoadingProgress(null);
|
|
963
|
+
|
|
964
|
+
expect(standaloneLoadingProgress()).toBeNull();
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("should handle different progress phases", () => {
|
|
968
|
+
const phases = ["initializing", "reloading", "updating"] as const;
|
|
969
|
+
|
|
970
|
+
phases.forEach((phase) => {
|
|
971
|
+
setStandaloneLoadingProgress({
|
|
972
|
+
current: 1,
|
|
973
|
+
total: 3,
|
|
974
|
+
currentSong: `${phase} song`,
|
|
975
|
+
phase,
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const progress = standaloneLoadingProgress();
|
|
979
|
+
expect(progress?.phase).toBe(phase);
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
describe("Memory and Performance", () => {
|
|
985
|
+
it("should handle large song collections efficiently", async () => {
|
|
986
|
+
const largeSongCount = 1000;
|
|
987
|
+
vi.mocked(loadSetting).mockResolvedValue(null);
|
|
988
|
+
|
|
989
|
+
const playlistData = {
|
|
990
|
+
playlist: {
|
|
991
|
+
id: "large-playlist",
|
|
992
|
+
title: "large playlist",
|
|
993
|
+
description: "test description",
|
|
994
|
+
},
|
|
995
|
+
songs: Array.from({ length: largeSongCount }, (_, i) => ({
|
|
996
|
+
id: `song-${i}`,
|
|
997
|
+
title: `song ${i}`,
|
|
998
|
+
artist: "test artist",
|
|
999
|
+
album: "test album",
|
|
1000
|
+
duration: 180,
|
|
1001
|
+
originalFilename: `song-${i}.mp3`,
|
|
1002
|
+
fileSize: 1024,
|
|
1003
|
+
sha: `sha-${i}`,
|
|
1004
|
+
})),
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const mockCallbacks = {
|
|
1008
|
+
setSelectedPlaylist: vi.fn(),
|
|
1009
|
+
setPlaylistSongs: vi.fn(),
|
|
1010
|
+
setSidebarCollapsed: vi.fn(),
|
|
1011
|
+
setError: vi.fn(),
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const startTime = performance.now();
|
|
1015
|
+
|
|
1016
|
+
await initializeStandalonePlaylist(playlistData, mockCallbacks);
|
|
1017
|
+
|
|
1018
|
+
const endTime = performance.now();
|
|
1019
|
+
const duration = endTime - startTime;
|
|
1020
|
+
|
|
1021
|
+
expect(duration).toBeLessThan(1000);
|
|
1022
|
+
expect(mockCallbacks.setPlaylistSongs).toHaveBeenCalled();
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
});
|