mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(player): E.18 — extract three transport-coordination modules (#581)
Cluster of three small thematically-related cuts in one PR (per the new
'cluster, don't single-shot' convention):
- `src/store/seekTargetState.ts` — the seek-target guard (`seekTarget`,
`seekTargetSetAt`, `SEEK_TARGET_GUARD_TIMEOUT_MS`, set/clear/get
accessors) that suppresses stale Rust progress ticks until the
engine catches up to the requested position
- `src/store/togglePlayLock.ts` — the 300 ms double-click cooldown
behind a `tryAcquireTogglePlayLock()` helper that auto-releases on
a timer (collapses the three-line inline pattern in `togglePlay`)
- `src/store/loudnessReseed.ts` — the full `reseedLoudnessForTrackId`
pipeline (gen-bump → cache + backfill wipe → state reset → server
row delete → forced seed enqueue), pulled out of playerStore as a
single async helper
All three were file-private; no caller-side changes outside
playerStore's own imports. The progress handler's seek-guard branch is
now ~3 lines shorter and reads through accessors. `togglePlay` collapses
to one guard check.
24 tests across the three modules pin the API contracts.
playerStore 3189 → 3139 LOC.
This commit is contained in:
committed by
GitHub
parent
9029ab8ec5
commit
4c64844349
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* `reseedLoudnessForTrackId` orchestrates a full analysis re-run for one
|
||||
* track: invalidate waveform gen, clear loudness + backfill state, wipe
|
||||
* server rows, kick a forced seed. The orchestration is what's worth
|
||||
* pinning — each individual helper is already tested in its own module.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const authState = {
|
||||
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
|
||||
loudnessTargetLufs: -14,
|
||||
};
|
||||
const playerSnapshot: {
|
||||
currentTrack: { id: string } | null;
|
||||
updateReplayGainForCurrentTrack: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
currentTrack: null,
|
||||
updateReplayGainForCurrentTrack: vi.fn(),
|
||||
};
|
||||
return {
|
||||
authState,
|
||||
playerSnapshot,
|
||||
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
|
||||
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
bumpWaveformRefreshGenMock: vi.fn(),
|
||||
clearLoudnessCacheMock: vi.fn(),
|
||||
resetBackfillStateMock: vi.fn(),
|
||||
playerSetStateMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
|
||||
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
|
||||
vi.mock('./playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.playerSnapshot,
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
vi.mock('./waveformRefreshGen', () => ({
|
||||
bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock,
|
||||
}));
|
||||
vi.mock('./loudnessGainCache', () => ({
|
||||
clearLoudnessCacheStateForTrackId: hoisted.clearLoudnessCacheMock,
|
||||
}));
|
||||
vi.mock('./loudnessBackfillState', () => ({
|
||||
resetLoudnessBackfillStateForTrackId: hoisted.resetBackfillStateMock,
|
||||
}));
|
||||
|
||||
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.authState.normalizationEngine = 'loudness';
|
||||
hoisted.authState.loudnessTargetLufs = -14;
|
||||
hoisted.playerSnapshot.currentTrack = null;
|
||||
hoisted.invokeMock.mockReset();
|
||||
hoisted.invokeMock.mockResolvedValue(undefined);
|
||||
hoisted.buildStreamUrlMock.mockClear();
|
||||
hoisted.bumpWaveformRefreshGenMock.mockClear();
|
||||
hoisted.clearLoudnessCacheMock.mockClear();
|
||||
hoisted.resetBackfillStateMock.mockClear();
|
||||
hoisted.playerSetStateMock.mockClear();
|
||||
hoisted.playerSnapshot.updateReplayGainForCurrentTrack = vi.fn();
|
||||
});
|
||||
|
||||
describe('reseedLoudnessForTrackId', () => {
|
||||
it('is a no-op for empty trackId', async () => {
|
||||
await reseedLoudnessForTrackId('');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when normalization engine isn't loudness", async () => {
|
||||
hoisted.authState.normalizationEngine = 'off';
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the full reseed pipeline in order', async () => {
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.bumpWaveformRefreshGenMock).toHaveBeenCalledWith('t1');
|
||||
expect(hoisted.clearLoudnessCacheMock).toHaveBeenCalledWith('t1');
|
||||
expect(hoisted.resetBackfillStateMock).toHaveBeenCalledWith('t1');
|
||||
const invokeCalls = hoisted.invokeMock.mock.calls.map(c => c[0]);
|
||||
expect(invokeCalls).toEqual([
|
||||
'analysis_delete_waveform_for_track',
|
||||
'analysis_delete_loudness_for_track',
|
||||
'analysis_enqueue_seed_from_url',
|
||||
]);
|
||||
expect(hoisted.invokeMock.mock.calls[2][1]).toEqual({
|
||||
trackId: 't1',
|
||||
url: 'https://mock/stream/t1',
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('blanks the seekbar only when the reseed target is the current track', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||
expect(setStateCalls).toContainEqual({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('does NOT blank the seekbar when reseeding a different track', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 'other' };
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||
expect(setStateCalls).not.toContainEqual({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('resets live normalization-state to placeholder values', async () => {
|
||||
hoisted.authState.loudnessTargetLufs = -10;
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||
expect(setStateCalls).toContainEqual({
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: -10,
|
||||
normalizationEngineLive: 'loudness',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues past errors in delete-waveform', async () => {
|
||||
hoisted.invokeMock
|
||||
.mockRejectedValueOnce(new Error('waveform delete failed'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('continues past errors in delete-loudness', async () => {
|
||||
hoisted.invokeMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('loudness delete failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await reseedLoudnessForTrackId('t1');
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('swallows the final enqueue-seed error', async () => {
|
||||
hoisted.invokeMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('enqueue failed'));
|
||||
await expect(reseedLoudnessForTrackId('t1')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user