import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
  player,
  playQueue,
  togglePlay,
  skipNext,
  skipPrev,
  seekTo,
  setVolume,
  registerAudioEl,
  reportTimeUpdate,
  reportDuration,
  readStoredVolume,
  toggleShuffle,
  cycleRepeat,
  reportStateFromAudio,
  __setShuffleRng,
  restoreQueue
} from './store.svelte';
import type { TrackRef } from '$lib/api/types';
import { writePersistedQueue, clearPersistedQueue } from './persisted';
import { makeTrack } from '$test-utils/fixtures/track';

vi.mock('$lib/auth/store.svelte', () => ({
  user: {
    get value() {
      return { id: 'test-user' };
    }
  }
}));

function track(id: string, dur = 180): TrackRef {
  return makeTrack({
    id,
    title: `T ${id}`,
    duration_sec: dur,
    stream_url: `/api/tracks/${id}/stream`
  });
}

beforeEach(() => {
  localStorage.clear();
  // Reset state by starting with an empty queue (playQueue([]) leaves queue=[], index=0).
  playQueue([]);
  closeQueueDrawer();
  setVolume(1);
  registerAudioEl(null);
  // Reset shuffle and repeat to defaults
  if (player.shuffle) toggleShuffle();
  while (player.repeat !== 'off') cycleRepeat();
});

describe('player store — basic actions', () => {
  test('playQueue sets queue, index, state="loading", position=0', () => {
    playQueue([track('1'), track('2'), track('3')], 1);
    expect(player.queue.length).toBe(3);
    expect(player.index).toBe(1);
    expect(player.state).toBe('loading');
    expect(player.position).toBe(0);
    expect(player.current?.id).toBe('2');
  });

  test('playQueue clamps startIndex to [0, length-1]', () => {
    playQueue([track('1'), track('2')], 99);
    expect(player.index).toBe(1);
    playQueue([track('1'), track('2')], -5);
    expect(player.index).toBe(0);
  });

  test('togglePlay flips paused<->playing; no-op on idle', () => {
    expect(player.state).toBe('idle');
    togglePlay();
    expect(player.state).toBe('idle'); // still idle with empty queue

    playQueue([track('1')]);
    // After playQueue we're in 'loading'; togglePlay should not advance without an audio event.
    // But togglePlay from 'loading' should set the intent to play anyway — tested via report later.
    // Here, simulate the paused state:
    reportTimeUpdate(0);
    // manually put us in paused state by calling togglePlay twice isn't clean; set via the state-report API in Task 2.
    // Instead, test the simpler case: playQueue then togglePlay = still 'loading' (intent is to play).
    togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent
    expect(player.state).toBe('paused');
    togglePlay(); // from 'paused' — moves to 'loading' (re-play intent)
    expect(player.state).toBe('loading');
  });

  test('skipNext mid-queue: index++, position=0', () => {
    playQueue([track('1'), track('2'), track('3')], 0);
    reportTimeUpdate(50);
    skipNext();
    expect(player.index).toBe(1);
    expect(player.position).toBe(0);
  });

  test('skipNext at end with no repeat: index stays, state=paused, position=duration', () => {
    playQueue([track('1'), track('2')], 1);
    reportDuration(180);
    skipNext();
    expect(player.index).toBe(1);
    expect(player.state).toBe('paused');
    expect(player.position).toBe(180);
  });

  test('skipPrev with position<3 AND index>0: decrements index', () => {
    playQueue([track('1'), track('2'), track('3')], 2);
    reportTimeUpdate(2);
    skipPrev();
    expect(player.index).toBe(1);
    expect(player.position).toBe(0);
  });

  test('skipPrev with position>=3: seeks to 0, index unchanged', () => {
    playQueue([track('1'), track('2')], 1);
    reportTimeUpdate(10);
    skipPrev();
    expect(player.index).toBe(1);
    expect(player.position).toBe(0);
  });

  test('skipPrev at index=0: position=0, index stays', () => {
    playQueue([track('1'), track('2')], 0);
    reportTimeUpdate(2);
    skipPrev();
    expect(player.index).toBe(0);
    expect(player.position).toBe(0);
  });

  test('setVolume clamps to [0,1] and persists to localStorage', () => {
    setVolume(0.5);
    expect(player.volume).toBe(0.5);
    expect(localStorage.getItem('minstrel.volume')).toBe('0.5');

    setVolume(-1);
    expect(player.volume).toBe(0);

    setVolume(2);
    expect(player.volume).toBe(1);
  });

  test('readStoredVolume returns 1.0 when missing or invalid', () => {
    localStorage.clear();
    expect(readStoredVolume()).toBe(1);
    localStorage.setItem('minstrel.volume', 'not-a-number');
    expect(readStoredVolume()).toBe(1);
    localStorage.setItem('minstrel.volume', '-10');
    expect(readStoredVolume()).toBe(1); // out of range → fallback
    localStorage.setItem('minstrel.volume', '0.7');
    expect(readStoredVolume()).toBe(0.7);
  });

  test('seekTo writes position and, when registered, audioEl.currentTime', () => {
    const el = { currentTime: 0 } as unknown as HTMLAudioElement;
    registerAudioEl(el);
    playQueue([track('1')]);
    seekTo(42);
    expect(player.position).toBe(42);
    expect(el.currentTime).toBe(42);

    // After unregister, seekTo still updates store but doesn't throw.
    registerAudioEl(null);
    seekTo(10);
    expect(player.position).toBe(10);
  });

  test('reportTimeUpdate and reportDuration mutate position and duration', () => {
    playQueue([track('1')]);
    reportTimeUpdate(15);
    reportDuration(240);
    expect(player.position).toBe(15);
    expect(player.duration).toBe(240);
  });
});

describe('player store — shuffle + repeat + audio reports', () => {
  test('cycleRepeat: off → all → one → off', () => {
    playQueue([track('1')]);
    expect(player.repeat).toBe('off');
    cycleRepeat();
    expect(player.repeat).toBe('all');
    cycleRepeat();
    expect(player.repeat).toBe('one');
    cycleRepeat();
    expect(player.repeat).toBe('off');
  });

  test('toggleShuffle randomizes queue[index+1..end], leaves current and earlier alone', () => {
    // Deterministic RNG: always returns 0 so Fisher-Yates swaps each i with i itself → no change.
    // Flip to always 0.999 to force swaps.
    const seq = [0.999, 0.999, 0.999, 0.999, 0.999];
    let i = 0;
    __setShuffleRng(() => seq[i++ % seq.length]);

    const tracks = [track('1'), track('2'), track('3'), track('4'), track('5')];
    playQueue(tracks, 1);
    toggleShuffle();

    expect(player.shuffle).toBe(true);
    // Index 0 and 1 preserved:
    expect(player.queue[0].id).toBe('1');
    expect(player.queue[1].id).toBe('2');
    // Index 2..4 reordered: because RNG = 0.999 always, Math.floor(rng() * (j+1)) === j for each,
    // so swaps are with self — queue is unchanged. So validate that the IDs 3/4/5 are still the
    // tail set, in any order, to tolerate future RNG semantics.
    const tailIds = [player.queue[2].id, player.queue[3].id, player.queue[4].id].sort();
    expect(tailIds).toEqual(['3', '4', '5']);

    __setShuffleRng(Math.random);
  });

  test('skipNext at end with repeat=all: wraps to 0', () => {
    playQueue([track('1'), track('2')], 1);
    cycleRepeat(); // repeat=all
    skipNext();
    expect(player.index).toBe(0);
    expect(player.state).toBe('loading');
  });

  test('skipNext at end with repeat=one: still ends (explicit skip overrides)', () => {
    playQueue([track('1')], 0);
    cycleRepeat(); // all
    cycleRepeat(); // one
    reportDuration(120);
    skipNext();
    expect(player.state).toBe('paused');
    expect(player.position).toBe(120);
  });

  test('reportStateFromAudio("ended") with repeat="one": position=0, index unchanged, state=loading', () => {
    playQueue([track('1'), track('2')], 0);
    reportTimeUpdate(120);
    cycleRepeat(); cycleRepeat(); // repeat=one
    reportStateFromAudio('ended');
    expect(player.index).toBe(0);
    expect(player.position).toBe(0);
    expect(player.state).toBe('loading');
  });

  test('reportStateFromAudio("ended") with repeat="all": wraps when at end', () => {
    playQueue([track('1'), track('2')], 1);
    cycleRepeat(); // repeat=all
    reportStateFromAudio('ended');
    expect(player.index).toBe(0);
    expect(player.state).toBe('loading');
  });

  test('reportStateFromAudio("ended") with repeat="all" mid-queue: advances', () => {
    playQueue([track('1'), track('2'), track('3')], 0);
    cycleRepeat(); // repeat=all
    reportStateFromAudio('ended');
    expect(player.index).toBe(1);
  });

  test('reportStateFromAudio("ended") with repeat="off" at end: paused', () => {
    playQueue([track('1'), track('2')], 1);
    reportDuration(180);
    reportStateFromAudio('ended');
    expect(player.state).toBe('paused');
    expect(player.position).toBe(180);
  });

  test('reportStateFromAudio("playing") sets state=playing, clears error', () => {
    playQueue([track('1')]);
    reportStateFromAudio('error', 'boom');
    expect(player.error).toBe('boom');
    reportStateFromAudio('playing');
    expect(player.state).toBe('playing');
    expect(player.error).toBeNull();
  });

  test('reportStateFromAudio("paused") sets state=paused', () => {
    playQueue([track('1')]);
    reportStateFromAudio('playing');
    reportStateFromAudio('paused');
    expect(player.state).toBe('paused');
  });

  test('reportStateFromAudio("waiting") sets state=loading', () => {
    playQueue([track('1')]);
    reportStateFromAudio('playing');
    reportStateFromAudio('waiting');
    expect(player.state).toBe('loading');
  });

  test('reportStateFromAudio("error", msg) sets state=error, error=msg', () => {
    playQueue([track('1')]);
    reportStateFromAudio('error', 'network lost');
    expect(player.state).toBe('error');
    expect(player.error).toBe('network lost');
  });

  test('reportStateFromAudio("error") auto-advances past a bad track', () => {
    playQueue([track('1'), track('2'), track('3')]);
    reportStateFromAudio('error', 'boom');
    expect(player.index).toBe(1);
    expect(player.state).toBe('loading');
    expect(player.error).toBeNull();
  });

  test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => {
    playQueue([track('1'), track('2')]);
    reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2
    expect(player.index).toBe(1);
    expect(player.state).toBe('loading');
    reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted
    expect(player.state).toBe('error');
    expect(player.error).toBe('boom');
  });

  test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
    const fresh = [track('a'), track('b')];
    const refetch = vi.fn().mockResolvedValue(fresh);
    playQueue([track('1')], 0, { source: 'for_you', refetch });
    reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
    expect(refetch).toHaveBeenCalledOnce();
    expect(player.state).toBe('loading');
    await Promise.resolve();
    await Promise.resolve();
    await Promise.resolve();
    expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
    expect(player.index).toBe(0);
  });

  test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
    const refetch = vi.fn().mockResolvedValue([track('x')]);
    playQueue([track('1')], 0, { source: 'for_you', refetch });
    reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
    await Promise.resolve();
    await Promise.resolve();
    await Promise.resolve();
    reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
    expect(player.state).toBe('error');
    expect(refetch).toHaveBeenCalledOnce();
  });
});

import {
  enqueueTrack,
  enqueueTracks,
  playNext,
  playRadio,
  moveQueueItem,
  removeFromQueue,
  playFromQueueIndex,
  toggleQueueDrawer,
  closeQueueDrawer
} from './store.svelte';
import * as client from '$lib/api/client';

describe('player store — enqueue + playRadio', () => {
  test('enqueueTrack appends to a non-empty queue, _index unchanged', () => {
    playQueue([track('1'), track('2')], 1);
    enqueueTrack(track('3'));
    expect(player.queue.length).toBe(3);
    expect(player.queue[2].id).toBe('3');
    expect(player.index).toBe(1);
  });

  test('enqueueTrack on idle (empty queue) sets index=0 and _state stays idle', () => {
    playQueue([]);
    expect(player.state).toBe('idle');
    enqueueTrack(track('1'));
    expect(player.queue.length).toBe(1);
    expect(player.index).toBe(0);
    expect(player.state).toBe('idle');
  });

  test('enqueueTracks([]) is a no-op', () => {
    playQueue([track('1')]);
    enqueueTracks([]);
    expect(player.queue.length).toBe(1);
  });

  test('enqueueTracks concatenates non-empty list', () => {
    playQueue([track('1')]);
    enqueueTracks([track('2'), track('3')]);
    expect(player.queue.map((t) => t.id)).toEqual(['1', '2', '3']);
  });

  test('playRadio fetches /api/radio and feeds tracks into playQueue', async () => {
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({
      tracks: [track('seed'), track('next')]
    });
    await playRadio('seed');
    expect(apiGet).toHaveBeenCalledWith('/api/radio?seed_track=seed');
    expect(player.queue.length).toBe(2);
    expect(player.index).toBe(0);
    expect(player.state).toBe('loading');
    apiGet.mockRestore();
  });

  test('playRadio with empty response leaves queue alone', async () => {
    playQueue([track('a')]);
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: [] });
    await playRadio('seed');
    expect(player.queue.map((t) => t.id)).toEqual(['a']);
    apiGet.mockRestore();
  });
});

describe('playNext', () => {
  test('splices at _index + 1, leaving _index unchanged', () => {
    playQueue([track('a'), track('b')], 0);
    playNext(track('c'));
    expect(player.queue.map((t) => t.id)).toEqual(['a', 'c', 'b']);
    expect(player.index).toBe(0);
  });

  test('on empty queue, seeds with the track at index 0', () => {
    playQueue([]);
    playNext(track('x'));
    expect(player.queue.map((t) => t.id)).toEqual(['x']);
    expect(player.index).toBe(0);
  });
});

describe('M4c radio auto-refresh at 80%', () => {
  test('refresh fires at 80% queue consumption', async () => {
    const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
    await playRadio('seed');
    apiGet.mockClear();
    apiGet.mockResolvedValueOnce({
      tracks: [track('seed'), track('new1')]
    });
    // Advance to index 3 (4 of 5 = 80%)
    skipNext(); skipNext(); skipNext();
    // Allow $effect to flush
    await new Promise((r) => setTimeout(r, 30));
    expect(apiGet).toHaveBeenCalled();
    const callArg = apiGet.mock.calls[0][0] as string;
    expect(callArg).toContain('seed_track=seed');
    expect(callArg).toContain('exclude=');
    apiGet.mockRestore();
  });

  test('refresh below 80% does not fire', async () => {
    const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
    await playRadio('seed');
    apiGet.mockClear();
    skipNext(); skipNext(); // index = 2 (3 of 5 = 60%)
    await new Promise((r) => setTimeout(r, 30));
    expect(apiGet).not.toHaveBeenCalled();
    apiGet.mockRestore();
  });

  test('refresh appends new tracks excluding seed at index 0', async () => {
    const initial = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial });
    await playRadio('seed');
    apiGet.mockClear();
    // Refresh response: 5 tracks where the first is the seed (will be stripped).
    const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')];
    apiGet.mockResolvedValueOnce({ tracks: refreshTracks });
    skipNext(); skipNext(); skipNext(); // index = 3 (80%)
    await new Promise((r) => setTimeout(r, 30));
    // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9
    expect(player.queue.length).toBe(9);
    apiGet.mockRestore();
  });

  test('refresh does NOT double-fire while in-flight', async () => {
    const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
    await playRadio('seed');
    apiGet.mockClear();
    // Hang the refresh call so the in-flight flag stays set.
    let resolveFn!: (v: unknown) => void;
    const hanging = new Promise((r) => { resolveFn = r; });
    apiGet.mockReturnValueOnce(hanging as unknown as Promise<unknown>);
    skipNext(); skipNext(); skipNext(); // 80% — fires refresh
    await new Promise((r) => setTimeout(r, 30));
    // Now advance further while in-flight; should NOT fire a second call.
    skipNext();
    await new Promise((r) => setTimeout(r, 30));
    expect(apiGet).toHaveBeenCalledTimes(1);
    resolveFn({ tracks: [] });
    apiGet.mockRestore();
  });

  test('refresh resets when user enqueues from non-radio source', async () => {
    const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
    await playRadio('seed');
    enqueueTrack(track('manual1'));
    apiGet.mockClear();
    skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the 6-track queue
    await new Promise((r) => setTimeout(r, 30));
    expect(apiGet).not.toHaveBeenCalled();
    apiGet.mockRestore();
  });
});

describe('queue mutations', () => {
  test('moveQueueItem reorders correctly when the playing track stays put', () => {
    playQueue([track('a'), track('b'), track('c')], 0);
    moveQueueItem(1, 2);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'c', 'b']);
    expect(player.index).toBe(0);
  });

  test('moveQueueItem keeps _index pointing at the playing track when its row moves', () => {
    playQueue([track('a'), track('b'), track('c')], 1);
    moveQueueItem(1, 0);
    expect(player.queue.map((x) => x.id)).toEqual(['b', 'a', 'c']);
    expect(player.index).toBe(0);
  });

  test('moveQueueItem preserves _index logical pointer when other rows reorder around it', () => {
    playQueue([track('a'), track('b'), track('c'), track('d')], 1);
    moveQueueItem(0, 3);
    expect(player.queue.map((x) => x.id)).toEqual(['b', 'c', 'd', 'a']);
    expect(player.index).toBe(0);
  });

  test('moveQueueItem with from === to is a no-op', () => {
    playQueue([track('a'), track('b'), track('c')], 1);
    moveQueueItem(1, 1);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']);
    expect(player.index).toBe(1);
  });

  test('moveQueueItem with out-of-range from or to is a no-op', () => {
    playQueue([track('a'), track('b'), track('c')], 0);
    moveQueueItem(-1, 1);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']);
    moveQueueItem(0, 5);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']);
  });

  test('removeFromQueue with idx < _index decrements _index', () => {
    playQueue([track('a'), track('b'), track('c')], 2);
    removeFromQueue(0);
    expect(player.queue.map((x) => x.id)).toEqual(['b', 'c']);
    expect(player.index).toBe(1);
  });

  test('removeFromQueue with idx > _index leaves _index unchanged', () => {
    playQueue([track('a'), track('b'), track('c')], 0);
    removeFromQueue(2);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'b']);
    expect(player.index).toBe(0);
  });

  test('removeFromQueue with idx === _index advances to next, sets state to loading', () => {
    playQueue([track('a'), track('b'), track('c')], 1);
    removeFromQueue(1);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'c']);
    expect(player.index).toBe(1);
    expect(player.state).toBe('loading');
  });

  test('removeFromQueue emptying the queue sets state to idle', () => {
    playQueue([track('a')], 0);
    removeFromQueue(0);
    expect(player.queue).toEqual([]);
    expect(player.state).toBe('idle');
  });

  test('removeFromQueue of the currently-playing last track pauses on the previous track', () => {
    playQueue([track('a'), track('b'), track('c')], 2);
    removeFromQueue(2);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'b']);
    expect(player.index).toBe(1);
    expect(player.state).toBe('paused');
    expect(player.position).toBe(0);
  });

  test('playFromQueueIndex sets index, resets position, sets state to loading', () => {
    playQueue([track('a'), track('b'), track('c')], 0);
    playFromQueueIndex(2);
    expect(player.index).toBe(2);
    expect(player.position).toBe(0);
    expect(player.state).toBe('loading');
  });

  test('playFromQueueIndex with out-of-range idx is a no-op', () => {
    playQueue([track('a'), track('b')], 0);
    const stateBefore = player.state;
    playFromQueueIndex(5);
    expect(player.index).toBe(0);
    expect(player.state).toBe(stateBefore);
  });

  test('toggleQueueDrawer flips _queueDrawerOpen', () => {
    expect(player.queueDrawerOpen).toBe(false);
    toggleQueueDrawer();
    expect(player.queueDrawerOpen).toBe(true);
    toggleQueueDrawer();
    expect(player.queueDrawerOpen).toBe(false);
  });

  test('closeQueueDrawer is idempotent', () => {
    closeQueueDrawer();
    expect(player.queueDrawerOpen).toBe(false);
    toggleQueueDrawer();
    expect(player.queueDrawerOpen).toBe(true);
    closeQueueDrawer();
    expect(player.queueDrawerOpen).toBe(false);
  });

  test('restoreQueue rehydrates queue, index, position and parks state at paused', () => {
    // Seed localStorage manually so we don't depend on the persistence
    // effect (which is integration-tested at the layout level).
    const userId = 'test-user';
    writePersistedQueue(userId, {
      queue: [track('a'), track('b'), track('c')],
      index: 1,
      position: 42.5
    });
    restoreQueue(userId);
    expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']);
    expect(player.index).toBe(1);
    expect(player.position).toBe(42.5);
    expect(player.state).toBe('paused');
    clearPersistedQueue(userId);
  });

  test('restoreQueue clamps out-of-range index', () => {
    const userId = 'test-user';
    writePersistedQueue(userId, {
      queue: [track('a'), track('b'), track('c')],
      index: 99,
      position: 0
    });
    restoreQueue(userId);
    expect(player.index).toBe(2);
    clearPersistedQueue(userId);
  });

  test('restoreQueue with empty userId is a no-op', () => {
    playQueue([track('a')], 0);
    restoreQueue('');
    expect(player.queue.map((x) => x.id)).toEqual(['a']);
  });
});

describe('crossfade', () => {
  test('deriveFadeScalar returns 1 when crossfadeSec is 0', async () => {
    const { deriveFadeScalar } = await import('./store.svelte');
    expect(deriveFadeScalar(50, 200, 0)).toBe(1);
    expect(deriveFadeScalar(0, 200, 0)).toBe(1);
    expect(deriveFadeScalar(200, 200, 0)).toBe(1);
  });

  test('deriveFadeScalar returns 1 when track is too short to fade twice', async () => {
    const { deriveFadeScalar } = await import('./store.svelte');
    // 6s track with 4s crossfade: 6 < 4*2, so no fade.
    expect(deriveFadeScalar(0, 6, 4)).toBe(1);
    expect(deriveFadeScalar(3, 6, 4)).toBe(1);
  });

  test('deriveFadeScalar ramps in over the leading X seconds', async () => {
    const { deriveFadeScalar } = await import('./store.svelte');
    // 60s track, 4s crossfade.
    expect(deriveFadeScalar(0, 60, 4)).toBe(0);
    expect(deriveFadeScalar(2, 60, 4)).toBe(0.5);
    expect(deriveFadeScalar(4, 60, 4)).toBe(1);
  });

  test('deriveFadeScalar ramps out over the trailing X seconds', async () => {
    const { deriveFadeScalar } = await import('./store.svelte');
    expect(deriveFadeScalar(56, 60, 4)).toBe(1);
    expect(deriveFadeScalar(58, 60, 4)).toBe(0.5);
    expect(deriveFadeScalar(60, 60, 4)).toBe(0);
  });

  test('setCrossfade clamps to [0, 12] and persists', async () => {
    const { setCrossfade, player } = await import('./store.svelte');
    setCrossfade(4);
    expect(player.crossfadeSec).toBe(4);
    expect(localStorage.getItem('minstrel.crossfade')).toBe('4');
    setCrossfade(99);
    expect(player.crossfadeSec).toBe(12);
    setCrossfade(-3);
    expect(player.crossfadeSec).toBe(0);
  });

  test('readStoredCrossfade survives an invalid stored value', async () => {
    const { readStoredCrossfade } = await import('./store.svelte');
    localStorage.setItem('minstrel.crossfade', 'banana');
    expect(readStoredCrossfade()).toBe(0);
    localStorage.setItem('minstrel.crossfade', '500');
    expect(readStoredCrossfade()).toBe(0);
    localStorage.setItem('minstrel.crossfade', '7');
    expect(readStoredCrossfade()).toBe(7);
  });
});
