@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,620 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { createRoot } from "solid-js";
|
|
3
|
+
import {
|
|
4
|
+
songUpdateTrigger,
|
|
5
|
+
triggerSongUpdate,
|
|
6
|
+
getSongUpdateTrigger,
|
|
7
|
+
getLastUpdateTime,
|
|
8
|
+
getSongSpecificTrigger,
|
|
9
|
+
triggerSpecificSongUpdate,
|
|
10
|
+
clearUpdateHistory,
|
|
11
|
+
getUpdateStats,
|
|
12
|
+
triggerSongUpdateWithOptions,
|
|
13
|
+
} from "./songReactivity.js";
|
|
14
|
+
|
|
15
|
+
describe("Song Reactivity System", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Clear update history before each test to ensure clean state
|
|
18
|
+
clearUpdateHistory();
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// Clean up after each test
|
|
24
|
+
clearUpdateHistory();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("Basic Reactivity", () => {
|
|
28
|
+
it("should start with a numeric trigger value", () => {
|
|
29
|
+
createRoot(() => {
|
|
30
|
+
const triggerValue = getSongUpdateTrigger();
|
|
31
|
+
expect(typeof triggerValue).toBe("number");
|
|
32
|
+
expect(songUpdateTrigger()).toBe(triggerValue);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should increment trigger when triggerSongUpdate is called", () => {
|
|
37
|
+
createRoot(() => {
|
|
38
|
+
const initialValue = getSongUpdateTrigger();
|
|
39
|
+
|
|
40
|
+
triggerSongUpdate();
|
|
41
|
+
expect(getSongUpdateTrigger()).toBe(initialValue + 1);
|
|
42
|
+
|
|
43
|
+
triggerSongUpdate();
|
|
44
|
+
expect(getSongUpdateTrigger()).toBe(initialValue + 2);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should track song updates with timestamps", () => {
|
|
49
|
+
createRoot(() => {
|
|
50
|
+
const songId = "test-song-123";
|
|
51
|
+
const beforeTime = Date.now();
|
|
52
|
+
|
|
53
|
+
triggerSongUpdate(songId);
|
|
54
|
+
|
|
55
|
+
const afterTime = Date.now();
|
|
56
|
+
const updateTime = getLastUpdateTime(songId);
|
|
57
|
+
|
|
58
|
+
expect(updateTime).toBeDefined();
|
|
59
|
+
expect(updateTime).toBeGreaterThanOrEqual(beforeTime);
|
|
60
|
+
expect(updateTime).toBeLessThanOrEqual(afterTime);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should return undefined for songs that haven't been updated", () => {
|
|
65
|
+
createRoot(() => {
|
|
66
|
+
const updateTime = getLastUpdateTime("non-existent-song");
|
|
67
|
+
expect(updateTime).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should handle multiple song updates", () => {
|
|
72
|
+
createRoot(() => {
|
|
73
|
+
const initialValue = getSongUpdateTrigger();
|
|
74
|
+
|
|
75
|
+
triggerSongUpdate("song1");
|
|
76
|
+
triggerSongUpdate("song2");
|
|
77
|
+
triggerSongUpdate("song1"); // Update song1 again
|
|
78
|
+
|
|
79
|
+
expect(getLastUpdateTime("song1")).toBeDefined();
|
|
80
|
+
expect(getLastUpdateTime("song2")).toBeDefined();
|
|
81
|
+
expect(getSongUpdateTrigger()).toBe(initialValue + 3);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("Song-Specific Signals", () => {
|
|
87
|
+
it("should create and return song-specific triggers", () => {
|
|
88
|
+
createRoot(() => {
|
|
89
|
+
const songId = "test-song-456";
|
|
90
|
+
const trigger1 = getSongSpecificTrigger(songId);
|
|
91
|
+
const trigger2 = getSongSpecificTrigger(songId);
|
|
92
|
+
|
|
93
|
+
// Should return the same trigger for the same song ID
|
|
94
|
+
expect(trigger1).toBe(trigger2);
|
|
95
|
+
expect(typeof trigger1).toBe("function");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should create different triggers for different songs", () => {
|
|
100
|
+
createRoot(() => {
|
|
101
|
+
const trigger1 = getSongSpecificTrigger("song1");
|
|
102
|
+
const trigger2 = getSongSpecificTrigger("song2");
|
|
103
|
+
|
|
104
|
+
expect(trigger1).not.toBe(trigger2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should start song-specific triggers at 0", () => {
|
|
109
|
+
createRoot(() => {
|
|
110
|
+
const songId = "test-song-789";
|
|
111
|
+
const trigger = getSongSpecificTrigger(songId);
|
|
112
|
+
|
|
113
|
+
expect(trigger()).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should increment song-specific triggers independently", () => {
|
|
118
|
+
createRoot(() => {
|
|
119
|
+
const song1Trigger = getSongSpecificTrigger("song1");
|
|
120
|
+
const song2Trigger = getSongSpecificTrigger("song2");
|
|
121
|
+
|
|
122
|
+
// Initial state
|
|
123
|
+
expect(song1Trigger()).toBe(0);
|
|
124
|
+
expect(song2Trigger()).toBe(0);
|
|
125
|
+
|
|
126
|
+
// Update song1 specifically
|
|
127
|
+
triggerSpecificSongUpdate("song1");
|
|
128
|
+
expect(song1Trigger()).toBe(1);
|
|
129
|
+
expect(song2Trigger()).toBe(0); // Should remain unchanged
|
|
130
|
+
|
|
131
|
+
// Update song2 specifically
|
|
132
|
+
triggerSpecificSongUpdate("song2");
|
|
133
|
+
expect(song1Trigger()).toBe(1); // Should remain unchanged
|
|
134
|
+
expect(song2Trigger()).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should update timestamp when triggering specific song update", () => {
|
|
139
|
+
createRoot(() => {
|
|
140
|
+
const songId = "test-song-timestamp";
|
|
141
|
+
const beforeTime = Date.now();
|
|
142
|
+
|
|
143
|
+
triggerSpecificSongUpdate(songId);
|
|
144
|
+
|
|
145
|
+
const afterTime = Date.now();
|
|
146
|
+
const updateTime = getLastUpdateTime(songId);
|
|
147
|
+
|
|
148
|
+
expect(updateTime).toBeDefined();
|
|
149
|
+
expect(updateTime).toBeGreaterThanOrEqual(beforeTime);
|
|
150
|
+
expect(updateTime).toBeLessThanOrEqual(afterTime);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should not affect global trigger when using triggerSpecificSongUpdate", () => {
|
|
155
|
+
createRoot(() => {
|
|
156
|
+
const initialGlobalTrigger = getSongUpdateTrigger();
|
|
157
|
+
|
|
158
|
+
triggerSpecificSongUpdate("test-song");
|
|
159
|
+
|
|
160
|
+
expect(getSongUpdateTrigger()).toBe(initialGlobalTrigger);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("Enhanced Trigger Options", () => {
|
|
166
|
+
it("should trigger global update by default", () => {
|
|
167
|
+
createRoot(() => {
|
|
168
|
+
const initialTrigger = getSongUpdateTrigger();
|
|
169
|
+
|
|
170
|
+
triggerSongUpdateWithOptions({ songId: "test-song" });
|
|
171
|
+
|
|
172
|
+
expect(getSongUpdateTrigger()).toBe(initialTrigger + 1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should only trigger specific song when specificOnly is true", () => {
|
|
177
|
+
createRoot(() => {
|
|
178
|
+
const songId = "test-song-specific";
|
|
179
|
+
const initialGlobalTrigger = getSongUpdateTrigger();
|
|
180
|
+
const songTrigger = getSongSpecificTrigger(songId);
|
|
181
|
+
const initialSongTrigger = songTrigger();
|
|
182
|
+
|
|
183
|
+
triggerSongUpdateWithOptions({
|
|
184
|
+
songId,
|
|
185
|
+
specificOnly: true,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Global trigger should not change
|
|
189
|
+
expect(getSongUpdateTrigger()).toBe(initialGlobalTrigger);
|
|
190
|
+
|
|
191
|
+
// Song-specific trigger should increment
|
|
192
|
+
expect(songTrigger()).toBe(initialSongTrigger + 1);
|
|
193
|
+
|
|
194
|
+
// Timestamp should be updated
|
|
195
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should handle different update types", () => {
|
|
200
|
+
createRoot(() => {
|
|
201
|
+
const songId = "test-song-types";
|
|
202
|
+
const initialTrigger = getSongUpdateTrigger();
|
|
203
|
+
|
|
204
|
+
// Test different update types
|
|
205
|
+
const updateTypes = ["edit", "create", "delete", "reorder"] as const;
|
|
206
|
+
|
|
207
|
+
updateTypes.forEach((type, index) => {
|
|
208
|
+
triggerSongUpdateWithOptions({
|
|
209
|
+
songId: `${songId}-${type}`,
|
|
210
|
+
type,
|
|
211
|
+
metadata: { operation: type },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(getSongUpdateTrigger()).toBe(initialTrigger + index + 1);
|
|
215
|
+
expect(getLastUpdateTime(`${songId}-${type}`)).toBeDefined();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should handle options without songId", () => {
|
|
221
|
+
createRoot(() => {
|
|
222
|
+
const initialTrigger = getSongUpdateTrigger();
|
|
223
|
+
|
|
224
|
+
triggerSongUpdateWithOptions({
|
|
225
|
+
type: "edit",
|
|
226
|
+
metadata: { batch: true },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(getSongUpdateTrigger()).toBe(initialTrigger + 1);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should handle metadata in options", () => {
|
|
234
|
+
createRoot(() => {
|
|
235
|
+
const songId = "test-song-metadata";
|
|
236
|
+
const metadata = {
|
|
237
|
+
reason: "user-edit",
|
|
238
|
+
batch: false,
|
|
239
|
+
timestamp: Date.now(),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// The function should not throw with metadata
|
|
243
|
+
expect(() => {
|
|
244
|
+
triggerSongUpdateWithOptions({
|
|
245
|
+
songId,
|
|
246
|
+
type: "edit",
|
|
247
|
+
metadata,
|
|
248
|
+
});
|
|
249
|
+
}).not.toThrow();
|
|
250
|
+
|
|
251
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("Update Statistics and History", () => {
|
|
257
|
+
it("should provide accurate update statistics", () => {
|
|
258
|
+
createRoot(() => {
|
|
259
|
+
// Initial stats after clearing
|
|
260
|
+
let stats = getUpdateStats();
|
|
261
|
+
const initialUpdates = stats.totalUpdates;
|
|
262
|
+
expect(stats.trackedSongs).toBe(0);
|
|
263
|
+
expect(stats.recentUpdates).toHaveLength(0);
|
|
264
|
+
|
|
265
|
+
// Add some updates
|
|
266
|
+
triggerSongUpdate("song1");
|
|
267
|
+
triggerSongUpdate("song2");
|
|
268
|
+
triggerSongUpdate("song1"); // Update song1 again
|
|
269
|
+
|
|
270
|
+
stats = getUpdateStats();
|
|
271
|
+
expect(stats.totalUpdates).toBe(initialUpdates + 3);
|
|
272
|
+
expect(stats.trackedSongs).toBe(2); // Only 2 unique songs
|
|
273
|
+
expect(stats.recentUpdates).toHaveLength(2);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("should order recent updates by timestamp", () => {
|
|
278
|
+
createRoot(() => {
|
|
279
|
+
// Add updates in sequence
|
|
280
|
+
triggerSongUpdate("song1");
|
|
281
|
+
triggerSongUpdate("song2");
|
|
282
|
+
|
|
283
|
+
const stats = getUpdateStats();
|
|
284
|
+
const recentUpdates = stats.recentUpdates;
|
|
285
|
+
|
|
286
|
+
if (recentUpdates.length >= 2) {
|
|
287
|
+
// More recent update should have higher or equal timestamp
|
|
288
|
+
expect(recentUpdates[0]?.[1]).toBeGreaterThanOrEqual(
|
|
289
|
+
recentUpdates[1]?.[1] || 0
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should limit recent updates to 10 entries", () => {
|
|
296
|
+
createRoot(() => {
|
|
297
|
+
// Add more than 10 updates
|
|
298
|
+
for (let i = 0; i < 15; i++) {
|
|
299
|
+
triggerSongUpdate(`song${i}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const stats = getUpdateStats();
|
|
303
|
+
expect(stats.recentUpdates.length).toBeLessThanOrEqual(10);
|
|
304
|
+
expect(stats.trackedSongs).toBe(15);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should clear all update history", () => {
|
|
309
|
+
createRoot(() => {
|
|
310
|
+
// Add some updates
|
|
311
|
+
triggerSongUpdate("song1");
|
|
312
|
+
triggerSongUpdate("song2");
|
|
313
|
+
const songTrigger = getSongSpecificTrigger("song3");
|
|
314
|
+
triggerSpecificSongUpdate("song3");
|
|
315
|
+
|
|
316
|
+
// Verify updates exist
|
|
317
|
+
expect(getLastUpdateTime("song1")).toBeDefined();
|
|
318
|
+
expect(getLastUpdateTime("song2")).toBeDefined();
|
|
319
|
+
expect(getLastUpdateTime("song3")).toBeDefined();
|
|
320
|
+
expect(songTrigger()).toBe(1);
|
|
321
|
+
|
|
322
|
+
// Clear history
|
|
323
|
+
clearUpdateHistory();
|
|
324
|
+
|
|
325
|
+
// Verify everything is cleared
|
|
326
|
+
expect(getLastUpdateTime("song1")).toBeUndefined();
|
|
327
|
+
expect(getLastUpdateTime("song2")).toBeUndefined();
|
|
328
|
+
expect(getLastUpdateTime("song3")).toBeUndefined();
|
|
329
|
+
|
|
330
|
+
const stats = getUpdateStats();
|
|
331
|
+
expect(stats.trackedSongs).toBe(0);
|
|
332
|
+
expect(stats.recentUpdates).toHaveLength(0);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("Edge Cases and Error Handling", () => {
|
|
338
|
+
it("should handle empty song IDs gracefully", () => {
|
|
339
|
+
createRoot(() => {
|
|
340
|
+
expect(() => {
|
|
341
|
+
triggerSongUpdate("");
|
|
342
|
+
triggerSpecificSongUpdate("");
|
|
343
|
+
getSongSpecificTrigger("");
|
|
344
|
+
getLastUpdateTime("");
|
|
345
|
+
}).not.toThrow();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("should handle undefined song IDs", () => {
|
|
350
|
+
createRoot(() => {
|
|
351
|
+
expect(() => {
|
|
352
|
+
triggerSongUpdate(undefined);
|
|
353
|
+
getLastUpdateTime(undefined as any);
|
|
354
|
+
}).not.toThrow();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("should handle rapid successive updates", () => {
|
|
359
|
+
createRoot(() => {
|
|
360
|
+
const songId = "rapid-update-song";
|
|
361
|
+
const initialTrigger = getSongUpdateTrigger();
|
|
362
|
+
|
|
363
|
+
// Trigger multiple rapid updates
|
|
364
|
+
for (let i = 0; i < 100; i++) {
|
|
365
|
+
triggerSongUpdate(songId);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
expect(getSongUpdateTrigger()).toBe(initialTrigger + 100);
|
|
369
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("should handle concurrent song-specific updates", () => {
|
|
374
|
+
createRoot(() => {
|
|
375
|
+
const songs = ["song1", "song2", "song3"];
|
|
376
|
+
const triggers = songs.map((id) => getSongSpecificTrigger(id));
|
|
377
|
+
|
|
378
|
+
// Simulate concurrent updates
|
|
379
|
+
songs.forEach((songId) => {
|
|
380
|
+
triggerSpecificSongUpdate(songId);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// All song-specific triggers should be incremented
|
|
384
|
+
triggers.forEach((trigger) => {
|
|
385
|
+
expect(trigger()).toBe(1);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// All songs should have timestamps
|
|
389
|
+
songs.forEach((songId) => {
|
|
390
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("should maintain separate state for each song", () => {
|
|
396
|
+
createRoot(() => {
|
|
397
|
+
const song1Trigger = getSongSpecificTrigger("song1");
|
|
398
|
+
const song2Trigger = getSongSpecificTrigger("song2");
|
|
399
|
+
|
|
400
|
+
// Update song1 multiple times
|
|
401
|
+
triggerSpecificSongUpdate("song1");
|
|
402
|
+
triggerSpecificSongUpdate("song1");
|
|
403
|
+
triggerSpecificSongUpdate("song1");
|
|
404
|
+
|
|
405
|
+
// Update song2 once
|
|
406
|
+
triggerSpecificSongUpdate("song2");
|
|
407
|
+
|
|
408
|
+
expect(song1Trigger()).toBe(3);
|
|
409
|
+
expect(song2Trigger()).toBe(1);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("Integration Scenarios", () => {
|
|
415
|
+
it("should handle mixed global and specific updates", () => {
|
|
416
|
+
createRoot(() => {
|
|
417
|
+
const songId = "mixed-update-song";
|
|
418
|
+
const songTrigger = getSongSpecificTrigger(songId);
|
|
419
|
+
const initialGlobalTrigger = getSongUpdateTrigger();
|
|
420
|
+
|
|
421
|
+
// Mix of update types
|
|
422
|
+
triggerSongUpdate(songId); // Global + tracking
|
|
423
|
+
triggerSpecificSongUpdate(songId); // Specific only
|
|
424
|
+
triggerSongUpdate(); // Global only, no songId
|
|
425
|
+
triggerSongUpdateWithOptions({ songId, specificOnly: true }); // Specific only
|
|
426
|
+
|
|
427
|
+
expect(getSongUpdateTrigger()).toBe(initialGlobalTrigger + 2); // Only 2 global updates
|
|
428
|
+
expect(songTrigger()).toBe(2); // 2 specific updates
|
|
429
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("should work correctly after clearing history", () => {
|
|
434
|
+
createRoot(() => {
|
|
435
|
+
const songId = "post-clear-song";
|
|
436
|
+
|
|
437
|
+
// Add initial updates
|
|
438
|
+
triggerSongUpdate(songId);
|
|
439
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
440
|
+
|
|
441
|
+
// Clear history
|
|
442
|
+
clearUpdateHistory();
|
|
443
|
+
expect(getLastUpdateTime(songId)).toBeUndefined();
|
|
444
|
+
|
|
445
|
+
// Add new updates after clearing
|
|
446
|
+
triggerSongUpdate(songId);
|
|
447
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
448
|
+
|
|
449
|
+
const songTrigger = getSongSpecificTrigger(songId);
|
|
450
|
+
triggerSpecificSongUpdate(songId);
|
|
451
|
+
expect(songTrigger()).toBe(1);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("should handle realistic user workflow", () => {
|
|
456
|
+
createRoot(() => {
|
|
457
|
+
const playlistId = "playlist123";
|
|
458
|
+
const songIds = ["song1", "song2", "song3"];
|
|
459
|
+
const initialTrigger = getSongUpdateTrigger();
|
|
460
|
+
|
|
461
|
+
// User creates playlist and adds songs
|
|
462
|
+
songIds.forEach((songId) => {
|
|
463
|
+
triggerSongUpdateWithOptions({
|
|
464
|
+
songId,
|
|
465
|
+
type: "create",
|
|
466
|
+
metadata: { playlistId },
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// User edits song1
|
|
471
|
+
triggerSongUpdateWithOptions({
|
|
472
|
+
songId: "song1",
|
|
473
|
+
type: "edit",
|
|
474
|
+
metadata: { field: "title", playlistId },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// User reorders songs
|
|
478
|
+
triggerSongUpdateWithOptions({
|
|
479
|
+
type: "reorder",
|
|
480
|
+
metadata: { playlistId, newOrder: ["song2", "song1", "song3"] },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Verify all operations were tracked
|
|
484
|
+
expect(getSongUpdateTrigger()).toBeGreaterThanOrEqual(
|
|
485
|
+
initialTrigger + 4
|
|
486
|
+
); // 3 creates + 1 edit + 1 reorder
|
|
487
|
+
songIds.forEach((songId) => {
|
|
488
|
+
expect(getLastUpdateTime(songId)).toBeDefined();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const stats = getUpdateStats();
|
|
492
|
+
expect(stats.trackedSongs).toBe(3);
|
|
493
|
+
expect(stats.totalUpdates).toBeGreaterThanOrEqual(4);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe("Development Helpers", () => {
|
|
499
|
+
it("should expose debugging functions in development mode", () => {
|
|
500
|
+
createRoot(() => {
|
|
501
|
+
// Mock development environment
|
|
502
|
+
const originalDEV = (globalThis as any).__DEV__;
|
|
503
|
+
(globalThis as any).__DEV__ = true;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
// Simulate development helper setup
|
|
507
|
+
const mockGlobal = globalThis as any;
|
|
508
|
+
mockGlobal.__songReactivity = {
|
|
509
|
+
getSongUpdateTrigger,
|
|
510
|
+
getUpdateStats,
|
|
511
|
+
clearUpdateHistory,
|
|
512
|
+
triggerSongUpdate,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
expect(mockGlobal.__songReactivity).toBeDefined();
|
|
516
|
+
expect(typeof mockGlobal.__songReactivity.getSongUpdateTrigger).toBe(
|
|
517
|
+
"function"
|
|
518
|
+
);
|
|
519
|
+
expect(typeof mockGlobal.__songReactivity.getUpdateStats).toBe(
|
|
520
|
+
"function"
|
|
521
|
+
);
|
|
522
|
+
expect(typeof mockGlobal.__songReactivity.clearUpdateHistory).toBe(
|
|
523
|
+
"function"
|
|
524
|
+
);
|
|
525
|
+
expect(typeof mockGlobal.__songReactivity.triggerSongUpdate).toBe(
|
|
526
|
+
"function"
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Test that debugging functions work
|
|
530
|
+
expect(() => {
|
|
531
|
+
mockGlobal.__songReactivity.triggerSongUpdate("debug-song");
|
|
532
|
+
mockGlobal.__songReactivity.getUpdateStats();
|
|
533
|
+
}).not.toThrow();
|
|
534
|
+
} finally {
|
|
535
|
+
(globalThis as any).__DEV__ = originalDEV;
|
|
536
|
+
delete (globalThis as any).__songReactivity;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe("Performance Considerations", () => {
|
|
543
|
+
it("should handle many song updates efficiently", () => {
|
|
544
|
+
createRoot(() => {
|
|
545
|
+
const startTime = performance.now();
|
|
546
|
+
const initialTrigger = getSongUpdateTrigger();
|
|
547
|
+
|
|
548
|
+
// Simulate many updates
|
|
549
|
+
for (let i = 0; i < 1000; i++) {
|
|
550
|
+
triggerSongUpdate(`song${i % 100}`); // 1000 updates across 100 songs
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const endTime = performance.now();
|
|
554
|
+
const duration = endTime - startTime;
|
|
555
|
+
|
|
556
|
+
// Should complete in reasonable time (less than 100ms on most systems)
|
|
557
|
+
expect(duration).toBeLessThan(100);
|
|
558
|
+
|
|
559
|
+
// Verify state is correct
|
|
560
|
+
expect(getSongUpdateTrigger()).toBe(initialTrigger + 1000);
|
|
561
|
+
expect(getUpdateStats().trackedSongs).toBe(100);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("should handle many song-specific triggers efficiently", () => {
|
|
566
|
+
createRoot(() => {
|
|
567
|
+
const songCount = 500;
|
|
568
|
+
const triggers: (() => number)[] = [];
|
|
569
|
+
|
|
570
|
+
const startTime = performance.now();
|
|
571
|
+
|
|
572
|
+
// Create many song-specific triggers
|
|
573
|
+
for (let i = 0; i < songCount; i++) {
|
|
574
|
+
triggers.push(getSongSpecificTrigger(`song${i}`));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Update each one
|
|
578
|
+
for (let i = 0; i < songCount; i++) {
|
|
579
|
+
triggerSpecificSongUpdate(`song${i}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const endTime = performance.now();
|
|
583
|
+
const duration = endTime - startTime;
|
|
584
|
+
|
|
585
|
+
// Should complete in reasonable time
|
|
586
|
+
expect(duration).toBeLessThan(200);
|
|
587
|
+
|
|
588
|
+
// Verify all triggers were updated
|
|
589
|
+
triggers.forEach((trigger) => {
|
|
590
|
+
expect(trigger()).toBe(1);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
describe("Function Exports", () => {
|
|
597
|
+
it("should export all required functions", () => {
|
|
598
|
+
expect(typeof songUpdateTrigger).toBe("function");
|
|
599
|
+
expect(typeof triggerSongUpdate).toBe("function");
|
|
600
|
+
expect(typeof getSongUpdateTrigger).toBe("function");
|
|
601
|
+
expect(typeof getLastUpdateTime).toBe("function");
|
|
602
|
+
expect(typeof getSongSpecificTrigger).toBe("function");
|
|
603
|
+
expect(typeof triggerSpecificSongUpdate).toBe("function");
|
|
604
|
+
expect(typeof clearUpdateHistory).toBe("function");
|
|
605
|
+
expect(typeof getUpdateStats).toBe("function");
|
|
606
|
+
expect(typeof triggerSongUpdateWithOptions).toBe("function");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("should maintain signal functionality", () => {
|
|
610
|
+
createRoot(() => {
|
|
611
|
+
// Test that signals work as expected
|
|
612
|
+
const initialValue = songUpdateTrigger();
|
|
613
|
+
triggerSongUpdate();
|
|
614
|
+
const newValue = songUpdateTrigger();
|
|
615
|
+
|
|
616
|
+
expect(newValue).toBeGreaterThan(initialValue);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
});
|