refactor(player): E.6 — extract deriveNormalizationSnapshot into module (#569)

`deriveNormalizationSnapshot` — the loudness / replaygain / off branch
that the runtime uses to compute the normalization fields on every track
switch and queue rewrite — moves into src/store/normalizationSnapshot.ts.
File-private, four internal call sites updated to import from the new
module. Adds 8 focused tests pinning each branch (off, loudness with
target LUFS, replaygain enabled with tag + pre-gain, replaygain enabled
with fallback) plus neighbour-track context for album-mode resolution.

playerStore 3470 → 3434 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 12:21:51 +02:00
committed by GitHub
parent b68bddd034
commit 81b161a418
3 changed files with 189 additions and 37 deletions
+137
View File
@@ -0,0 +1,137 @@
/**
* Derived normalization snapshot — three engine branches and a couple of
* replaygain corner cases (no tag → fallback, neighbour-track context for
* album-mode resolution). useAuthStore is mocked through a hoisted state
* object so each test can flip flags without rebuilding the store.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const { authState } = vi.hoisted(() => ({
authState: {
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
loudnessTargetLufs: -14,
replayGainEnabled: false,
replayGainMode: 'track' as 'track' | 'album',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
},
}));
vi.mock('./authStore', () => ({
useAuthStore: { getState: () => authState },
}));
vi.mock('../utils/resolveReplayGainDb', () => ({
resolveReplayGainDb: vi.fn(
(track: Track) => (track as Track & { _testGain?: number | null })._testGain ?? null,
),
}));
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
function track(id: string, gain: number | null = null): Track {
return {
id,
title: id,
artist: 'A',
album: 'X',
albumId: 'X',
duration: 100,
...(gain !== null ? { _testGain: gain } : {}),
} as Track;
}
beforeEach(() => {
authState.normalizationEngine = 'off';
authState.loudnessTargetLufs = -14;
authState.replayGainEnabled = false;
authState.replayGainMode = 'track';
authState.replayGainPreGainDb = 0;
authState.replayGainFallbackDb = -6;
});
describe("engine='off'", () => {
it('returns a fully-null snapshot', () => {
expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0)).toEqual({
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
});
});
});
describe("engine='loudness'", () => {
it('returns the configured target LUFS and clears nowDb', () => {
authState.normalizationEngine = 'loudness';
authState.loudnessTargetLufs = -10;
expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0)).toEqual({
normalizationNowDb: null,
normalizationTargetLufs: -10,
normalizationEngineLive: 'loudness',
});
});
});
describe("engine='replaygain'", () => {
it("falls through to 'off' when replayGainEnabled is false", () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = false;
expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0).normalizationEngineLive).toBe('off');
});
it('uses the resolved gain + pre-gain when a tag is present', () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
authState.replayGainPreGainDb = 2;
const snap = deriveNormalizationSnapshot(track('a', -8), [track('a', -8)], 0);
expect(snap.normalizationNowDb).toBe(-6); // -8 + 2
expect(snap.normalizationEngineLive).toBe('replaygain');
expect(snap.normalizationTargetLufs).toBeNull();
});
it('uses the fallback dB when no tag is resolvable', () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
authState.replayGainFallbackDb = -3;
const snap = deriveNormalizationSnapshot(track('a'), [track('a')], 0);
expect(snap.normalizationNowDb).toBe(-3);
expect(snap.normalizationEngineLive).toBe('replaygain');
});
it('passes neighbour-track context for album-mode resolution', async () => {
const { resolveReplayGainDb } = await import('../utils/resolveReplayGainDb');
const spy = vi.mocked(resolveReplayGainDb);
spy.mockClear();
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
authState.replayGainMode = 'album';
const queue = [track('prev'), track('current'), track('next')];
deriveNormalizationSnapshot(queue[1], queue, 1);
expect(spy).toHaveBeenCalledWith(queue[1], queue[0], queue[2], true, 'album');
});
it('passes null for prev when queueIndex is 0', async () => {
const { resolveReplayGainDb } = await import('../utils/resolveReplayGainDb');
const spy = vi.mocked(resolveReplayGainDb);
spy.mockClear();
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
const queue = [track('a'), track('b')];
deriveNormalizationSnapshot(queue[0], queue, 0);
expect(spy.mock.calls[0][1]).toBeNull();
expect(spy.mock.calls[0][2]).toEqual(queue[1]);
});
it('passes null for next when queueIndex is the last slot', async () => {
const { resolveReplayGainDb } = await import('../utils/resolveReplayGainDb');
const spy = vi.mocked(resolveReplayGainDb);
spy.mockClear();
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
const queue = [track('a'), track('b')];
deriveNormalizationSnapshot(queue[1], queue, 1);
expect(spy.mock.calls[0][1]).toEqual(queue[0]);
expect(spy.mock.calls[0][2]).toBeNull();
});
});