Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
dev/openkara/src/mock/tauri-mock-impl.ts
Raw
1// @generated by extracting the shared mock IPC logic from2 3/**4 * Data payload for the mock Tauri IPC.  All fields must be JSON-serializable5 * (no Uint8Array, no functions, no undefined values that would be dropped by6 * JSON.stringify).7 */8export interface MockData {9  songs: MockSong[];
10
lyrics: MockLyrics;
11 primarySongHash: string;
12 sidebarWidth: number;
13 libraryRegistry: {
14 active_library_id: string | null;
15 libraries: Array<{
16 id: string;
17 kind: string;
18 display_name: string;
19 root_path: string;
20 }>;
21 };
22 activeLibrary: {
23 id: string;
24 kind: string;
25 display_name: string;
26 root_path: string;
27 };
28 libraryPath: string;
29 windowShellState: {
30 chrome_variant: string;
31 tier: string;
32 toolbar_height_px: number;
33 traffic_light_inset_leading: number;
34 sidebar_header_height_px: number;
35 sidebar_width_px: number;
36 };
37 /** Initial settings snapshot (mutable via set_* commands). */
38 settings: Record<string, unknown>;
39 /** Initial playback snapshot (mutable via play/pause/seek/etc). */
40 playbackSnapshot: Record<string, unknown>;
41 bootstrapStatus: Record<string, unknown>;
42 /** Initial playlists (empty array for E2E; website preview adds some). */
43 playlists: Array<{
44 id: string;
45 name: string;
46 song_count: number;
47 created_at: number;
48 updated_at: number;
49 }>;
50 playlistSongs: Record<string, string[]>;
51 rotationState: {
52 singer_names: string[];
53 current_index: number;
54 mode: string;
55 active: boolean;
56 };
57 /** When true, playback loops back to loopStartPositionMs on song end. */
58 loopPlayback?: boolean;
59 /** Position to seek back to when looping. Defaults to 0. */
60 loopStartPositionMs?: number;
61}
62 
63// cover_art as number[] for JSON serialization.
64export interface MockSong {
65 hash: string;
66 file_path: string | null;
67 audio_source_kind: string;
68 cdg_path: string | null;
69 media_g_container: string | null;
70 instrumental: boolean;
71 language: string | null;
72 title: string | null;
73 artist: string | null;
74 album: string | null;
75 duration_ms: number;
76 cover_art: number[] | null;
77 imported_at: number;
78 original_ext: string | null;
79}
80 
81export interface MockLyrics {
82 raw_lrc: string;
83 lines: Array<{
84 time_ms: number;
85 text: string;
86 words: unknown;
87 bg_words: unknown;
88 section: unknown;
89 }>;
90 offset_ms: number;
91 source: string;
92}
93 
94export interface TauriMockResult {
95 internals: {
96 metadata: {
97 currentWindow: { label: string };
98 currentWebview: { label: string };
99 };
100 invoke: (cmd: string, args?: unknown) => Promise<unknown>;
101 transformCallback: (
102 callback: (...args: unknown[]) => void,
103 once?: boolean,
104 ) => number;
105 unregisterCallback: (id: number) => void;
106 };
107 eventPluginInternals: { unregisterListener: () => void };
108 helpers: {
109 emitEvent: (eventName: string, payload: unknown) => void;
110 setCommandDelayMs: (cmd: string, delayMs: number) => void;
111 setMockSongs: (songs: MockSong[]) => void;
112 setMockLyrics: (lyrics: MockLyrics) => void;
113 setLargeLibrary: (count: number) => void;
114 getInvokeCalls: () => Array<{ cmd: string; args: unknown }>;
115 getLastNativeMenu: () => unknown;
116 clickNativeMenuItem: (label: string) => Promise<void>;
117 clickNativeSubmenuItem: (
118 parentLabel: string,
119 label: string,
120 ) => Promise<void>;
121 setPlaybackSnapshot: (
122 patch: Record<string, unknown>,
123 ) => Record<string, unknown>;
124 setSeparationCompleted: (songHash: string) => void;
125 getPlaybackSnapshot: () => Record<string, unknown>;
126 };
127}
128 
129// Everything below is a single self-contained function. All logic lives
130// inside the function body so `toString()` produces a valid standalone script.
131 
132// eslint-disable-next-line @typescript-eslint/no-explicit-any -- must be `any` for toString() serialization
133export function createTauriMock(data: any): TauriMockResult {
134 // Mutable so tests can override the library, lyrics, playback, etc.
135 let mockSongs = data.songs;
136 let mockLyrics = data.lyrics;
137 const invokeCalls: Array<{ cmd: string; args: any }> = [];
138 const commandDelayMs = new Map<string, number>();
139 const playlists: any[] = (data.playlists || []).map((p: any) => ({ ...p }));
140 const playlistSongs = new Map<string, any[]>();
141 const menuResources = new Map<number, any>();
142 const separationStatuses: Record<string, any> = {};
143 let nextPlaylistId = 1;
144 let nextEventId = 1;
145 let nextMenuRid = 1;
146 let lastNativeMenu: any = null;
147 let transportGeneration = 0;
148 let rotationState = { ...data.rotationState };
149 let settingsSnapshot = { ...data.settings };
150 let currentPlaybackSnapshot = { ...data.playbackSnapshot };
151 
152 let playbackEndTimer: ReturnType<typeof setTimeout> | null = null;
153 
154 // Initialize playlist songs from data
155 if (data.playlistSongs) {
156 for (const [playlistId, songHashes] of Object.entries(data.playlistSongs)) {
157 playlistSongs.set(
158 playlistId,
159 (songHashes as string[]).map((songHash, index) => ({
160 song_hash: songHash,
161 added_at: index + 1,
162 sort_order: index,
163 singer: null,
164 })),
165 );
166 }
167 }
168 
169 function clone(value: any): any {
170 if (value == null) return value;
171 return JSON.parse(JSON.stringify(value));
172 }
173 
174 function bumpTransportGeneration(): number {
175 transportGeneration += 1;
176 (currentPlaybackSnapshot as any).transport_generation = transportGeneration;
177 return transportGeneration;
178 }
179 
180 function schedulePlaybackEnd(): void {
181 if (playbackEndTimer) {
182 clearTimeout(playbackEndTimer);
183 playbackEndTimer = null;
184 }
185 const snap = currentPlaybackSnapshot as any;
186 if (!snap.is_playing || snap.state === "buffering") return;
187 const remaining = (snap.duration_ms || 0) - (snap.position_ms || 0);
188 if (remaining <= 0) return;
189 playbackEndTimer = setTimeout(() => {
190 playbackEndTimer = null;
191 const gen = (currentPlaybackSnapshot as any).transport_generation;
192 if (gen !== transportGeneration) return;
193 if (data.loopPlayback) {
194 const loopMs = data.loopStartPositionMs ?? 0;
195 currentPlaybackSnapshot = {
196 ...currentPlaybackSnapshot,
197 state: "playing",
198 is_playing: true,
199 position_ms: loopMs,
200 };
201 emitMockEvent("playback-position", {
202 ms: loopMs,
203 transport_generation: transportGeneration,
204 snapshot: clone(currentPlaybackSnapshot),
205 });
206 schedulePlaybackEnd();
207 } else {
208 currentPlaybackSnapshot = {
209 ...currentPlaybackSnapshot,
210 state: "idle",
211 is_playing: false,
212 position_ms: (currentPlaybackSnapshot as any).duration_ms,
213 };
214 emitMockEvent("playback-position", {
215 ms: (currentPlaybackSnapshot as any).duration_ms,
216 transport_generation: transportGeneration,
217 snapshot: clone(currentPlaybackSnapshot),
218 });
219 }
220 }, remaining);
221 }
222 
223 function clearPlaybackEnd(): void {
224 if (playbackEndTimer) {
225 clearTimeout(playbackEndTimer);
226 playbackEndTimer = null;
227 }
228 }
229 
230 function resolveCommandResult(cmd: string, result: any): Promise<any> {
231 return Promise.resolve(result)
232 .then((value) => clone(value))
233 .then((value) => {
234 const delayMs = commandDelayMs.get(cmd) || 0;
235 if (delayMs <= 0) return value;
236 return new Promise((resolve) => {
237 setTimeout(() => resolve(value), delayMs);
238 });
239 });
240 }
241 
242 function playlistSnapshot(): any[] {
243 return playlists.map((playlist) => ({
244 ...playlist,
245 song_count: playlistSongs.get(playlist.id)?.length || 0,
246 }));
247 }
248 
249 function menuResourceSnapshot(resource: any): any {
250 return {
251 label: resource.label,
252 children: resource.items
253 ? resource.items.map((child: any) => menuResourceSnapshot(child))
254 : undefined,
255 };
256 }
257 
258 function readMenuItemReference(reference: any): any {
259 const rid = Array.isArray(reference) ? reference[0] : reference?.rid;
260 const resource = menuResources.get(rid);
261 if (!resource) {
262 throw new Error("Unknown native menu resource in mock: " + rid);
263 }
264 return resource;
265 }
266 
267 const eventListeners = new Map<string, Map<number, number>>();
268 let callbackId = 0;
269 const callbacks = new Map<number, (...args: any[]) => void>();
270 
271 function emitMockEvent(eventName: string, payload: any): void {
272 const handlers = eventListeners.get(eventName);
273 if (!handlers) return;
274 for (const [id, callbackIdNum] of handlers) {
275 const callback = callbacks.get(callbackIdNum);
276 if (typeof callback === "function") {
277 callback({ event: eventName, id, payload: clone(payload) });
278 }
279 }
280 }
281 
282 function handleTauriEventCommand(
283 cmd: string,
284 args: any,
285 ): Promise<any> | null {
286 if (cmd === "plugin:event|listen") {
287 const id = nextEventId++;
288 if (args && args.event && typeof args.handler === "number") {
289 const handlers =
290 eventListeners.get(args.event) || new Map<number, number>();
291 handlers.set(id, args.handler);
292 eventListeners.set(args.event, handlers);
293 }
294 return Promise.resolve(id);
295 }
296 if (cmd === "plugin:event|unlisten") {
297 if (args && args.event) {
298 const handlers = eventListeners.get(args.event);
299 if (handlers) handlers.delete(args.eventId);
300 }
301 return Promise.resolve(undefined);
302 }
303 if (cmd === "plugin:event|emit" || cmd === "plugin:event|emit_to") {
304 return Promise.resolve(undefined);
305 }
306 return null;
307 }
308 
309 function handleTauriResourceCommand(
310 cmd: string,
311 args: any,
312 ): Promise<any> | null {
313 if (cmd === "plugin:resources|close") {
314 menuResources.delete(args.rid);
315 return Promise.resolve(undefined);
316 }
317 return null;
318 }
319 
320 function handleTauriWindowCommand(cmd: string): Promise<any> | null {
321 if (cmd === "plugin:window|is_maximized") {
322 return Promise.resolve(false);
323 }
324 if (
325 cmd === "plugin:window|close" ||
326 cmd === "plugin:window|minimize" ||
327 cmd === "plugin:window|start_dragging" ||
328 cmd === "plugin:window|start_resize_dragging" ||
329 cmd === "plugin:window|toggle_maximize"
330 ) {
331 return Promise.resolve(undefined);
332 }
333 return null;
334 }
335 
336 const COMMANDS: Record<string, any> = {
337 get_library_registry: data.libraryRegistry,
338 get_settings: () => settingsSnapshot,
339 get_window_shell_state: data.windowShellState,
340 get_library_path: data.libraryPath,
341 
342 get_active_library: data.activeLibrary,
343 get_library: () => mockSongs,
344 search_library: (args: any) => {
345 const q = ((args && args.query) || "").toLowerCase();
346 if (!q) return mockSongs;
347 return mockSongs.filter(
348 (s: any) =>
349 (s.title ?? "").toLowerCase().includes(q) ||
350 (s.artist ?? "").toLowerCase().includes(q),
351 );
352 },
353 get_all_separation_statuses: () => clone(Object.values(separationStatuses)),
354 get_all_upload_statuses: {},
355 get_remote_cache_usage: () => ({
356 used_bytes: 512 * 1024 * 1024,
357 limit_bytes: 2 * 1024 * 1024 * 1024,
358 entry_count: 3,
359 pinned_count: 0,
360 }),
361 clear_remote_cache: () => 0,
362 get_remote_diagnostics: () => ({
363 has_active_remote: false,
364 repository_id: null,
365 writer_id: null,
366 committed_generation: 0,
367 local_base_generation: 0,
368 local_state: "clean",
369 local_db_digest: null,
370 active_operation_id: null,
371 last_success_at_ms: null,
372 last_error_code: null,
373 recent_operations: [],
374 }),
375 
376 get_playback_state: () => clone(currentPlaybackSnapshot),
377 play: (args: any) => {
378 const songId =
379 (args && (args.songId || args.song_id)) || data.primarySongHash;
380 const song = mockSongs.find((s: any) => s.hash === songId);
381 bumpTransportGeneration();
382 currentPlaybackSnapshot = {
383 ...currentPlaybackSnapshot,
384 song_id: songId,
385 state: "playing",
386 is_playing: true,
387 position_ms: 0,
388 duration_ms: song ? song.duration_ms : 300000,
389 buffered_ms: 0,
390 };
391 schedulePlaybackEnd();
392 return clone(currentPlaybackSnapshot);
393 },
394 resume: () => {
395 bumpTransportGeneration();
396 currentPlaybackSnapshot = {
397 ...currentPlaybackSnapshot,
398 state: "playing",
399 is_playing: true,
400 };
401 schedulePlaybackEnd();
402 return clone(currentPlaybackSnapshot);
403 },
404 pause: () => {
405 bumpTransportGeneration();
406 clearPlaybackEnd();
407 currentPlaybackSnapshot = {
408 ...currentPlaybackSnapshot,
409 state: "idle",
410 is_playing: false,
411 };
412 return clone(currentPlaybackSnapshot);
413 },
414 seek: (args: any) => {
415 bumpTransportGeneration();
416 clearPlaybackEnd();
417 const targetMs = (args && args.ms) || 0;
418 const bufferingSnapshot = {
419 ...currentPlaybackSnapshot,
420 state: "buffering",
421 is_playing: true,
422 position_ms: targetMs,
423 buffered_ms: targetMs,
424 };
425 currentPlaybackSnapshot = bufferingSnapshot;
426 const playingSnapshot = {
427 ...bufferingSnapshot,
428 state: "playing",
429 position_ms: targetMs + 50,
430 buffered_ms: (currentPlaybackSnapshot as any).duration_ms || 354000,
431 };
432 queueMicrotask(() => {
433 emitMockEvent("playback-position", {
434 ms: bufferingSnapshot.position_ms,
435 transport_generation: (bufferingSnapshot as any).transport_generation,
436 snapshot: clone(bufferingSnapshot),
437 });
438 });
439 setTimeout(() => {
440 currentPlaybackSnapshot = playingSnapshot;
441 emitMockEvent("playback-position", {
442 ms: playingSnapshot.position_ms,
443 transport_generation: (playingSnapshot as any).transport_generation,
444 snapshot: clone(playingSnapshot),
445 });
446 schedulePlaybackEnd();
447 }, 80);
448 return clone(bufferingSnapshot);
449 },
450 set_volume: (args: any) => {
451 const level = args && args.level != null ? args.level : 0.8;
452 currentPlaybackSnapshot = { ...currentPlaybackSnapshot, volume: level };
453 return clone(currentPlaybackSnapshot);
454 },
455 set_stem_volume: (args: any) => {
456 const stem = args && args.stem;
457 const level = args && args.level != null ? args.level : 1;
458 if (stem && (currentPlaybackSnapshot as any).stem_volumes) {
459 currentPlaybackSnapshot = {
460 ...currentPlaybackSnapshot,
461 stem_volumes: {
462 ...(currentPlaybackSnapshot as any).stem_volumes,
463 [stem]: level,
464 },
465 };
466 }
467 return clone(currentPlaybackSnapshot);
468 },
469 load_stems: () => clone(currentPlaybackSnapshot),
470 
471 fetch_lyrics: (args: any) => ({ ...mockLyrics, song_id: args?.songId }),
472 fetch_lyrics_online: (args: any) => ({
473 ...data.lyrics,
474 song_id: args?.songId,
475 }),
476 save_manual_lyrics: (args: any) => ({
477 raw_lrc: (args && args.text) || "",
478 lines: [],
479 offset_ms: 0,
480 source: "manual",
481 }),
482 set_lyrics_offset: undefined,
483 
484 list_playlists: () => playlistSnapshot(),
485 create_playlist: (args: any) => {
486 const pl = {
487 id: "pl-" + nextPlaylistId++,
488 name: (args && args.name) || "New Playlist",
489 song_count: 0,
490 created_at: Date.now(),
491 updated_at: Date.now(),
492 };
493 return pl;
494 },
495 rename_playlist: undefined,
496 delete_playlist: undefined,
497 add_songs_to_playlist: (args: any) => {
498 const current = playlistSongs.get(args.playlistId) || [];
499 const existing = new Set(current.map((entry: any) => entry.song_hash));
500 const next = [...current];
501 for (const songHash of args.songHashes || []) {
502 if (!existing.has(songHash)) {
503 next.push({
504 song_hash: songHash,
505 added_at: Date.now(),
506 sort_order: next.length,
507 singer: null,
508 });
509 }
510 }
511 playlistSongs.set(args.playlistId, next);
512 return undefined;
513 },
514 remove_songs_from_playlist: (args: any) => {
515 const remove = new Set(args.songHashes || []);
516 const next = (playlistSongs.get(args.playlistId) || [])
517 .filter((entry: any) => !remove.has(entry.song_hash))
518 .map((entry: any, index: number) => ({ ...entry, sort_order: index }));
519 playlistSongs.set(args.playlistId, next);
520 return undefined;
521 },
522 get_playlist_songs: (args: any) => playlistSongs.get(args.playlistId) || [],
523 
524 get_rotation_state: () => rotationState,
525 set_rotation_state: (args: any) => {
526 rotationState = args.rotation;
527 return undefined;
528 },
529 advance_rotation: () => {
530 if (rotationState.singer_names.length > 0) {
531 rotationState = {
532 ...rotationState,
533 current_index:
534 (rotationState.current_index + 1) %
535 rotationState.singer_names.length,
536 };
537 }
538 return rotationState;
539 },
540 
541 get_model_bootstrap_status: data.bootstrapStatus,
542 
543 window_ready: undefined,
544 set_language: (args: any) => {
545 settingsSnapshot = {
546 ...settingsSnapshot,
547 language: (args && args.language) || "en",
548 };
549 return settingsSnapshot;
550 },
551 set_stem_mode: (args: any) => {
552 settingsSnapshot = {
553 ...settingsSnapshot,
554 stem_mode: (args && args.mode) || "two_stem",
555 };
556 return settingsSnapshot;
557 },
558 set_lyrics_font_step: (args: any) => {
559 settingsSnapshot = {
560 ...settingsSnapshot,
561 lyrics_font_step: (args && args.step) || 0,
562 };
563 return settingsSnapshot;
564 },
565 set_execution_provider: (args: any) => {
566 settingsSnapshot = {
567 ...settingsSnapshot,
568 execution_provider: (args && args.provider) || "cpu",
569 };
570 return settingsSnapshot;
571 },
572 set_hide_batch_separate: (args: any) => {
573 settingsSnapshot = {
574 ...settingsSnapshot,
575 hide_batch_separate: (args && args.value) || false,
576 };
577 return settingsSnapshot;
578 },
579 set_cover_art_backdrop: (args: any) => {
580 settingsSnapshot = {
581 ...settingsSnapshot,
582 cover_art_backdrop: (args && args.value) || false,
583 };
584 return settingsSnapshot;
585 },
586 set_hide_upgrade_all: (args: any) => {
587 settingsSnapshot = {
588 ...settingsSnapshot,
589 hide_upgrade_all: (args && args.value) || false,
590 };
591 return settingsSnapshot;
592 },
593 set_eq_enabled: (args: any) => {
594 settingsSnapshot = {
595 ...settingsSnapshot,
596 eq_enabled: !!(args && args.enabled),
597 };
598 return settingsSnapshot;
599 },
600 set_eq_gains: (args: any) => {
601 settingsSnapshot = {
602 ...settingsSnapshot,
603 eq_gains_db: (args && args.gainsDb) || [0, 0, 0, 0, 0],
604 };
605 return settingsSnapshot;
606 },
607 set_library_sort_mode: (args: any) => {
608 settingsSnapshot = {
609 ...settingsSnapshot,
610 library_sort_mode: (args && args.mode) || "recently_imported",
611 };
612 return settingsSnapshot;
613 },
614 set_theme_preference: (args: any) => {
615 settingsSnapshot = {
616 ...settingsSnapshot,
617 theme_preference: (args && args.preference) || "dark",
618 };
619 return settingsSnapshot;
620 },
621 set_update_policy: (args: any) => {
622 settingsSnapshot = {
623 ...settingsSnapshot,
624 update_policy: (args && args.policy) || "notify",
625 };
626 return settingsSnapshot;
627 },
628 check_runtime_updates: () => ({
629 generation: 1,
630 release_id: "mock-release",
631 target_triple: "aarch64-apple-darwin",
632 state: "up_to_date",
633 installed_version: "v1.27.1",
634 available_version: "v1.27.1",
635 available_bytes: 0,
636 restart_required: true,
637 }),
638 set_crossfade_enabled: (args: any) => {
639 settingsSnapshot = {
640 ...settingsSnapshot,
641 crossfade_enabled: !!(args && args.enabled),
642 };
643 return settingsSnapshot;
644 },
645 set_crossfade_duration_ms: (args: any) => {
646 settingsSnapshot = {
647 ...settingsSnapshot,
648 crossfade_duration_ms: (args && args.durationMs) || 3000,
649 };
650 return settingsSnapshot;
651 },
652 
653 get_audio_peaks: { writeIndex: 0, peaks: [] },
654 set_preload_candidate: undefined,
655 get_waveform: (args: any) => {
656 const buckets = (args && args.buckets) || 200;
657 return Array.from({ length: buckets }, () => 0);
658 },
659 restart_app: undefined,
660 create_library: undefined,
661 open_library: undefined,
662 batch_separate: undefined,
663 cancel_remote_auth: undefined,
664 };
665 
666 function invoke(cmd: string, args?: any): Promise<any> {
667 invokeCalls.push({ cmd, args: clone(args) });
668 
669 const eventResult = handleTauriEventCommand(cmd, args);
670 if (eventResult) return eventResult;
671 
672 const resourceResult = handleTauriResourceCommand(cmd, args || {});
673 if (resourceResult) return resourceResult;
674 
675 const windowResult = handleTauriWindowCommand(cmd);
676 if (windowResult) return windowResult;
677 
678 if (cmd === "plugin:menu|new") {
679 const rid = nextMenuRid++;
680 const options = (args && args.options) || {};
681 const items = (options.items || []).map(readMenuItemReference);
682 const resource = {
683 rid,
684 kind: args.kind,
685 label: options.text || options.item || "",
686 action: options.handler?.onmessage || args?.handler?.onmessage || null,
687 items,
688 popupPosition: null,
689 };
690 menuResources.set(rid, resource);
691 return Promise.resolve([rid, String(rid)]);
692 }
693 if (cmd === "plugin:menu|popup") {
694 const resource = readMenuItemReference({ rid: args.rid });
695 resource.popupPosition = args.at ? { x: args.at.x, y: args.at.y } : null;
696 lastNativeMenu = resource;
697 return Promise.resolve(undefined);
698 }
699 
700 const handler = COMMANDS[cmd];
701 if (handler === undefined) {
702 if (Object.prototype.hasOwnProperty.call(COMMANDS, cmd)) {
703 return Promise.resolve(undefined);
704 }
705 return Promise.reject(
706 new Error("Unhandled Tauri invoke in mock: " + cmd),
707 );
708 }
709 if (typeof handler === "function") {
710 try {
711 const result = handler(args);
712 if (cmd === "create_playlist" && result) {
713 playlists.push(result);
714 playlistSongs.set(result.id, []);
715 }
716 return resolveCommandResult(cmd, result);
717 } catch (e) {
718 return Promise.reject(e);
719 }
720 }
721 return resolveCommandResult(cmd, handler);
722 }
723 
724 function menuSnapshot(menu: any): any {
725 return {
726 items: menu.items.map((item: any) => menuResourceSnapshot(item)),
727 popupPosition: menu.popupPosition,
728 };
729 }
730 
731 async function clickMenuItem(menu: any, label: string): Promise<void> {
732 const item = menu.items.find((candidate: any) => candidate.label === label);
733 if (!item || typeof item.action !== "function") {
734 throw new Error("Native menu item not found in mock: " + label);
735 }
736 await item.action();
737 }
738 
739 async function clickSubmenuItem(
740 menu: any,
741 parentLabel: string,
742 label: string,
743 ): Promise<void> {
744 const parent = menu.items.find(
745 (candidate: any) => candidate.label === parentLabel,
746 );
747 const item = parent?.items?.find(
748 (candidate: any) => candidate.label === label,
749 );
750 if (!item || typeof item.action !== "function") {
751 throw new Error(
752 "Native submenu item not found in mock: " + parentLabel + " > " + label,
753 );
754 }
755 await item.action();
756 }
757 
758 function generateLargeLibrary(count: number): any[] {
759 const songs = [];
760 const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
761 for (let i = 0; i < count; i++) {
762 const letter = letters[i % 26];
763 songs.push({
764 hash: "large-" + i,
765 file_path: "/music/song-" + i + ".mp3",
766 audio_source_kind: "original",
767 cdg_path: null,
768 media_g_container: null,
769 instrumental: false,
770 language: "en",
771 title: letter + " Song " + i,
772 artist: letter + " Artist " + i,
773 album: null,
774 duration_ms: 180000,
775 cover_art: null,
776 imported_at: count - i,
777 original_ext: ".mp3",
778 });
779 }
780 return songs;
781 }
782 
783 // If the initial snapshot is already playing (website preview auto-play),
784 // schedule the end-of-song timer so playback loops correctly.
785 if ((currentPlaybackSnapshot as any).is_playing) {
786 schedulePlaybackEnd();
787 }
788 
789 // ── Return ──
790 return {
791 internals: {
792 metadata: {
793 currentWindow: { label: "main" },
794 currentWebview: { label: "main" },
795 },
796 invoke,
797 transformCallback: function (callback: (...args: any[]) => void): number {
798 const id = ++callbackId;
799 callbacks.set(id, callback);
800 return id;
801 },
802 unregisterCallback: function (id: number): void {
803 callbacks.delete(id);
804 },
805 },
806 eventPluginInternals: {
807 unregisterListener: function () {},
808 },
809 helpers: {
810 emitEvent: (eventName: string, payload: any) =>
811 emitMockEvent(eventName, payload),
812 setCommandDelayMs: (cmd: string, delayMs: number) => {
813 commandDelayMs.set(cmd, Math.max(0, delayMs));
814 },
815 setMockSongs: (songs: any[]) => {
816 mockSongs = songs;
817 },
818 setMockLyrics: (lyrics: any) => {
819 mockLyrics = lyrics;
820 },
821 setLargeLibrary: (count: number) => {
822 mockSongs = generateLargeLibrary(count);
823 },
824 getInvokeCalls: () => clone(invokeCalls),
825 getLastNativeMenu: () =>
826 lastNativeMenu ? menuSnapshot(lastNativeMenu) : null,
827 clickNativeMenuItem: async (label: string) => {
828 if (!lastNativeMenu) throw new Error("No native menu has been opened");
829 await clickMenuItem(lastNativeMenu, label);
830 },
831 clickNativeSubmenuItem: async (parentLabel: string, label: string) => {
832 if (!lastNativeMenu) throw new Error("No native menu has been opened");
833 await clickSubmenuItem(lastNativeMenu, parentLabel, label);
834 },
835 setPlaybackSnapshot: (patch: any) => {
836 const next = { ...currentPlaybackSnapshot, ...patch };
837 if (
838 patch &&
839 patch.stem_volumes &&
840 (currentPlaybackSnapshot as any).stem_volumes
841 ) {
842 next.stem_volumes = {
843 ...(currentPlaybackSnapshot as any).stem_volumes,
844 ...patch.stem_volumes,
845 };
846 }
847 currentPlaybackSnapshot = next;
848 emitMockEvent("playback-position", {
849 ms: next.position_ms,
850 transport_generation: next.transport_generation,
851 snapshot: clone(next),
852 });
853 if (
854 patch &&
855 (patch.is_playing !== undefined ||
856 patch.state !== undefined ||
857 patch.position_ms !== undefined ||
858 patch.duration_ms !== undefined)
859 ) {
860 if (next.is_playing && next.state !== "buffering") {
861 schedulePlaybackEnd();
862 } else {
863 clearPlaybackEnd();
864 }
865 }
866 return clone(next);
867 },
868 setSeparationCompleted: (songHash: string) => {
869 separationStatuses[songHash] = {
870 song_id: songHash,
871 state: "completed",
872 };
873 emitMockEvent("separation-complete", {
874 song_id: songHash,
875 status: { song_id: songHash, state: "completed" },
876 });
877 },
878 getPlaybackSnapshot: () => clone(currentPlaybackSnapshot),
879 },
880 };
881}
882