Files
Psychotoxical-psysonic/src/store/offlineJobStore.test.ts
T
Frank Stellmacher b4c8ed4b65 fix(offline): cancellable downloads + stable sidebar progress toast (#694)
* fix(sidebar): keep offline-download toast from squishing in a short window

The toast lives in the sidebar nav flex column; without flex-shrink: 0 the
column compressed it vertically when the main window was small. The label
now also ellipsis-truncates instead of overflowing on a narrow sidebar.

* fix(offline): make offline downloads cancellable down to the Rust transfer

A running offline download could not be stopped — the sidebar X button only
dropped not-yet-started tracks between batches of 8, and the Rust transfer had
no cancellation path at all, so in-flight HTTP streams always ran to completion.

Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the
device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel
commands. download_track_offline takes an optional download_id, checks the flag
right after acquiring its semaphore slot, and threads it through
finalize_streamed_download / stream_to_file so an in-flight stream aborts at the
next chunk — the partial .part file is cleaned up by the existing error path.

* fix(offline): cancel per-track and clear the sidebar toast immediately

downloadAlbum tags each run with a downloadId, checks for cancellation before
every track instead of once per 8-track batch (which never re-ran for albums of
8 or fewer tracks), and persists tracks that finished before the cancel so they
are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for
the album and call cancel_offline_downloads so Rust aborts the in-flight
transfers — the toast disappears at once instead of lingering on stuck rows.

Adds offlineJobStore cancellation tests.

* docs(changelog): offline download cancel button + toast sizing fixes
2026-05-14 20:08:08 +02:00

85 lines
2.7 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useOfflineJobStore, cancelledDownloads, type DownloadJob } from './offlineJobStore';
function job(over: Partial<DownloadJob>): DownloadJob {
return {
trackId: 't',
albumId: 'a',
albumName: 'A',
trackTitle: 'T',
trackIndex: 0,
totalTracks: 1,
status: 'queued',
downloadId: 'a-1',
...over,
};
}
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], bulkProgress: {} });
cancelledDownloads.clear();
});
describe('offlineJobStore cancellation', () => {
it('cancelAllDownloads drops queued + downloading jobs but keeps settled ones', () => {
const calls: string[][] = [];
onInvoke('cancel_offline_downloads', (a: unknown) => {
calls.push((a as { downloadIds: string[] }).downloadIds);
});
useOfflineJobStore.setState({
jobs: [
job({ trackId: 'q', status: 'queued' }),
job({ trackId: 'd', status: 'downloading' }),
job({ trackId: 'done', status: 'done' }),
job({ trackId: 'err', status: 'error' }),
],
bulkProgress: {},
});
useOfflineJobStore.getState().cancelAllDownloads();
// Only settled jobs survive → the sidebar toast clears.
expect(useOfflineJobStore.getState().jobs.map(j => j.status).sort()).toEqual(['done', 'error']);
expect(cancelledDownloads.has('a')).toBe(true);
// Rust is told to abort the in-flight transfers for this download id.
expect(calls).toEqual([['a-1']]);
});
it('cancelDownload drops every job for one album and leaves others running', () => {
const calls: string[][] = [];
onInvoke('cancel_offline_downloads', (a: unknown) => {
calls.push((a as { downloadIds: string[] }).downloadIds);
});
useOfflineJobStore.setState({
jobs: [
job({ trackId: 't1', albumId: 'a', status: 'downloading', downloadId: 'a-1' }),
job({ trackId: 't2', albumId: 'b', status: 'downloading', downloadId: 'b-1' }),
],
bulkProgress: {},
});
useOfflineJobStore.getState().cancelDownload('a');
expect(useOfflineJobStore.getState().jobs.map(j => j.albumId)).toEqual(['b']);
expect(cancelledDownloads.has('a')).toBe(true);
expect(calls).toEqual([['a-1']]);
});
it('cancelAllDownloads with nothing active does not call into Rust', () => {
let called = false;
onInvoke('cancel_offline_downloads', () => {
called = true;
});
useOfflineJobStore.setState({
jobs: [job({ status: 'done' }), job({ status: 'error' })],
bulkProgress: {},
});
useOfflineJobStore.getState().cancelAllDownloads();
expect(called).toBe(false);
expect(useOfflineJobStore.getState().jobs).toHaveLength(2);
});
});