Many improvemtns and fixes related to decoupling of svelte and rust on android.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Mock implementation of Tauri invoke for testing
|
||||
*/
|
||||
|
||||
export interface InvokeCall {
|
||||
command: string;
|
||||
args: Record<string, any>;
|
||||
}
|
||||
|
||||
let invokeHistory: InvokeCall[] = [];
|
||||
let invokeResponses: Map<string, any> = new Map();
|
||||
|
||||
/**
|
||||
* Mock invoke function that captures calls
|
||||
*/
|
||||
export const mockInvoke = async (
|
||||
command: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<any> => {
|
||||
const callArgs = args || {};
|
||||
invokeHistory.push({ command, args: callArgs });
|
||||
|
||||
// Return mock response if set
|
||||
const response = invokeResponses.get(command);
|
||||
if (response !== undefined) {
|
||||
if (response instanceof Error) {
|
||||
throw response;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Default success response
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Set a mock response for a command
|
||||
*/
|
||||
export const setMockResponse = (command: string, response: any): void => {
|
||||
invokeResponses.set(command, response);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all invoke calls made during test
|
||||
*/
|
||||
export const getInvokeCalls = (): InvokeCall[] => {
|
||||
return [...invokeHistory];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get calls for a specific command
|
||||
*/
|
||||
export const getInvokeCalls_ForCommand = (command: string): InvokeCall[] => {
|
||||
return invokeHistory.filter((call) => call.command === command);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the last invoke call
|
||||
*/
|
||||
export const getLastInvokeCall = (): InvokeCall | undefined => {
|
||||
return invokeHistory[invokeHistory.length - 1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear invoke history
|
||||
*/
|
||||
export const clearInvokeHistory = (): void => {
|
||||
invokeHistory = [];
|
||||
invokeResponses.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a command was called with expected parameters
|
||||
*/
|
||||
export const expectInvokeCall = (
|
||||
command: string,
|
||||
expectedArgs: Record<string, any>
|
||||
): void => {
|
||||
const calls = getInvokeCalls_ForCommand(command);
|
||||
|
||||
if (calls.length === 0) {
|
||||
throw new Error(`Command "${command}" was never called`);
|
||||
}
|
||||
|
||||
const lastCall = calls[calls.length - 1];
|
||||
|
||||
// Deep equality check
|
||||
for (const [key, expectedValue] of Object.entries(expectedArgs)) {
|
||||
const actualValue = lastCall.args[key];
|
||||
|
||||
if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) {
|
||||
throw new Error(
|
||||
`Parameter "${key}" mismatch:\n` +
|
||||
` Expected: ${JSON.stringify(expectedValue)}\n` +
|
||||
` Actual: ${JSON.stringify(actualValue)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get parameter value from invoke calls
|
||||
*/
|
||||
export const getInvokeParameter = (
|
||||
command: string,
|
||||
paramName: string,
|
||||
callIndex = -1 // -1 = last call
|
||||
): any => {
|
||||
const calls = getInvokeCalls_ForCommand(command);
|
||||
|
||||
if (calls.length === 0) {
|
||||
throw new Error(`Command "${command}" was never called`);
|
||||
}
|
||||
|
||||
const targetCall = callIndex === -1 ? calls[calls.length - 1] : calls[callIndex];
|
||||
return targetCall.args[paramName];
|
||||
};
|
||||
+16
-15
@@ -12,7 +12,8 @@ interface LibraryState {
|
||||
currentLibrary: Library | null;
|
||||
items: MediaItem[];
|
||||
currentItem: MediaItem | null;
|
||||
isLoading: boolean;
|
||||
/** Counter for concurrent loading operations. isLoading = loadingCount > 0 */
|
||||
loadingCount: number;
|
||||
error: string | null;
|
||||
totalItems: number;
|
||||
searchQuery: string;
|
||||
@@ -34,7 +35,7 @@ function createLibraryStore() {
|
||||
currentLibrary: null,
|
||||
items: [],
|
||||
currentItem: null,
|
||||
isLoading: false,
|
||||
loadingCount: 0,
|
||||
error: null,
|
||||
totalItems: 0,
|
||||
searchQuery: "",
|
||||
@@ -50,7 +51,7 @@ function createLibraryStore() {
|
||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||
|
||||
async function loadLibraries() {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
@@ -71,13 +72,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
libraries,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return libraries;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load libraries";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +87,7 @@ function createLibraryStore() {
|
||||
parentId: string,
|
||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
|
||||
) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
@@ -115,19 +116,19 @@ function createLibraryStore() {
|
||||
...s,
|
||||
items: result.items,
|
||||
totalItems: result.totalRecordCount,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load items";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItem(itemId: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@@ -144,13 +145,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
currentItem: item,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load item";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +162,7 @@ function createLibraryStore() {
|
||||
return;
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, isLoading: true, error: null, searchQuery: query }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@@ -179,13 +180,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
searchResults: result.items,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Search failed";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -273,7 +274,7 @@ export const library = createLibraryStore();
|
||||
export const libraries = derived(library, ($lib) => $lib.libraries);
|
||||
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
|
||||
export const libraryItems = derived(library, ($lib) => $lib.items);
|
||||
export const isLibraryLoading = derived(library, ($lib) => $lib.isLoading);
|
||||
export const isLibraryLoading = derived(library, ($lib) => $lib.loadingCount > 0);
|
||||
export const libraryError = derived(library, ($lib) => $lib.error);
|
||||
export const viewMode = derived(library, ($lib) => $lib.viewMode);
|
||||
export const genres = derived(library, ($lib) => $lib.genres);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Integration tests for playbackMode store
|
||||
*
|
||||
* Tests that the store calls Tauri commands with correct parameter names.
|
||||
*
|
||||
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
|
||||
* snake_case Rust parameter names to camelCase for the frontend.
|
||||
* So Rust `repository_handle: String` → frontend sends `repositoryHandle`.
|
||||
* Nested struct fields with #[serde(rename_all = "camelCase")] also use camelCase.
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
describe("playbackMode store - Tauri invoke parameter verification", () => {
|
||||
let mockInvokedCalls: Array<{ command: string; args: Record<string, any> }> =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvokedCalls = [];
|
||||
|
||||
// Mock invoke to capture calls
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(async (command: string, args?: Record<string, any>) => {
|
||||
mockInvokedCalls.push({ command, args: args || {} });
|
||||
return { success: true };
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("player_play_tracks command parameters", () => {
|
||||
it("should use repositoryHandle (camelCase, auto-converted by Tauri v2)", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "test-handle-123", // ✓ CORRECT - Tauri v2 auto-converts
|
||||
request: {
|
||||
trackIds: ["track-1"],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("nested request fields use camelCase", () => {
|
||||
const correctRequest = {
|
||||
trackIds: ["track-1"], // ✓ camelCase for nested struct field
|
||||
startIndex: 0, // ✓ camelCase
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "", // ✓ camelCase for context field
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctRequest)).toContain("trackIds");
|
||||
expect(Object.keys(correctRequest)).toContain("startIndex");
|
||||
expect(Object.keys(correctRequest.context)).toContain("searchQuery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback_mode_transfer_to_local command parameters", () => {
|
||||
it("should use currentItemId and positionTicks (camelCase)", () => {
|
||||
const correctCall = {
|
||||
currentItemId: "item-123", // ✓ CORRECT
|
||||
positionTicks: 50000, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("currentItemId");
|
||||
expect(Object.keys(correctCall)).toContain("positionTicks");
|
||||
expect(Object.keys(correctCall)).not.toContain("current_item_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("position_ticks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session commands use sessionId (camelCase)", () => {
|
||||
it("remote_send_command uses sessionId", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
command: "PlayPause",
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
});
|
||||
|
||||
it("remote_play_on_session uses sessionId, itemIds, startIndex", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
itemIds: ["id1", "id2"], // ✓ CORRECT
|
||||
startIndex: 0, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).toContain("itemIds");
|
||||
expect(Object.keys(correctCall)).toContain("startIndex");
|
||||
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_ids");
|
||||
expect(Object.keys(correctCall)).not.toContain("start_index");
|
||||
});
|
||||
|
||||
it("remote_session_seek uses sessionId and positionTicks", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
positionTicks: 50000, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).toContain("positionTicks");
|
||||
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("position_ticks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Download commands use itemId (camelCase)", () => {
|
||||
it("pin_item uses itemId", () => {
|
||||
const correctCall = {
|
||||
itemId: "item-123", // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("itemId");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_id");
|
||||
});
|
||||
|
||||
it("unpin_item uses itemId", () => {
|
||||
const correctCall = {
|
||||
itemId: "item-123", // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("itemId");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Queue commands use repositoryHandle (camelCase)", () => {
|
||||
it("player_add_track_by_id uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
trackId: "track-123",
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("player_add_tracks_by_ids uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
trackIds: ["track-1", "track-2"],
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Player commands", () => {
|
||||
it("player_play_album_track uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
albumId: "album-123",
|
||||
albumName: "Test Album",
|
||||
trackId: "track-123",
|
||||
shuffle: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
|
||||
// Nested struct fields use camelCase
|
||||
expect(Object.keys(correctCall.request)).toContain("albumId");
|
||||
expect(Object.keys(correctCall.request)).toContain("albumName");
|
||||
expect(Object.keys(correctCall.request)).toContain("trackId");
|
||||
});
|
||||
|
||||
it("player_seek uses position (simple types don't need renaming)", () => {
|
||||
const correctCall = {
|
||||
position: 500.5,
|
||||
};
|
||||
|
||||
expect(correctCall.position).toBe(500.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error detection - what NOT to do", () => {
|
||||
it("repository_handle (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
repository_handle: "handle-123", // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("repositoryHandle");
|
||||
expect(Object.keys(wrongCall)).toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("session_id (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
session_id: "session-123", // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("sessionId");
|
||||
expect(Object.keys(wrongCall)).toContain("session_id");
|
||||
});
|
||||
|
||||
it("item_ids (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
item_ids: ["id1", "id2"], // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("itemIds");
|
||||
expect(Object.keys(wrongCall)).toContain("item_ids");
|
||||
});
|
||||
|
||||
it("start_index (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
start_index: 0, // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("startIndex");
|
||||
expect(Object.keys(wrongCall)).toContain("start_index");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,8 +85,10 @@ describe("playbackMode store", () => {
|
||||
// Call disconnect
|
||||
await playbackMode.disconnect();
|
||||
|
||||
// Verify Rust backend was notified with correct mode
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: "Idle" });
|
||||
// Verify Rust backend was notified with correct mode (mode is now an object with type field)
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", {
|
||||
mode: { type: "idle" },
|
||||
});
|
||||
|
||||
// Verify sessions.selectSession was called with null
|
||||
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
||||
|
||||
@@ -185,38 +185,25 @@ function createPlaybackModeStore() {
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// TODO: After Phase 3 (repository migration), this will be handled by Rust
|
||||
// For now, we need to fetch playback info and start local playback from TypeScript
|
||||
|
||||
// Get repository to fetch playback info
|
||||
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
||||
const repository = auth.getRepository();
|
||||
const playbackInfo = await repository.getPlaybackInfo(itemId);
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Build play item request (handle both camelCase and PascalCase)
|
||||
const itemType = (nowPlaying as any).type || (nowPlaying as any).Type;
|
||||
const artists = (nowPlaying as any).artists || (nowPlaying as any).Artists;
|
||||
const albumName = (nowPlaying as any).albumName || (nowPlaying as any).AlbumName;
|
||||
const runTimeTicks = (nowPlaying as any).runTimeTicks || (nowPlaying as any).RunTimeTicks;
|
||||
const primaryImageTag = (nowPlaying as any).primaryImageTag || (nowPlaying as any).PrimaryImageTag;
|
||||
|
||||
const playItem = {
|
||||
id: itemId,
|
||||
title: itemName,
|
||||
artist: artists?.[0],
|
||||
album: albumName,
|
||||
duration: runTimeTicks ? ticksToSeconds(runTimeTicks) : undefined,
|
||||
artworkUrl: repository.getImageUrl(itemId, "Primary", {
|
||||
tag: primaryImageTag,
|
||||
}),
|
||||
mediaType: itemType === "Audio" ? "audio" : "video",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: itemId,
|
||||
};
|
||||
|
||||
// Start local playback (events allowed through because isTransferring=true)
|
||||
await invoke("player_play_item", { item: playItem });
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repositoryHandle = repository.getHandle();
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds: [itemId],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
@@ -323,7 +310,7 @@ function createPlaybackModeStore() {
|
||||
|
||||
try {
|
||||
// Notify Rust backend to switch to idle mode
|
||||
await invoke("playback_mode_set", { mode: "Idle" });
|
||||
await invoke("playback_mode_set", { mode: { type: "idle" } });
|
||||
|
||||
// Update local state
|
||||
sessions.selectSession(null);
|
||||
|
||||
Reference in New Issue
Block a user