mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(player): E.19 — extract analysis-refresh helpers cluster (#583)
Two thematic cuts in one PR:
- `src/store/waveformRefresh.ts` — `refreshWaveformForTrack` plus its
`WaveformCachePayload` type. Fetches the cached waveform row and
applies bins to the player store, guarded by both the refresh
generation snapshot and the current-track check so a stale read
can't overwrite fresh data.
- `src/store/loudnessRefresh.ts` — `refreshLoudnessForTrack` plus the
`loudnessRefreshInflight` coalescing map and `LoudnessCachePayload`
type. Orchestrates the loudness fetch: dedupe concurrent calls by
(trackId, syncEngine, target), distinguish hit vs miss, enqueue
bounded backfill, suppress stale-target results by recursive retry.
Both file-private; no caller-side changes outside playerStore's own
imports. Imports of helpers that were only used by these two functions
get cleaned up out of playerStore (coerceWaveformBins, getBackfillAttempts,
forgetLoudnessGain, redactSubsonicUrlForLog, LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow, etc.).
20 tests across the two modules pin the orchestration: gen + current-track
guards on waveform; coalesce + hit/miss/backfill/stale-target/sync-flag
branches on loudness.
playerStore 3139 → 2983 LOC — first sub-3000 milestone for Phase E.
This commit is contained in:
committed by
GitHub
parent
4c64844349
commit
7dc4888a06
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* `refreshWaveformForTrack` fetches an analysis row from Rust and applies
|
||||
* it to the player store — but only if the refresh generation hasn't been
|
||||
* bumped meanwhile and the track is still current. The tests pin both
|
||||
* guards and the success / null-row / empty-bins / error branches.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
|
||||
coerceWaveformBinsMock: vi.fn((bins: unknown) => {
|
||||
if (bins == null) return null;
|
||||
if (Array.isArray(bins) && bins.length === 0) return null;
|
||||
return bins as number[];
|
||||
}),
|
||||
playerSnapshot: {
|
||||
currentTrack: null as { id: string } | null,
|
||||
},
|
||||
playerSetStateMock: vi.fn(),
|
||||
gen: 0,
|
||||
getGenMock: vi.fn(() => hoisted.gen),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('../utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
|
||||
vi.mock('./playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.playerSnapshot,
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
vi.mock('./waveformRefreshGen', () => ({
|
||||
getWaveformRefreshGen: hoisted.getGenMock,
|
||||
}));
|
||||
|
||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.invokeMock.mockReset();
|
||||
hoisted.invokeMock.mockResolvedValue(null);
|
||||
hoisted.coerceWaveformBinsMock.mockClear();
|
||||
hoisted.playerSetStateMock.mockClear();
|
||||
hoisted.playerSnapshot.currentTrack = null;
|
||||
hoisted.gen = 0;
|
||||
});
|
||||
|
||||
describe('refreshWaveformForTrack', () => {
|
||||
it('is a no-op for empty trackId', async () => {
|
||||
await refreshWaveformForTrack('');
|
||||
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards results when the gen has been bumped since the call started', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockImplementationOnce(async () => {
|
||||
hoisted.gen = 99; // simulate concurrent bump
|
||||
return { bins: [1, 2, 3] };
|
||||
});
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when the track is no longer current after the fetch', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 'other' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ bins: [1, 2, 3] });
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blanks bins when the row is null', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce(null);
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('blanks bins when coerceWaveformBins returns null (invalid shape)', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ bins: 'garbage' });
|
||||
hoisted.coerceWaveformBinsMock.mockReturnValueOnce(null);
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
|
||||
});
|
||||
|
||||
it('applies the coerced bins on a clean fetch', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockResolvedValueOnce({ bins: [10, 20, 30] });
|
||||
hoisted.coerceWaveformBinsMock.mockReturnValueOnce([10, 20, 30]);
|
||||
await refreshWaveformForTrack('t1');
|
||||
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: [10, 20, 30] });
|
||||
});
|
||||
|
||||
it('swallows fetch errors silently (placeholder waveform stays)', async () => {
|
||||
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||
hoisted.invokeMock.mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(refreshWaveformForTrack('t1')).resolves.toBeUndefined();
|
||||
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user