Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions app/frontend/__tests__/library-section.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Tests for unified library_get_section integration.
*
* Verifies that applySectionData and buildCacheEntry correctly use
* authoritative stats from the backend response (total_tracks, total_duration)
* instead of computing them from the tracks array.
*/

import { describe, expect, it } from 'vitest';

// Mock window and Alpine before importing modules that depend on them
globalThis.window = {
...globalThis.window,
Alpine: {
disableEffectScheduling: (fn) => fn(),
},
__TAURI__: undefined,
};

// Dynamic import to ensure window mock is set up first
const { applySectionData } = await import('../js/utils/library-operations.js');
const { buildCacheEntry } = await import('../js/utils/library-cache.js');

// ---------------------------------------------------------------------------
// applySectionData
// ---------------------------------------------------------------------------

describe('applySectionData', () => {
function createMockStore() {
return {
totalTracks: 0,
totalDuration: 0,
_lastLoadedSection: null,
_sectionTracks: null,
_setSectionTracks(tracks) {
this._sectionTracks = tracks;
},
};
}

it('uses total_tracks and total_duration from backend response', () => {
const store = createMockStore();
const tracks = [
{ id: 1, duration: 100 },
{ id: 2, duration: 200 },
];
const data = {
total_tracks: 42,
total_duration: 12345.5,
};

applySectionData(store, 'all', tracks, data);

expect(store.totalTracks).toBe(42);
expect(store.totalDuration).toBe(12345.5);
expect(store._lastLoadedSection).toBe('all');
expect(store._sectionTracks).toEqual(tracks);
});

it('falls back to total field when total_tracks is absent', () => {
const store = createMockStore();
const tracks = [{ id: 1, duration: 60 }];
const data = { total: 10 };

applySectionData(store, 'liked', tracks, data);

expect(store.totalTracks).toBe(10);
});

it('falls back to tracks.length when no total fields present', () => {
const store = createMockStore();
const tracks = [{ id: 1 }, { id: 2 }, { id: 3 }];

applySectionData(store, 'recent', tracks, {});

expect(store.totalTracks).toBe(3);
});

it('falls back to computing totalDuration from tracks when total_duration absent', () => {
const store = createMockStore();
const tracks = [
{ id: 1, duration: 100 },
{ id: 2, duration: 200 },
];

applySectionData(store, 'added', tracks, {});

expect(store.totalDuration).toBe(300);
});

it('prefers total_duration of 0 over JS-computed fallback', () => {
const store = createMockStore();
const tracks = [{ id: 1, duration: 100 }];
const data = { total_duration: 0 };

applySectionData(store, 'top25', tracks, data);

// total_duration: 0 is a valid authoritative value
expect(store.totalDuration).toBe(0);
});
});

// ---------------------------------------------------------------------------
// buildCacheEntry
// ---------------------------------------------------------------------------

describe('buildCacheEntry', () => {
it('uses total_tracks and total_duration from backend response', () => {
const data = {
tracks: [{ duration: 100 }],
total_tracks: 500,
total_duration: 99999.0,
};

const entry = buildCacheEntry(data);

expect(entry.totalTracks).toBe(500);
expect(entry.totalDuration).toBe(99999.0);
expect(entry.timestamp).toBeGreaterThan(0);
});

it('falls back to total field when total_tracks is absent', () => {
const data = {
tracks: [{ duration: 100 }],
total: 10,
};

const entry = buildCacheEntry(data);

expect(entry.totalTracks).toBe(10);
});

it('falls back to tracks.length when no total fields', () => {
const data = {
tracks: [{ duration: 100 }, { duration: 200 }],
};

const entry = buildCacheEntry(data);

expect(entry.totalTracks).toBe(2);
});

it('falls back to computing duration from tracks when total_duration absent', () => {
const data = {
tracks: [{ duration: 100 }, { duration: 200 }],
};

const entry = buildCacheEntry(data);

expect(entry.totalDuration).toBe(300);
});

it('handles empty tracks with backend stats', () => {
const data = {
tracks: [],
total_tracks: 0,
total_duration: 0,
};

const entry = buildCacheEntry(data);

expect(entry.totalTracks).toBe(0);
expect(entry.totalDuration).toBe(0);
});
});
39 changes: 31 additions & 8 deletions app/frontend/__tests__/library.store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -569,13 +569,23 @@ describe('Paginated Store - _isPaginated', () => {
describe('loadLibraryData - FOUC regression', () => {
let loadLibraryData;

let mockGetSection;

beforeEach(async () => {
vi.resetModules();

// Mock the library API
// Mock the library API with getSection (unified endpoint)
mockGetSection = vi.fn().mockResolvedValue({
section: 'all',
tracks: makeTracks(50),
total_tracks: 50,
total_duration: 3600,
has_more: false,
revision: 1,
});
vi.doMock('../js/api/library.js', () => ({
library: {
getCount: vi.fn().mockResolvedValue({ total: 50, total_duration: 3600 }),
getSection: mockGetSection,
},
}));

Expand Down Expand Up @@ -603,7 +613,6 @@ describe('loadLibraryData - FOUC regression', () => {
store.currentSection = 'all';
store._lastLoadedSection = 'all';
store._sectionCache = {};
store._fetchPage = vi.fn().mockResolvedValue(undefined);
store._getFilterParams = () => ({});
store._updateCache = vi.fn();
return store;
Expand All @@ -613,14 +622,21 @@ describe('loadLibraryData - FOUC regression', () => {
const store = createLoadStore();
const statesDuringFetch = [];

// Capture state when _fetchPage is called (during the await)
store._fetchPage = vi.fn().mockImplementation(() => {
// Capture state when getSection is called (during the await)
mockGetSection.mockImplementation(() => {
statesDuringFetch.push({
totalTracks: store.totalTracks,
loading: store.loading,
pagesEmpty: Object.keys(store._trackPages).length === 0,
});
return Promise.resolve();
return Promise.resolve({
section: 'all',
tracks: makeTracks(50),
total_tracks: 50,
total_duration: 3600,
has_more: false,
revision: 1,
});
});

await loadLibraryData(store);
Expand All @@ -644,9 +660,16 @@ describe('loadLibraryData - FOUC regression', () => {
};

const tracksSeenDuringFetch = [];
store._fetchPage = vi.fn().mockImplementation(() => {
mockGetSection.mockImplementation(() => {
tracksSeenDuringFetch.push(store.totalTracks);
return Promise.resolve();
return Promise.resolve({
section: 'all',
tracks: makeTracks(50),
total_tracks: 50,
total_duration: 3600,
has_more: false,
revision: 1,
});
});

await loadLibraryData(store);
Expand Down
84 changes: 84 additions & 0 deletions app/frontend/js/api/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,90 @@ export const library = {
return request(`/library${queryString ? `?${queryString}` : ''}`);
},

/**
* Get a complete view model for any library section in a single call.
* Returns tracks, authoritative stats, pagination metadata, and a revision
* number for cache invalidation — all from the same DB transaction.
* @param {object} params
* @param {string} params.section - 'all', 'liked', 'top25', 'recent', 'added', or 'playlist-{id}'
* @param {string} [params.search] - Search query (all section only)
* @param {string} [params.artist] - Artist filter (all section only)
* @param {string} [params.album] - Album filter (all section only)
* @param {string} [params.sort] - Sort field (all section only)
* @param {string} [params.order] - Sort order (all section only)
* @param {number} [params.limit] - Page size / result limit
* @param {number} [params.offset] - Page offset (all section only)
* @param {string} [params.ignoreWords] - Ignore words for sort (all section only)
* @param {number} [params.days] - Days lookback (recent/added sections)
* @returns {Promise<{section: string, tracks: Array, total_tracks: number, total_duration: number, page: number|null, page_size: number|null, has_more: boolean, revision: number}>}
*/
async getSection(params = {}) {
if (invoke) {
try {
return await invoke('library_get_section', {
section: params.section,
search: params.search || null,
artist: params.artist || null,
album: params.album || null,
sortBy: params.sort || null,
sortOrder: params.order || null,
limit: params.limit || null,
offset: params.offset || null,
ignoreWords: params.ignoreWords || null,
days: params.days || null,
});
} catch (error) {
console.error('[api.library.getSection] Tauri error:', error);
throw new ApiError(500, error.toString());
}
}
// HTTP fallback: dispatch to the appropriate REST endpoint per section
const section = params.section || 'all';

// Playlist sections use the playlists REST endpoint
const playlistMatch = section.match(/^playlist-(\d+)$/);
if (playlistMatch) {
const data = await request(`/playlists/${playlistMatch[1]}`);
const tracks = (data.tracks || []).map((item) => item.track || item);
const totalDuration = tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
return {
section,
tracks,
total_tracks: tracks.length,
total_duration: totalDuration,
page: null,
page_size: null,
has_more: false,
revision: 0,
};
}

// All other sections compose from /library + /library/count
const query = new URLSearchParams();
if (params.search) query.set('search', params.search);
if (params.artist) query.set('artist', params.artist);
if (params.album) query.set('album', params.album);
if (params.sort) query.set('sort_by', params.sort);
if (params.order) query.set('sort_order', params.order);
if (params.limit) query.set('limit', params.limit.toString());
if (params.offset) query.set('offset', params.offset.toString());
const qs = query.toString();
const [trackData, countData] = await Promise.all([
request(`/library${qs ? `?${qs}` : ''}`),
request(`/library/count${qs ? `?${qs}` : ''}`),
]);
return {
section,
tracks: trackData.tracks || [],
total_tracks: countData.total ?? (trackData.tracks || []).length,
total_duration: countData.total_duration ?? 0,
page: params.offset != null ? Math.floor(params.offset / (params.limit || 50)) : null,
page_size: params.limit || null,
has_more: trackData.total > (params.offset || 0) + (trackData.tracks || []).length,
revision: 0,
};
},

/**
* Get a single track by ID (uses Tauri command)
* @param {number} id - Track ID
Expand Down
9 changes: 8 additions & 1 deletion app/frontend/js/api/queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,14 @@ export const queue = {
throw new ApiError(500, error.toString());
}
}
throw new ApiError(500, 'playContext not available in browser mode');
return request('/queue/play-context', {
method: 'POST',
body: JSON.stringify({
track_ids: trackIds,
start_index: startIndex,
shuffle,
}),
});
},

save(state) {
Expand Down
Loading
Loading