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:
Frank Stellmacher
2026-05-12 16:15:34 +02:00
committed by GitHub
parent 4c64844349
commit 7dc4888a06
5 changed files with 492 additions and 162 deletions
+99
View File
@@ -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();
});
});