Many improvemtns and fixes related to decoupling of svelte and rust on android.
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s

This commit is contained in:
2026-02-28 19:50:47 +01:00
parent 07f3bf04ca
commit e8e37649fa
53 changed files with 2309 additions and 792 deletions
+117
View File
@@ -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
View File
@@ -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);
+236
View File
@@ -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");
});
});
});
+4 -2
View File
@@ -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);
+17 -30
View File
@@ -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);