Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Code Branches Pull requestsIssuesInsights
main
Home Code PRsIssues
dev/openkara/src/types/ipc-contract.test.ts
1/**2 * IPC Contract Tests3 *4 * These tests validate the contract between the TypeScript frontend and the5 * Rust Tauri backend. They ensure:6 *   1. Frontend `invoke()` command names match Rust `#[tauri::command]` names7 *   2. TypeScript return types have all fields the Rust structs serialize8 *   3. Frontend parameter names match the Rust command handler parameter names9 *10 * When a contract test fails, it means either:11 *   - The frontend wrapper is calling a command the backend does not expose
12 * - The backend renamed a command without updating the frontend
13 * - A TypeScript type is missing a field that the Rust struct serializes
14 * - A field was added to a Rust struct without updating the TypeScript type
15 *
16 * These are STATIC tests -- they validate type shapes at compile time via
17 * assignability checks and verify command name registries at runtime.
18 */
19import { describe, expect, test } from "vitest";
20 
21import type {
22 AppSettings,
23 CommandError,
24 DeleteSongsResult,
25 ExpandedImportPaths,
26 ExtractEmbeddedCoverArtResult,
27 ImportLyricsResult,
28 ImportSongsResult,
29 LyricsPayload,
30 PlaybackStateSnapshot,
31 SeparationStatusSnapshot,
32 Song,
33 SongProperties,
34} from "./ipc";
35 
36interface CommandContract {
37 /** The Tauri IPC command name (snake_case, matches Rust fn name) */
38 command: string;
39 /** File that wraps this command on the frontend */
40 frontendFile: string;
41 /** Frontend function that calls invoke() */
42 frontendFn: string;
43 /** Whether the command takes user-supplied arguments (excludes State/AppHandle) */
44 hasArgs: boolean;
45 /** Names of user-supplied parameters the Rust handler expects (snake_case) */
46 rustParams?: string[];
47}
48 
49const PLAYBACK_COMMANDS: CommandContract[] = [
50 {
51 command: "play",
52 frontendFile: "src/lib/tauri/playback.ts",
53 frontendFn: "play",
54 hasArgs: true,
55 rustParams: ["song_id"],
56 },
57 {
58 command: "resume",
59 frontendFile: "src/lib/tauri/playback.ts",
60 frontendFn: "resume",
61 hasArgs: false,
62 },
63 {
64 command: "pause",
65 frontendFile: "src/lib/tauri/playback.ts",
66 frontendFn: "pause",
67 hasArgs: false,
68 },
69 {
70 command: "seek",
71 frontendFile: "src/lib/tauri/playback.ts",
72 frontendFn: "seek",
73 hasArgs: true,
74 rustParams: ["ms"],
75 },
76 {
77 command: "set_volume",
78 frontendFile: "src/lib/tauri/playback.ts",
79 frontendFn: "setVolume",
80 hasArgs: true,
81 rustParams: ["level"],
82 },
83 {
84 command: "set_stem_volume",
85 frontendFile: "src/lib/tauri/playback.ts",
86 frontendFn: "setStemVolume",
87 hasArgs: true,
88 rustParams: ["stem", "level"],
89 },
90 {
91 command: "load_stems",
92 frontendFile: "src/lib/tauri/playback.ts",
93 frontendFn: "loadStems",
94 hasArgs: false,
95 },
96 {
97 command: "get_playback_state",
98 frontendFile: "src/lib/tauri/playback.ts",
99 frontendFn: "getPlaybackState",
100 hasArgs: false,
101 },
102 {
103 command: "get_audio_peaks",
104 frontendFile: "src/lib/tauri/playback.ts",
105 frontendFn: "getAudioPeaks",
106 hasArgs: false,
107 },
108 {
109 command: "set_preload_candidate",
110 frontendFile: "src/lib/tauri/playback.ts",
111 frontendFn: "setPreloadCandidate",
112 hasArgs: true,
113 rustParams: ["song_id"],
114 },
115];
116 
117const LIBRARY_COMMANDS: CommandContract[] = [
118 {
119 command: "import_songs",
120 frontendFile: "src/lib/tauri/library.ts",
121 frontendFn: "importSongs",
122 hasArgs: true,
123 rustParams: ["paths", "options"],
124 },
125 {
126 command: "get_import_candidate_details",
127 frontendFile: "src/lib/tauri/library.ts",
128 frontendFn: "getImportCandidateDetails",
129 hasArgs: true,
130 rustParams: ["paths"],
131 },
132 {
133 command: "expand_import_paths",
134 frontendFile: "src/lib/tauri/library.ts",
135 frontendFn: "expandImportPaths",
136 hasArgs: true,
137 rustParams: ["paths"],
138 },
139 {
140 command: "pick_import_paths",
141 frontendFile: "src/lib/tauri/library.ts",
142 frontendFn: "pickImportPaths",
143 hasArgs: true,
144 rustParams: ["default_path"],
145 },
146 {
147 command: "get_library",
148 frontendFile: "src/lib/tauri/library.ts",
149 frontendFn: "getLibrary",
150 hasArgs: false,
151 },
152 {
153 command: "search_library",
154 frontendFile: "src/lib/tauri/library.ts",
155 frontendFn: "searchLibrary",
156 hasArgs: true,
157 rustParams: ["query"],
158 },
159 {
160 command: "get_cover_art",
161 frontendFile: "src/lib/tauri/library.ts",
162 frontendFn: "getCoverArt",
163 hasArgs: true,
164 rustParams: ["hash", "size"],
165 },
166 {
167 command: "update_song_metadata",
168 frontendFile: "src/lib/tauri/library.ts",
169 frontendFn: "updateSongMetadata",
170 hasArgs: true,
171 rustParams: ["hash", "title", "artist"],
172 },
173 {
174 command: "set_songs_instrumental",
175 frontendFile: "src/lib/tauri/library.ts",
176 frontendFn: "setSongsInstrumental",
177 hasArgs: true,
178 rustParams: ["song_ids", "instrumental"],
179 },
180 {
181 command: "set_songs_language",
182 frontendFile: "src/lib/tauri/library.ts",
183 frontendFn: "setSongsLanguage",
184 hasArgs: true,
185 rustParams: ["song_ids", "language"],
186 },
187 {
188 command: "delete_songs",
189 frontendFile: "src/lib/tauri/library.ts",
190 frontendFn: "deleteSongs",
191 hasArgs: true,
192 rustParams: ["song_ids"],
193 },
194 {
195 command: "get_song_properties",
196 frontendFile: "src/lib/tauri/library.ts",
197 frontendFn: "getSongProperties",
198 hasArgs: true,
199 rustParams: ["song_id"],
200 },
201 {
202 command: "extract_embedded_cover_art",
203 frontendFile: "src/lib/tauri/maintenance.ts",
204 frontendFn: "extractEmbeddedCoverArt",
205 hasArgs: true,
206 rustParams: ["song_ids"],
207 },
208 {
209 command: "check_library_integrity",
210 frontendFile: "src/lib/tauri/library.ts",
211 frontendFn: "checkLibraryIntegrity",
212 hasArgs: false,
213 },
214 {
215 command: "remove_missing_library_entries",
216 frontendFile: "src/lib/tauri/library.ts",
217 frontendFn: "removeMissingLibraryEntries",
218 hasArgs: true,
219 rustParams: ["hashes"],
220 },
221];
222 
223const LYRICS_COMMANDS: CommandContract[] = [
224 {
225 command: "fetch_lyrics",
226 frontendFile: "src/lib/tauri/lyrics.ts",
227 frontendFn: "fetchLyrics",
228 hasArgs: true,
229 rustParams: ["song_id"],
230 },
231 {
232 command: "set_lyrics_offset",
233 frontendFile: "src/lib/tauri/lyrics.ts",
234 frontendFn: "setLyricsOffset",
235 hasArgs: true,
236 rustParams: ["song_id", "ms"],
237 },
238 {
239 command: "fetch_lyrics_online",
240 frontendFile: "src/lib/tauri/lyrics.ts",
241 frontendFn: "fetchLyricsOnline",
242 hasArgs: true,
243 rustParams: ["song_id", "user_initiated"],
244 },
245 {
246 command: "save_manual_lyrics",
247 frontendFile: "src/lib/tauri/lyrics.ts",
248 frontendFn: "saveManualLyrics",
249 hasArgs: true,
250 rustParams: ["song_id", "text"],
251 },
252 {
253 command: "extract_embedded_lyrics",
254 frontendFile: "src/lib/tauri/lyrics.ts",
255 frontendFn: "extractEmbeddedLyrics",
256 hasArgs: true,
257 rustParams: ["song_id"],
258 },
259 {
260 command: "import_lyrics_files",
261 frontendFile: "src/lib/tauri/lyrics.ts",
262 frontendFn: "importLyricsFiles",
263 hasArgs: true,
264 rustParams: ["paths"],
265 },
266 {
267 command: "set_lyrics_font_step",
268 frontendFile: "src/lib/tauri/settings.ts",
269 frontendFn: "setLyricsFontStep",
270 hasArgs: true,
271 rustParams: ["step"],
272 },
273];
274 
275const SETTINGS_COMMANDS: CommandContract[] = [
276 {
277 command: "set_eq_enabled",
278 frontendFile: "src/lib/tauri/settings.ts",
279 frontendFn: "setEqEnabled",
280 hasArgs: true,
281 rustParams: ["enabled"],
282 },
283 {
284 command: "set_eq_gains",
285 frontendFile: "src/lib/tauri/settings.ts",
286 frontendFn: "setEqGains",
287 hasArgs: true,
288 rustParams: ["gains_db"],
289 },
290 {
291 command: "set_library_sort_mode",
292 frontendFile: "src/lib/tauri/settings.ts",
293 frontendFn: "setLibrarySortMode",
294 hasArgs: true,
295 rustParams: ["mode"],
296 },
297 {
298 command: "set_theme_preference",
299 frontendFile: "src/lib/tauri/settings.ts",
300 frontendFn: "setThemePreference",
301 hasArgs: true,
302 rustParams: ["preference"],
303 },
304];
305 
306const SEPARATION_COMMANDS: CommandContract[] = [
307 {
308 command: "separate",
309 frontendFile: "src/lib/tauri/separation.ts",
310 frontendFn: "separate",
311 hasArgs: true,
312 rustParams: ["song_id"],
313 },
314 {
315 command: "get_separation_status",
316 frontendFile: "src/lib/tauri/separation.ts",
317 frontendFn: "getSeparationStatus",
318 hasArgs: true,
319 rustParams: ["song_id"],
320 },
321 {
322 command: "get_all_separation_statuses",
323 frontendFile: "src/lib/tauri/separation.ts",
324 frontendFn: "getAllSeparationStatuses",
325 hasArgs: false,
326 },
327 {
328 command: "upgrade_to_four_stem",
329 frontendFile: "src/lib/tauri/separation.ts",
330 frontendFn: "upgradeToFourStem",
331 hasArgs: true,
332 rustParams: ["song_id"],
333 },
334 {
335 command: "re_separate",
336 frontendFile: "src/lib/tauri/separation.ts",
337 frontendFn: "reSeparate",
338 hasArgs: true,
339 rustParams: ["song_id", "stem_mode"],
340 },
341];
342 
343const ALL_COMMANDS = [
344 ...PLAYBACK_COMMANDS,
345 ...LIBRARY_COMMANDS,
346 ...LYRICS_COMMANDS,
347 ...SEPARATION_COMMANDS,
348 ...SETTINGS_COMMANDS,
349];
350 
351describe("IPC command registry", () => {
352 test("all registered commands have unique names", () => {
353 const names = ALL_COMMANDS.map((c) => c.command);
354 const unique = new Set(names);
355 expect(unique.size).toBe(names.length);
356 });
357 
358 test("playback commands match contract documentation", () => {
359 // Phase 2 contract defines these exact command names
360 const expectedPlaybackCommands = [
361 "play",
362 "resume",
363 "pause",
364 "seek",
365 "set_volume",
366 "set_stem_volume",
367 "load_stems",
368 "get_playback_state",
369 "get_audio_peaks",
370 "set_preload_candidate",
371 ];
372 const registered = PLAYBACK_COMMANDS.map((c) => c.command);
373 expect(registered.sort()).toEqual(expectedPlaybackCommands.sort());
374 });
375 
376 test("library commands match contract documentation", () => {
377 // Phase 1 contract defines these exact command names
378 const expectedLibraryCommands = [
379 "import_songs",
380 "pick_import_paths",
381 "expand_import_paths",
382 "get_library",
383 "search_library",
384 "get_cover_art",
385 "set_songs_instrumental",
386 "extract_embedded_cover_art",
387 "get_import_candidate_details",
388 "update_song_metadata",
389 "set_songs_language",
390 "delete_songs",
391 "get_song_properties",
392 "check_library_integrity",
393 "remove_missing_library_entries",
394 ];
395 const registered = LIBRARY_COMMANDS.map((c) => c.command);
396 expect(registered.sort()).toEqual(expectedLibraryCommands.sort());
397 });
398 
399 test("lyrics commands match contract documentation", () => {
400 // Phase 4 contract defines these exact command names
401 const expectedLyricsCommands = [
402 "fetch_lyrics",
403 "set_lyrics_offset",
404 "set_lyrics_font_step",
405 "fetch_lyrics_online",
406 "save_manual_lyrics",
407 "extract_embedded_lyrics",
408 "import_lyrics_files",
409 ];
410 const registered = LYRICS_COMMANDS.map((c) => c.command);
411 expect(registered.sort()).toEqual(expectedLyricsCommands.sort());
412 });
413 
414 test("settings commands match contract documentation", () => {
415 const expectedSettingsCommands = [
416 "set_eq_enabled",
417 "set_eq_gains",
418 "set_library_sort_mode",
419 "set_theme_preference",
420 ];
421 const registered = SETTINGS_COMMANDS.map((c) => c.command);
422 expect(registered.sort()).toEqual(expectedSettingsCommands.sort());
423 });
424 
425 test("separation commands match contract documentation", () => {
426 const expectedSeparationCommands = [
427 "separate",
428 "get_separation_status",
429 "get_all_separation_statuses",
430 "upgrade_to_four_stem",
431 "re_separate",
432 ];
433 const registered = SEPARATION_COMMANDS.map((c) => c.command);
434 expect(registered.sort()).toEqual(expectedSeparationCommands.sort());
435 });
436 
437 test("all command names use snake_case (Tauri convention)", () => {
438 const snakeCase = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
439 for (const cmd of ALL_COMMANDS) {
440 expect(cmd.command).toMatch(snakeCase);
441 }
442 });
443 
444 test("all frontend function names use camelCase", () => {
445 const camelCase = /^[a-z][a-zA-Z0-9]*$/;
446 for (const cmd of ALL_COMMANDS) {
447 expect(cmd.frontendFn).toMatch(camelCase);
448 }
449 });
450});
451 
452describe("IPC parameter contracts", () => {
453 test("play expects song_id parameter (camelCase: songId)", () => {
454 const contract = PLAYBACK_COMMANDS.find((c) => c.command === "play")!;
455 expect(contract.rustParams).toContain("song_id");
456 });
457 
458 test("seek expects ms parameter", () => {
459 const contract = PLAYBACK_COMMANDS.find((c) => c.command === "seek")!;
460 expect(contract.rustParams).toContain("ms");
461 });
462 
463 test("set_volume expects level parameter", () => {
464 const contract = PLAYBACK_COMMANDS.find((c) => c.command === "set_volume")!;
465 expect(contract.rustParams).toContain("level");
466 });
467 
468 test("set_stem_volume expects stem and level parameters", () => {
469 const contract = PLAYBACK_COMMANDS.find(
470 (c) => c.command === "set_stem_volume",
471 )!;
472 expect(contract.rustParams).toEqual(["stem", "level"]);
473 });
474 
475 test("set_preload_candidate expects song_id parameter (camelCase: songId)", () => {
476 const contract = PLAYBACK_COMMANDS.find(
477 (c) => c.command === "set_preload_candidate",
478 )!;
479 expect(contract.rustParams).toContain("song_id");
480 });
481 
482 test("import_songs expects paths and optional options parameters", () => {
483 const contract = LIBRARY_COMMANDS.find(
484 (c) => c.command === "import_songs",
485 )!;
486 expect(contract.rustParams).toEqual(["paths", "options"]);
487 });
488 
489 test("search_library expects query parameter", () => {
490 const contract = LIBRARY_COMMANDS.find(
491 (c) => c.command === "search_library",
492 )!;
493 expect(contract.rustParams).toEqual(["query"]);
494 });
495 
496 test("set_songs_instrumental expects song_ids and instrumental parameters", () => {
497 const contract = LIBRARY_COMMANDS.find(
498 (c) => c.command === "set_songs_instrumental",
499 )!;
500 expect(contract.rustParams).toEqual(["song_ids", "instrumental"]);
501 });
502 
503 test("fetch_lyrics expects song_id parameter", () => {
504 const contract = LYRICS_COMMANDS.find((c) => c.command === "fetch_lyrics")!;
505 expect(contract.rustParams).toEqual(["song_id"]);
506 });
507 
508 test("set_lyrics_offset expects song_id and ms parameters", () => {
509 const contract = LYRICS_COMMANDS.find(
510 (c) => c.command === "set_lyrics_offset",
511 )!;
512 expect(contract.rustParams).toEqual(["song_id", "ms"]);
513 });
514 
515 test("separate expects song_id parameter", () => {
516 const contract = SEPARATION_COMMANDS.find((c) => c.command === "separate")!;
517 expect(contract.rustParams).toEqual(["song_id"]);
518 });
519 
520 test("re_separate expects song_id and stem_mode parameters", () => {
521 const contract = SEPARATION_COMMANDS.find(
522 (c) => c.command === "re_separate",
523 )!;
524 expect(contract.rustParams).toEqual(["song_id", "stem_mode"]);
525 });
526});
527 
528describe("PlaybackStateSnapshot shape matches Rust PlaybackStateSnapshot", () => {
529 function assertSnapshotShape(snapshot: PlaybackStateSnapshot): void {
530 // Required fields that Rust always serializes
531 expect(snapshot).toHaveProperty("song_id");
532 expect(snapshot).toHaveProperty("transport_generation");
533 expect(snapshot).toHaveProperty("state");
534 expect(snapshot).toHaveProperty("is_playing");
535 expect(snapshot).toHaveProperty("position_ms");
536 expect(snapshot).toHaveProperty("duration_ms");
537 expect(snapshot).toHaveProperty("buffered_ms");
538 expect(snapshot).toHaveProperty("volume");
539 expect(snapshot).toHaveProperty("stem_volumes");
540 expect(snapshot).toHaveProperty("has_stems");
541 expect(snapshot).toHaveProperty("stem_mode");
542 
543 // StemVolumes sub-structure
544 expect(snapshot.stem_volumes).toHaveProperty("vocals");
545 expect(snapshot.stem_volumes).toHaveProperty("drums");
546 expect(snapshot.stem_volumes).toHaveProperty("bass");
547 expect(snapshot.stem_volumes).toHaveProperty("other");
548 }
549 
550 test("idle snapshot has all required fields", () => {
551 const idle: PlaybackStateSnapshot = {
552 song_id: null,
553 transport_generation: 0,
554 state: "idle",
555 is_playing: false,
556 position_ms: 0,
557 duration_ms: null,
558 buffered_ms: 0,
559 volume: 1.0,
560 stem_volumes: { vocals: 1.0, drums: 1.0, bass: 1.0, other: 1.0 },
561 has_stems: false,
562 stem_mode: null,
563 };
564 assertSnapshotShape(idle);
565 });
566 
567 test("playing snapshot has all required fields", () => {
568 const playing: PlaybackStateSnapshot = {
569 song_id: "abc123",
570 transport_generation: 1,
571 state: "playing",
572 is_playing: true,
573 position_ms: 1500,
574 duration_ms: 180000,
575 buffered_ms: 180000,
576 volume: 0.8,
577 stem_volumes: { vocals: 0.5, drums: 1.0, bass: 1.0, other: 1.0 },
578 has_stems: true,
579 stem_mode: "two_stem",
580 };
581 assertSnapshotShape(playing);
582 });
583 
584 test("loading snapshot has all required fields", () => {
585 const loading: PlaybackStateSnapshot = {
586 song_id: "abc123",
587 transport_generation: 2,
588 state: "loading",
589 is_playing: false,
590 position_ms: 0,
591 duration_ms: null,
592 buffered_ms: 0,
593 volume: 1.0,
594 stem_volumes: { vocals: 1.0, drums: 1.0, bass: 1.0, other: 1.0 },
595 has_stems: false,
596 stem_mode: null,
597 };
598 assertSnapshotShape(loading);
599 });
600 
601 test("transport state values match Rust enum variants", () => {
602 const validStates = ["idle", "loading", "playing", "buffering"];
603 for (const state of validStates) {
604 const snapshot: PlaybackStateSnapshot = {
605 song_id: null,
606 transport_generation: 0,
607 state: state as PlaybackStateSnapshot["state"],
608 is_playing: false,
609 position_ms: 0,
610 duration_ms: null,
611 buffered_ms: 0,
612 volume: 1.0,
613 stem_volumes: { vocals: 1.0, drums: 1.0, bass: 1.0, other: 1.0 },
614 has_stems: false,
615 stem_mode: null,
616 };
617 expect(snapshot.state).toBe(state);
618 }
619 });
620 
621 test("stem_mode accepts two_stem, four_stem, and null", () => {
622 const twoStem: PlaybackStateSnapshot["stem_mode"] = "two_stem";
623 const fourStem: PlaybackStateSnapshot["stem_mode"] = "four_stem";
624 const nullMode: PlaybackStateSnapshot["stem_mode"] = null;
625 expect(twoStem).toBe("two_stem");
626 expect(fourStem).toBe("four_stem");
627 expect(nullMode).toBeNull();
628 });
629});
630 
631describe("Song shape matches Rust Song struct", () => {
632 test("Song has all fields from Rust serialization", () => {
633 const song: Song = {
634 hash: "abc123",
635 file_path: "/path/to/song.mp3",
636 audio_source_kind: "original",
637 cdg_path: null,
638 media_g_container: null,
639 instrumental: false,
640 language: null,
641 title: "Test Song",
642 artist: "Test Artist",
643 album: "Test Album",
644 duration_ms: 180000,
645 cover_art: null,
646 has_cover_art: false,
647 artwork_thumb_path: null,
648 imported_at: 1700000000,
649 original_ext: "mp3",
650 };
651 
652 // All fields from Rust Song struct
653 expect(song).toHaveProperty("hash");
654 expect(song).toHaveProperty("file_path");
655 expect(song).toHaveProperty("audio_source_kind");
656 expect(song).toHaveProperty("cdg_path");
657 expect(song).toHaveProperty("media_g_container");
658 expect(song).toHaveProperty("instrumental");
659 expect(song).toHaveProperty("language");
660 expect(song).toHaveProperty("title");
661 expect(song).toHaveProperty("artist");
662 expect(song).toHaveProperty("album");
663 expect(song).toHaveProperty("duration_ms");
664 expect(song).toHaveProperty("cover_art");
665 expect(song).toHaveProperty("has_cover_art");
666 expect(song).toHaveProperty("imported_at");
667 expect(song).toHaveProperty("original_ext");
668 });
669 
670 test("Song accepts null for optional fields (remote songs)", () => {
671 const remoteSong: Song = {
672 hash: "remote-song",
673 file_path: null,
674 audio_source_kind: "original_remote",
675 cdg_path: null,
676 media_g_container: null,
677 instrumental: false,
678 language: null,
679 title: null,
680 artist: null,
681 album: null,
682 duration_ms: 0,
683 cover_art: null,
684 has_cover_art: false,
685 artwork_thumb_path: null,
686 imported_at: 0,
687 original_ext: null,
688 };
689 expect(remoteSong.file_path).toBeNull();
690 expect(remoteSong.audio_source_kind).toBe("original_remote");
691 });
692 
693 test("audio_source_kind values match Rust enum", () => {
694 const validKinds: Song["audio_source_kind"][] = [
695 "original",
696 "original_remote",
697 "stems_remote",
698 ];
699 for (const kind of validKinds) {
700 expect(["original", "original_remote", "stems_remote"]).toContain(kind);
701 }
702 });
703 
704 test("media_g_container values match Rust enum", () => {
705 const validContainers: Array<Song["media_g_container"]> = [
706 "paired",
707 "zip",
708 null,
709 ];
710 for (const container of validContainers) {
711 expect(["paired", "zip", null]).toContain(container);
712 }
713 });
714});
715 
716describe("ImportSongsResult shape matches Rust ImportSongsResult", () => {
717 test("has imported and failed fields", () => {
718 const result: ImportSongsResult = {
719 imported: [],
720 failed: [],
721 };
722 expect(result).toHaveProperty("imported");
723 expect(result).toHaveProperty("failed");
724 expect(Array.isArray(result.imported)).toBe(true);
725 expect(Array.isArray(result.failed)).toBe(true);
726 });
727 
728 test("ImportFailure has path and error fields", () => {
729 const result: ImportSongsResult = {
730 imported: [],
731 failed: [
732 {
733 path: "/bad/file.mp3",
734 error: {
735 code: "media_read_failed",
736 message: "could not read file",
737 retryable: false,
738 fallback: "reimport_song",
739 },
740 },
741 ],
742 };
743 expect(result.failed[0]).toHaveProperty("path");
744 expect(result.failed[0]).toHaveProperty("error");
745 expect(result.failed[0].error).toHaveProperty("code");
746 expect(result.failed[0].error).toHaveProperty("message");
747 expect(result.failed[0].error).toHaveProperty("retryable");
748 expect(result.failed[0].error).toHaveProperty("fallback");
749 });
750});
751 
752describe("ExpandedImportPaths shape matches Rust ExpandedImportPaths", () => {
753 test("has paths and song_count fields", () => {
754 const result: ExpandedImportPaths = {
755 paths: ["/music/song.mp3"],
756 song_count: 1,
757 };
758 expect(result).toHaveProperty("paths");
759 expect(result).toHaveProperty("song_count");
760 });
761});
762 
763describe("LyricsPayload shape matches Rust LyricsPayload", () => {
764 test("has all fields from Rust serialization", () => {
765 const payload: LyricsPayload = {
766 song_id: "abc123",
767 lines: [
768 {
769 time_ms: 35660,
770 text: "Look at the stars",
771 words: null,
772 bg_words: null,
773 section: null,
774 },
775 {
776 time_ms: 38000,
777 text: "Look how they shine",
778 words: null,
779 bg_words: null,
780 section: null,
781 },
782 ],
783 source: "lrc_lib",
784 offset_ms: 0,
785 raw_lrc: "[00:35.66] Look at the stars\n[00:38.00] Look how they shine",
786 };
787 
788 expect(payload).toHaveProperty("song_id");
789 expect(payload).toHaveProperty("lines");
790 expect(payload).toHaveProperty("source");
791 expect(payload).toHaveProperty("offset_ms");
792 expect(payload).toHaveProperty("raw_lrc");
793 });
794 
795 test("LyricLine has time_ms, text, and words fields", () => {
796 const payload: LyricsPayload = {
797 song_id: "abc123",
798 lines: [
799 {
800 time_ms: 1000,
801 text: "Hello",
802 words: null,
803 bg_words: null,
804 section: null,
805 },
806 ],
807 source: "lrc_lib",
808 offset_ms: 0,
809 raw_lrc: "[00:01.00] Hello",
810 };
811 const line = payload.lines[0];
812 expect(line).toHaveProperty("time_ms");
813 expect(line).toHaveProperty("text");
814 expect(line).toHaveProperty("words");
815 });
816 
817 test("source values match Rust LyricsSource enum", () => {
818 const validSources: Array<LyricsPayload["source"]> = [
819 "lrc_lib",
820 "lrc_api",
821 "embedded",
822 "sidecar",
823 "manual",
824 null,
825 ];
826 for (const source of validSources) {
827 expect([
828 "lrc_lib",
829 "lrc_api",
830 "embedded",
831 "sidecar",
832 "manual",
833 null,
834 ]).toContain(source);
835 }
836 });
837 
838 test("miss payload returns empty lines and null source", () => {
839 const miss: LyricsPayload = {
840 song_id: "abc123",
841 lines: [],
842 source: null,
843 offset_ms: 0,
844 raw_lrc: "",
845 };
846 expect(miss.lines).toHaveLength(0);
847 expect(miss.source).toBeNull();
848 });
849});
850 
851describe("SeparationStatusSnapshot shape matches Rust SeparationStatusSnapshot", () => {
852 test("has all fields from Rust serialization", () => {
853 const snapshot: SeparationStatusSnapshot = {
854 song_id: "abc123",
855 state: "completed",
856 percent: 100,
857 cache_hit: true,
858 vocals_path: "/path/vocals.ogg",
859 accomp_path: "/path/accomp.ogg",
860 drums_path: null,
861 bass_path: null,
862 other_path: null,
863 model_variant: "htdemucs",
864 error: null,
865 };
866 
867 expect(snapshot).toHaveProperty("song_id");
868 expect(snapshot).toHaveProperty("state");
869 expect(snapshot).toHaveProperty("percent");
870 expect(snapshot).toHaveProperty("cache_hit");
871 expect(snapshot).toHaveProperty("vocals_path");
872 expect(snapshot).toHaveProperty("accomp_path");
873 expect(snapshot).toHaveProperty("drums_path");
874 expect(snapshot).toHaveProperty("bass_path");
875 expect(snapshot).toHaveProperty("other_path");
876 expect(snapshot).toHaveProperty("model_variant");
877 expect(snapshot).toHaveProperty("error");
878 });
879 
880 test("separation state values match Rust SeparationState enum", () => {
881 const validStates: SeparationStatusSnapshot["state"][] = [
882 "idle",
883 "running",
884 "completed",
885 "failed",
886 ];
887 for (const state of validStates) {
888 expect(["idle", "running", "completed", "failed"]).toContain(state);
889 }
890 });
891 
892 test("idle status has null paths and zero percent", () => {
893 const idle: SeparationStatusSnapshot = {
894 song_id: "abc123",
895 state: "idle",
896 percent: 0,
897 cache_hit: false,
898 vocals_path: null,
899 accomp_path: null,
900 drums_path: null,
901 bass_path: null,
902 other_path: null,
903 model_variant: null,
904 error: null,
905 };
906 expect(idle.vocals_path).toBeNull();
907 expect(idle.percent).toBe(0);
908 });
909 
910 test("failed status includes error object", () => {
911 const failed: SeparationStatusSnapshot = {
912 song_id: "abc123",
913 state: "failed",
914 percent: 100,
915 cache_hit: false,
916 vocals_path: null,
917 accomp_path: null,
918 drums_path: null,
919 bass_path: null,
920 other_path: null,
921 model_variant: null,
922 error: {
923 code: "separation_failed",
924 message: "model crashed",
925 retryable: true,
926 fallback: "stay_in_original_mode",
927 },
928 };
929 expect(failed.error).not.toBeNull();
930 expect(failed.error!.code).toBe("separation_failed");
931 });
932});
933 
934describe("CommandError shape matches Rust CommandError", () => {
935 test("has code, message, retryable, fallback fields", () => {
936 const error: CommandError = {
937 code: "song_not_found",
938 message: "song with hash abc not found",
939 retryable: false,
940 fallback: "refresh_library",
941 };
942 expect(error).toHaveProperty("code");
943 expect(error).toHaveProperty("message");
944 expect(error).toHaveProperty("retryable");
945 expect(error).toHaveProperty("fallback");
946 });
947 
948 test("ErrorCode values match Rust ErrorCode enum", () => {
949 const validCodes: CommandError["code"][] = [
950 "database_unavailable",
951 "media_read_failed",
952 "song_not_found",
953 "model_unavailable",
954 "audio_decode_failed",
955 "audio_output_unavailable",
956 "karaoke_not_ready",
957 "lyrics_not_ready",
958 "network_unavailable",
959 "invalid_playback_state",
960 "separation_failed",
961 "internal",
962 ];
963 // All 12 error codes from phase-5-error-contract.md
964 expect(validCodes).toHaveLength(12);
965 for (const code of validCodes) {
966 expect(typeof code).toBe("string");
967 }
968 });
969 
970 test("FallbackAction values match Rust FallbackAction enum", () => {
971 const validActions: CommandError["fallback"][] = [
972 "retry",
973 "refresh_library",
974 "reimport_song",
975 "check_audio_output_device",
976 "stay_in_original_mode",
977 "show_empty_state",
978 "keep_current_state",
979 ];
980 expect(validActions).toHaveLength(7);
981 for (const action of validActions) {
982 expect(typeof action).toBe("string");
983 }
984 });
985});
986 
987describe("SongProperties shape matches Rust SongProperties", () => {
988 test("has all fields from Rust serialization", () => {
989 const props: SongProperties = {
990 format: "MP3",
991 sample_rate_hz: 44100,
992 channels: 2,
993 bit_rate_bps: 320,
994 file_size_bytes: 5_000_000,
995 duration_ms: 180000,
996 hash: "abc123",
997 };
998 expect(props).toHaveProperty("format");
999 expect(props).toHaveProperty("sample_rate_hz");
1000 expect(props).toHaveProperty("channels");
1001 expect(props).toHaveProperty("bit_rate_bps");
1002 expect(props).toHaveProperty("file_size_bytes");
1003 expect(props).toHaveProperty("duration_ms");
1004 expect(props).toHaveProperty("hash");
1005 });
1006});
1007 
1008describe("DeleteSongsResult shape matches Rust DeleteSongsResult", () => {
1009 test("has deleted_song_ids and failed fields", () => {
1010 const result: DeleteSongsResult = {
1011 deleted_song_ids: ["abc", "def"],
1012 failed: [],
1013 };
1014 expect(result).toHaveProperty("deleted_song_ids");
1015 expect(result).toHaveProperty("failed");
1016 });
1017 
1018 test("DeleteSongsFailure has song_id and error fields", () => {
1019 const result: DeleteSongsResult = {
1020 deleted_song_ids: [],
1021 failed: [
1022 {
1023 song_id: "abc",
1024 error: {
1025 code: "database_unavailable",
1026 message: "db locked",
1027 retryable: true,
1028 fallback: "retry",
1029 },
1030 },
1031 ],
1032 };
1033 expect(result.failed[0]).toHaveProperty("song_id");
1034 expect(result.failed[0]).toHaveProperty("error");
1035 });
1036});
1037 
1038describe("ExtractEmbeddedCoverArtResult shape matches Rust", () => {
1039 test("has updated_songs and failed fields", () => {
1040 const result: ExtractEmbeddedCoverArtResult = {
1041 updated_songs: [],
1042 failed: [],
1043 };
1044 expect(result).toHaveProperty("updated_songs");
1045 expect(result).toHaveProperty("failed");
1046 });
1047});
1048 
1049describe("ImportLyricsResult shape matches Rust ImportLyricsResult", () => {
1050 test("has matched and unmatched fields", () => {
1051 const result: ImportLyricsResult = {
1052 matched: [],
1053 unmatched: [],
1054 };
1055 expect(result).toHaveProperty("matched");
1056 expect(result).toHaveProperty("unmatched");
1057 });
1058 
1059 test("LyricsMatch has song_id and lrc_path fields", () => {
1060 const result: ImportLyricsResult = {
1061 matched: [
1062 {
1063 song_id: "abc",
1064 lrc_path: "/path/to/lyrics.lrc",
1065 song_title: "Test Song",
1066 song_artist: "Test Artist",
1067 },
1068 ],
1069 unmatched: [],
1070 };
1071 expect(result.matched[0]).toHaveProperty("song_id");
1072 expect(result.matched[0]).toHaveProperty("lrc_path");
1073 expect(result.matched[0]).toHaveProperty("song_title");
1074 expect(result.matched[0]).toHaveProperty("song_artist");
1075 });
1076});
1077 
1078describe("AppSettings shape matches Rust AppSettings", () => {
1079 test("has all fields from Rust serialization", () => {
1080 const settings: AppSettings = {
1081 stem_mode: "two_stem",
1082 model_variant: "htdemucs",
1083 language: "en",
1084 hide_batch_separate: false,
1085 cover_art_backdrop: true,
1086 hide_upgrade_all: false,
1087 lyrics_font_step: 0,
1088 execution_provider: "cpu",
1089 available_execution_providers: ["cpu", "xnnpack"],
1090 eq_enabled: false,
1091 eq_gains_db: [0, 0, 0, 0, 0],
1092 crossfade_enabled: false,
1093 crossfade_duration_ms: 3_000,
1094 library_sort_mode: "recently_imported",
1095 theme_preference: "dark",
1096 update_policy: "notify",
1097 };
1098 expect(settings).toHaveProperty("stem_mode");
1099 expect(settings).toHaveProperty("model_variant");
1100 expect(settings).toHaveProperty("language");
1101 expect(settings).toHaveProperty("hide_batch_separate");
1102 expect(settings).toHaveProperty("cover_art_backdrop");
1103 expect(settings).toHaveProperty("hide_upgrade_all");
1104 expect(settings).toHaveProperty("lyrics_font_step");
1105 expect(settings).toHaveProperty("execution_provider");
1106 expect(settings).toHaveProperty("available_execution_providers");
1107 expect(settings).toHaveProperty("eq_enabled");
1108 expect(settings).toHaveProperty("eq_gains_db");
1109 expect(settings).toHaveProperty("library_sort_mode");
1110 expect(settings).toHaveProperty("theme_preference");
1111 expect(settings).toHaveProperty("update_policy");
1112 });
1113 
1114 test("stem_mode values match Rust StemMode enum", () => {
1115 const validModes: AppSettings["stem_mode"][] = ["two_stem", "four_stem"];
1116 for (const mode of validModes) {
1117 expect(["two_stem", "four_stem"]).toContain(mode);
1118 }
1119 });
1120 
1121 test("model_variant values match Rust ModelVariant enum", () => {
1122 const validVariants: AppSettings["model_variant"][] = [
1123 "htdemucs",
1124 "htdemucs_ft",
1125 ];
1126 for (const variant of validVariants) {
1127 expect(["htdemucs", "htdemucs_ft"]).toContain(variant);
1128 }
1129 });
1130 
1131 test("execution_provider values match Rust ExecutionProvider enum", () => {
1132 const validProviders: AppSettings["execution_provider"][] = [
1133 "cpu",
1134 "xnnpack",
1135 "directml",
1136 ];
1137 for (const provider of validProviders) {
1138 expect(["cpu", "xnnpack", "directml"]).toContain(provider);
1139 }
1140 });
1141 
1142 test("library_sort_mode values match Rust LibrarySortMode enum", () => {
1143 const validModes: AppSettings["library_sort_mode"][] = [
1144 "recently_imported",
1145 "title_asc",
1146 "artist_asc",
1147 ];
1148 for (const mode of validModes) {
1149 expect(["recently_imported", "title_asc", "artist_asc"]).toContain(mode);
1150 }
1151 });
1152});
1153 
1154describe("Event payload shapes", () => {
1155 test("PlaybackPositionEvent has ms and snapshot fields", () => {
1156 // Import the type at the module level is sufficient; here we validate
1157 // the shape by constructing a compatible object
1158 const event = {
1159 ms: 1234,
1160 transport_generation: 1,
1161 snapshot: {
1162 song_id: "abc123",
1163 transport_generation: 1,
1164 state: "playing" as const,
1165 is_playing: true,
1166 position_ms: 1234,
1167 duration_ms: 180000,
1168 buffered_ms: 180000,
1169 volume: 1.0,
1170 stem_volumes: { vocals: 1.0, drums: 1.0, bass: 1.0, other: 1.0 },
1171 has_stems: false,
1172 stem_mode: null,
1173 },
1174 };
1175 expect(event).toHaveProperty("ms");
1176 expect(event).toHaveProperty("transport_generation");
1177 expect(event).toHaveProperty("snapshot");
1178 });
1179 
1180 test("PlaybackEndedEvent has song_id field", () => {
1181 const event = { song_id: "abc123" };
1182 expect(event).toHaveProperty("song_id");
1183 });
1184 
1185 test("PlaybackErrorEvent has song_id and error fields", () => {
1186 const event = {
1187 song_id: "abc123",
1188 error: {
1189 code: "audio_decode_failed" as const,
1190 message: "decode failed",
1191 retryable: false,
1192 fallback: "reimport_song" as const,
1193 },
1194 };
1195 expect(event).toHaveProperty("song_id");
1196 expect(event).toHaveProperty("error");
1197 expect(event.error).toHaveProperty("code");
1198 expect(event.error).toHaveProperty("retryable");
1199 });
1200 
1201 test("SeparationProgressEvent has song_id and percent fields", () => {
1202 const event = { song_id: "abc123", percent: 50 };
1203 expect(event).toHaveProperty("song_id");
1204 expect(event).toHaveProperty("percent");
1205 });
1206 
1207 test("SeparationCompleteEvent has song_id and status fields", () => {
1208 const event = {
1209 song_id: "abc123",
1210 status: {
1211 song_id: "abc123",
1212 state: "completed" as const,
1213 percent: 100,
1214 cache_hit: true,
1215 vocals_path: "/path/vocals.ogg",
1216 accomp_path: "/path/accomp.ogg",
1217 drums_path: null,
1218 bass_path: null,
1219 other_path: null,
1220 model_variant: "htdemucs",
1221 error: null,
1222 },
1223 };
1224 expect(event).toHaveProperty("song_id");
1225 expect(event).toHaveProperty("status");
1226 });
1227 
1228 test("SeparationErrorEvent has song_id and error fields", () => {
1229 const event = {
1230 song_id: "abc123",
1231 error: {
1232 code: "separation_failed" as const,
1233 message: "model failed",
1234 retryable: true,
1235 fallback: "stay_in_original_mode" as const,
1236 },
1237 };
1238 expect(event).toHaveProperty("song_id");
1239 expect(event).toHaveProperty("error");
1240 });
1241});
1242 
1243describe("Serialization compatibility", () => {
1244 test("PlaybackStateSnapshot uses snake_case field names (no rename_all on Rust struct)", () => {
1245 // The Rust PlaybackStateSnapshot struct does NOT use #[serde(rename_all)],
1246 // so fields serialize as-is in snake_case. The TypeScript type must match.
1247 const snapshot: PlaybackStateSnapshot = {
1248 song_id: "x", // not songId
1249 transport_generation: 1, // not transportGeneration
1250 state: "idle",
1251 is_playing: false, // not isPlaying
1252 position_ms: 0, // not positionMs
1253 duration_ms: null, // not durationMs
1254 buffered_ms: 0, // not bufferedMs
1255 volume: 1.0,
1256 stem_volumes: { vocals: 1.0, drums: 1.0, bass: 1.0, other: 1.0 }, // not stemVolumes
1257 has_stems: false, // not hasStems
1258 stem_mode: null, // not stemMode
1259 };
1260 expect(snapshot.song_id).toBeDefined();
1261 expect(snapshot.transport_generation).toBeDefined();
1262 expect(snapshot.is_playing).toBeDefined();
1263 expect(snapshot.position_ms).toBeDefined();
1264 });
1265 
1266 test("Song uses snake_case field names", () => {
1267 const song: Song = {
1268 hash: "x",
1269 file_path: null, // not filePath
1270 audio_source_kind: "original", // not audioSourceKind
1271 cdg_path: null, // not cdgPath
1272 media_g_container: null, // not mediaGContainer
1273 instrumental: false,
1274 language: null,
1275 title: null,
1276 artist: null,
1277 album: null,
1278 duration_ms: 0, // not durationMs
1279 cover_art: null, // not coverArt
1280 has_cover_art: false,
1281 artwork_thumb_path: null,
1282 imported_at: 0, // not importedAt
1283 original_ext: null, // not originalExt
1284 };
1285 expect(song.file_path).toBeDefined();
1286 expect(song.audio_source_kind).toBeDefined();
1287 expect(song.duration_ms).toBeDefined();
1288 });
1289 
1290 test("SeparationStatusSnapshot uses snake_case field names", () => {
1291 const snapshot: SeparationStatusSnapshot = {
1292 song_id: "x",
1293 state: "idle",
1294 percent: 0,
1295 cache_hit: false, // not cacheHit
1296 vocals_path: null, // not vocalsPath
1297 accomp_path: null, // not accompPath
1298 drums_path: null,
1299 bass_path: null,
1300 other_path: null,
1301 model_variant: null, // not modelVariant
1302 error: null,
1303 };
1304 expect(snapshot.cache_hit).toBeDefined();
1305 expect(snapshot.vocals_path).toBeDefined();
1306 expect(snapshot.model_variant).toBeDefined();
1307 });
1308 
1309 test("LyricsPayload uses snake_case field names", () => {
1310 const payload: LyricsPayload = {
1311 song_id: "x", // not songId
1312 lines: [],
1313 source: null,
1314 offset_ms: 0, // not offsetMs
1315 raw_lrc: "", // not rawLrc
1316 };
1317 expect(payload.song_id).toBeDefined();
1318 expect(payload.offset_ms).toBeDefined();
1319 expect(payload.raw_lrc).toBeDefined();
1320 });
1321 
1322 test("CommandError uses snake_case for retryable (no rename needed)", () => {
1323 const error: CommandError = {
1324 code: "internal",
1325 message: "test",
1326 retryable: false,
1327 fallback: "retry",
1328 };
1329 // retryable is already lowercase, no rename issue
1330 expect(error.retryable).toBe(false);
1331 expect(error.fallback).toBe("retry");
1332 });
1333});
1334