mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
371 lines
8.7 KiB
TypeScript
371 lines
8.7 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { StoreError } from '../src/errors';
|
|
import { createQueue } from '../src/queue';
|
|
import { createSlice } from '../src/slice';
|
|
import { createStore } from '../src/store';
|
|
|
|
describe('store', () => {
|
|
// Mock target that mimics HTMLVideoElement
|
|
class MockMedia extends EventTarget {
|
|
volume = 1;
|
|
muted = false;
|
|
paused = true;
|
|
play = vi.fn();
|
|
pause = vi.fn();
|
|
}
|
|
|
|
const audioSlice = createSlice<MockMedia>()({
|
|
initialState: { volume: 1, muted: false },
|
|
getSnapshot: ({ target }) => ({
|
|
volume: target.volume,
|
|
muted: target.muted,
|
|
}),
|
|
subscribe: ({ target, update, signal }) => {
|
|
const handler = () => update();
|
|
target.addEventListener('volumechange', handler);
|
|
signal.addEventListener('abort', () => {
|
|
target.removeEventListener('volumechange', handler);
|
|
});
|
|
},
|
|
request: {
|
|
setVolume: (volume: number, { target }) => {
|
|
target.volume = volume;
|
|
target.dispatchEvent(new Event('volumechange'));
|
|
},
|
|
setMuted: (muted: boolean, { target }) => {
|
|
target.muted = muted;
|
|
target.dispatchEvent(new Event('volumechange'));
|
|
},
|
|
},
|
|
});
|
|
|
|
const playbackSlice = createSlice<MockMedia>()({
|
|
initialState: { paused: true },
|
|
getSnapshot: ({ target }) => ({ paused: target.paused }),
|
|
subscribe: () => {},
|
|
request: {
|
|
play: {
|
|
key: 'playback',
|
|
async handler(_, { target }) {
|
|
target.play();
|
|
target.paused = false;
|
|
},
|
|
},
|
|
pause: {
|
|
key: 'playback',
|
|
async handler(_, { target }) {
|
|
target.pause();
|
|
target.paused = true;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
describe('creation', () => {
|
|
it('creates store with merged initial state', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice, playbackSlice],
|
|
});
|
|
|
|
expect(store.state).toEqual({
|
|
volume: 1,
|
|
muted: false,
|
|
paused: true,
|
|
});
|
|
});
|
|
|
|
it('creates store with default queue', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
expect(store.queue).toBeDefined();
|
|
});
|
|
|
|
it('accepts custom queue', () => {
|
|
const queue = createQueue<any>();
|
|
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
queue,
|
|
});
|
|
|
|
expect(store.queue).toBe(queue);
|
|
});
|
|
|
|
it('calls onSetup', () => {
|
|
const onSetup = vi.fn();
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
onSetup,
|
|
});
|
|
|
|
expect(onSetup).toHaveBeenCalledWith({
|
|
store,
|
|
signal: expect.any(AbortSignal),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('attach', () => {
|
|
it('syncs state from target', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
media.volume = 0.5;
|
|
media.muted = true;
|
|
|
|
store.attach(media);
|
|
|
|
expect(store.state).toEqual({ volume: 0.5, muted: true });
|
|
expect(store.target).toBe(media);
|
|
});
|
|
|
|
it('calls onAttach', () => {
|
|
const onAttach = vi.fn();
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
onAttach,
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
store.attach(media);
|
|
|
|
expect(onAttach).toHaveBeenCalledWith({
|
|
store,
|
|
target: media,
|
|
signal: expect.any(AbortSignal),
|
|
});
|
|
});
|
|
|
|
it('sets up subscriptions', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
const addListenerSpy = vi.spyOn(media, 'addEventListener');
|
|
|
|
store.attach(media);
|
|
|
|
expect(addListenerSpy).toHaveBeenCalledWith(
|
|
'volumechange',
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('detach cleans up', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
const removeListenerSpy = vi.spyOn(media, 'removeEventListener');
|
|
|
|
const detach = store.attach(media);
|
|
detach();
|
|
|
|
expect(store.target).toBeNull();
|
|
expect(removeListenerSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reattach cleans up previous', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media1 = new MockMedia();
|
|
const m1RemoveListenerSpy = vi.spyOn(media1, 'removeEventListener');
|
|
|
|
const media2 = new MockMedia();
|
|
|
|
media2.volume = 0.3;
|
|
|
|
store.attach(media1);
|
|
store.attach(media2);
|
|
|
|
expect(store.target).toBe(media2);
|
|
expect(store.state.volume).toBe(0.3);
|
|
expect(m1RemoveListenerSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('request', () => {
|
|
it('executes request on target', async () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
store.attach(media);
|
|
|
|
await store.request.setVolume(0.5);
|
|
|
|
expect(media.volume).toBe(0.5);
|
|
});
|
|
|
|
it('throws StoreError without target', async () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
onError: () => {}, // silence errors
|
|
});
|
|
|
|
await expect(store.request.setVolume(0.5)).rejects.toThrow(StoreError);
|
|
});
|
|
|
|
it('coordinates requests with same key', async () => {
|
|
const store = createStore({
|
|
slices: [playbackSlice],
|
|
onError: () => {}, // silence errors
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
store.attach(media);
|
|
|
|
const playPromise = store.request.play();
|
|
const pausePromise = store.request.pause();
|
|
|
|
await expect(playPromise).rejects.toThrow(StoreError);
|
|
await pausePromise;
|
|
|
|
expect(media.paused).toBe(true);
|
|
});
|
|
|
|
it('accepts metadata', async () => {
|
|
const handlerSpy = vi.fn();
|
|
|
|
const slice = createSlice<MockMedia>()({
|
|
initialState: {},
|
|
getSnapshot: () => ({}),
|
|
subscribe: () => {},
|
|
request: {
|
|
action: {
|
|
handler: (_, ctx) => {
|
|
handlerSpy(ctx.meta);
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const store = createStore({
|
|
slices: [slice],
|
|
});
|
|
|
|
store.attach(new MockMedia());
|
|
|
|
await store.request.action(null, {
|
|
source: 'user',
|
|
reason: 'test',
|
|
});
|
|
|
|
expect(handlerSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: 'user',
|
|
reason: 'test',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('subscribe', () => {
|
|
it('notifies on state change', async () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
store.attach(media);
|
|
|
|
const listener = vi.fn();
|
|
store.subscribe(listener);
|
|
|
|
await store.request.setVolume(0.5);
|
|
|
|
const calls = listener.mock.calls;
|
|
const lastState = calls[calls.length - 1][0];
|
|
expect(lastState.volume).toBe(0.5);
|
|
});
|
|
|
|
it('keyed subscription only notifies for those keys', async () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
store.attach(media);
|
|
|
|
const volumeListener = vi.fn();
|
|
store.subscribe(['volume'], volumeListener);
|
|
|
|
// Reset after attach sync
|
|
volumeListener.mockClear();
|
|
|
|
await store.request.setMuted(true);
|
|
expect(volumeListener).not.toHaveBeenCalled();
|
|
|
|
await store.request.setVolume(0.7);
|
|
expect(volumeListener).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('destroy', () => {
|
|
it('cleans up everything', () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
const media = new MockMedia();
|
|
store.attach(media);
|
|
store.destroy();
|
|
|
|
expect(store.destroyed).toBe(true);
|
|
expect(store.target).toBeNull();
|
|
expect(store.queue.destroyed).toBe(true);
|
|
});
|
|
|
|
it('rejects requests after destroy', async () => {
|
|
const store = createStore({
|
|
slices: [audioSlice],
|
|
});
|
|
|
|
store.attach(new MockMedia());
|
|
store.destroy();
|
|
|
|
await expect(store.request.setVolume(0.5)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('calls onError for request errors', async () => {
|
|
const onError = vi.fn();
|
|
|
|
const failingSlice = createSlice<MockMedia>()({
|
|
initialState: {},
|
|
getSnapshot: () => ({}),
|
|
subscribe: () => {},
|
|
request: {
|
|
fail: () => {
|
|
throw new Error('request failed');
|
|
},
|
|
},
|
|
});
|
|
|
|
const store = createStore({
|
|
slices: [failingSlice],
|
|
onError,
|
|
});
|
|
|
|
store.attach(new MockMedia());
|
|
|
|
await store.request.fail().catch(() => {});
|
|
|
|
expect(onError).toHaveBeenCalledWith({
|
|
error: expect.any(Error),
|
|
store,
|
|
});
|
|
});
|
|
});
|
|
});
|