mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): lit bindings (#289)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
export type { AsyncStatus } from '../../core/queue';
|
||||
|
||||
export { MutationController } from './mutation-controller';
|
||||
export type {
|
||||
MutationError,
|
||||
MutationIdle,
|
||||
MutationPending,
|
||||
MutationResult,
|
||||
MutationSuccess,
|
||||
} from './mutation-controller';
|
||||
|
||||
export { OptimisticController } from './optimistic-controller';
|
||||
export type {
|
||||
OptimisticError,
|
||||
OptimisticIdle,
|
||||
OptimisticPending,
|
||||
OptimisticResult,
|
||||
OptimisticSuccess,
|
||||
} from './optimistic-controller';
|
||||
|
||||
export { RequestController } from './request-controller';
|
||||
export { SelectorController } from './selector-controller';
|
||||
export { TasksController } from './tasks-controller';
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import type { EnsureFunction } from '@videojs/utils/types';
|
||||
import type { AsyncStatus, Task } from '../../core/queue';
|
||||
import type { AnyStore, InferStoreRequests } from '../../core/store';
|
||||
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
// ----------------------------------------
|
||||
// Mutation Types
|
||||
// ----------------------------------------
|
||||
|
||||
interface MutationBase<Mutate> {
|
||||
status: AsyncStatus;
|
||||
mutate: Mutate;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export interface MutationIdle<Mutate> extends MutationBase<Mutate> {
|
||||
status: 'idle';
|
||||
}
|
||||
|
||||
export interface MutationPending<Mutate> extends MutationBase<Mutate> {
|
||||
status: 'pending';
|
||||
}
|
||||
|
||||
export interface MutationSuccess<Mutate, Data> extends MutationBase<Mutate> {
|
||||
status: 'success';
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export interface MutationError<Mutate> extends MutationBase<Mutate> {
|
||||
status: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export type MutationResult<Mutate, Data>
|
||||
= | MutationIdle<Mutate>
|
||||
| MutationPending<Mutate>
|
||||
| MutationSuccess<Mutate, Data>
|
||||
| MutationError<Mutate>;
|
||||
|
||||
// ----------------------------------------
|
||||
// Controller
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Tracks a mutation's status with discriminated union result.
|
||||
* Triggers host updates when the task status changes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends LitElement {
|
||||
* #playMutation = new MutationController(this, store, 'play');
|
||||
*
|
||||
* render() {
|
||||
* const mutation = this.#playMutation.value;
|
||||
* return html`
|
||||
* <button
|
||||
* @click=${() => mutation.mutate()}
|
||||
* ?disabled=${mutation.status === 'pending'}
|
||||
* >
|
||||
* ...
|
||||
* </button>
|
||||
* `;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class MutationController<
|
||||
Store extends AnyStore,
|
||||
Name extends keyof InferStoreRequests<Store>,
|
||||
Mutate extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
|
||||
> implements ReactiveController {
|
||||
readonly #host: ReactiveControllerHost;
|
||||
readonly #store: Store;
|
||||
readonly #name: Name;
|
||||
|
||||
#task: Task | undefined;
|
||||
#unsubscribe = noop;
|
||||
|
||||
constructor(host: ReactiveControllerHost, store: Store, name: Name) {
|
||||
this.#host = host;
|
||||
this.#store = store;
|
||||
this.#name = name;
|
||||
this.#task = store.queue.tasks[name];
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): MutationResult<Mutate, Awaited<ReturnType<EnsureFunction<Mutate>>>> {
|
||||
const task = this.#task;
|
||||
|
||||
const base = {
|
||||
mutate: this.#store.request[this.#name] as Mutate,
|
||||
reset: this.#reset,
|
||||
};
|
||||
|
||||
if (task?.status === 'success') {
|
||||
return {
|
||||
status: 'success',
|
||||
...base,
|
||||
data: task.output as any,
|
||||
};
|
||||
}
|
||||
|
||||
if (task?.status === 'error') {
|
||||
return {
|
||||
status: 'error',
|
||||
...base,
|
||||
error: task.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: task?.status ?? 'idle',
|
||||
...base,
|
||||
};
|
||||
}
|
||||
|
||||
#reset = () => {
|
||||
this.#store.queue.reset(this.#name);
|
||||
};
|
||||
|
||||
hostConnected() {
|
||||
this.#task = this.#store.queue.tasks[this.#name];
|
||||
|
||||
this.#unsubscribe = this.#store.queue.subscribe((tasks) => {
|
||||
const newTask = tasks[this.#name];
|
||||
if (newTask !== this.#task) {
|
||||
this.#task = newTask;
|
||||
this.#host.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.#unsubscribe();
|
||||
this.#unsubscribe = noop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import type { EnsureFunction } from '@videojs/utils/types';
|
||||
import type { Task } from '../../core/queue';
|
||||
import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store';
|
||||
|
||||
import { Disposer } from '@videojs/utils/events';
|
||||
|
||||
// ----------------------------------------
|
||||
// Optimistic Types
|
||||
// ----------------------------------------
|
||||
|
||||
interface OptimisticBase<Value, SetValue> {
|
||||
value: Value;
|
||||
setValue: SetValue;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export interface OptimisticIdle<Value, SetValue> extends OptimisticBase<Value, SetValue> {
|
||||
status: 'idle';
|
||||
}
|
||||
|
||||
export interface OptimisticPending<Value, SetValue> extends OptimisticBase<Value, SetValue> {
|
||||
status: 'pending';
|
||||
}
|
||||
|
||||
export interface OptimisticSuccess<Value, SetValue> extends OptimisticBase<Value, SetValue> {
|
||||
status: 'success';
|
||||
}
|
||||
|
||||
export interface OptimisticError<Value, SetValue> extends OptimisticBase<Value, SetValue> {
|
||||
status: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export type OptimisticResult<Value, SetValue>
|
||||
= | OptimisticIdle<Value, SetValue>
|
||||
| OptimisticPending<Value, SetValue>
|
||||
| OptimisticSuccess<Value, SetValue>
|
||||
| OptimisticError<Value, SetValue>;
|
||||
|
||||
// ----------------------------------------
|
||||
// Controller
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Shows optimistic value while mutation is pending, actual value otherwise.
|
||||
* When setValue is called, immediately shows the new value while the request
|
||||
* is in flight. Reverts to actual value if the request fails.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class VolumeSlider extends LitElement {
|
||||
* #volume = new OptimisticController(this, store, 'setVolume', s => s.volume);
|
||||
*
|
||||
* render() {
|
||||
* const { value, setValue, status } = this.#volume.value;
|
||||
* return html`
|
||||
* <input
|
||||
* type="range"
|
||||
* .value=${value}
|
||||
* @input=${(e) => setValue(Number(e.target.value))}
|
||||
* style="opacity: ${status === 'pending' ? 0.5 : 1}"
|
||||
* />
|
||||
* `;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class OptimisticController<
|
||||
Store extends AnyStore,
|
||||
Name extends keyof InferStoreRequests<Store>,
|
||||
Value,
|
||||
Request extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
|
||||
> implements ReactiveController {
|
||||
readonly #host: ReactiveControllerHost;
|
||||
readonly #store: Store;
|
||||
readonly #name: Name;
|
||||
readonly #selector: (state: InferStoreState<Store>) => Value;
|
||||
readonly #disposer = new Disposer();
|
||||
|
||||
#optimistic: Value | null = null;
|
||||
#task: Task | undefined;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
store: Store,
|
||||
name: Name,
|
||||
selector: (state: InferStoreState<Store>) => Value,
|
||||
) {
|
||||
this.#host = host;
|
||||
this.#store = store;
|
||||
this.#name = name;
|
||||
this.#selector = selector;
|
||||
this.#task = store.queue.tasks[name];
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): OptimisticResult<Value, (value: Value) => ReturnType<EnsureFunction<Request>>> {
|
||||
const task = this.#task;
|
||||
|
||||
// Show optimistic value when set (cleared on task settlement)
|
||||
const value = this.#optimistic !== null ? this.#optimistic : this.#selector(this.#store.state);
|
||||
const base = {
|
||||
value,
|
||||
setValue: this.#setValue,
|
||||
reset: this.#reset,
|
||||
};
|
||||
|
||||
if (task?.status === 'error') {
|
||||
return {
|
||||
status: 'error',
|
||||
...base,
|
||||
error: task.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: task?.status ?? 'idle',
|
||||
...base,
|
||||
};
|
||||
}
|
||||
|
||||
#setValue = (newValue: Value): ReturnType<EnsureFunction<Request>> => {
|
||||
this.#optimistic = newValue;
|
||||
this.#host.requestUpdate();
|
||||
|
||||
const request = this.#store.request[this.#name] as (value: Value) => ReturnType<EnsureFunction<Request>>;
|
||||
|
||||
return request(newValue);
|
||||
};
|
||||
|
||||
#reset = (): void => {
|
||||
this.#optimistic = null;
|
||||
this.#host.requestUpdate();
|
||||
|
||||
this.#task = this.#store.queue.tasks[this.#name];
|
||||
if (this.#task) this.#store.queue.reset(this.#name);
|
||||
};
|
||||
|
||||
hostConnected() {
|
||||
this.#task = this.#store.queue.tasks[this.#name];
|
||||
|
||||
this.#disposer.add(this.#store.subscribe(this.#selector, () => this.#host.requestUpdate()));
|
||||
|
||||
this.#disposer.add(
|
||||
this.#store.queue.subscribe((tasks) => {
|
||||
const newTask = tasks[this.#name];
|
||||
if (newTask !== this.#task) {
|
||||
this.#task = newTask;
|
||||
|
||||
// Clear optimistic value when task settles
|
||||
if (this.#optimistic !== null && newTask?.status !== 'pending') {
|
||||
this.#optimistic = null;
|
||||
}
|
||||
|
||||
this.#host.requestUpdate();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.#disposer.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import type { AnyStore, InferStoreRequests } from '../../core/store';
|
||||
|
||||
/**
|
||||
* Provides access to a store request by key.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends LitElement {
|
||||
* #play = new RequestController(this, store, 'play');
|
||||
*
|
||||
* render() {
|
||||
* return html`<button @click=${() => this.#play.value()}>Play</button>`;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class RequestController<
|
||||
Store extends AnyStore,
|
||||
Name extends keyof InferStoreRequests<Store>,
|
||||
> implements ReactiveController {
|
||||
readonly #store: Store;
|
||||
readonly #name: Name;
|
||||
|
||||
constructor(host: ReactiveControllerHost, store: Store, name: Name) {
|
||||
this.#store = store;
|
||||
this.#name = name;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): InferStoreRequests<Store>[Name] {
|
||||
return this.#store.request[this.#name] as InferStoreRequests<Store>[Name];
|
||||
}
|
||||
|
||||
// no-op to satisfy `ReactiveController` interface
|
||||
hostConnected() {}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import type { AnyStore, InferStoreState } from '../../core/store';
|
||||
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
/**
|
||||
* Subscribes to a selected portion of store state.
|
||||
* Triggers host updates when the selected value changes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends LitElement {
|
||||
* #paused = new SelectorController(this, store, s => s.paused);
|
||||
*
|
||||
* render() {
|
||||
* return html`<button>${this.#paused.value ? 'Play' : 'Pause'}</button>`;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SelectorController<Store extends AnyStore, Value> implements ReactiveController {
|
||||
readonly #host: ReactiveControllerHost;
|
||||
readonly #store: Store;
|
||||
readonly #selector: (state: InferStoreState<Store>) => Value;
|
||||
|
||||
#value: Value;
|
||||
#unsubscribe = noop;
|
||||
|
||||
constructor(host: ReactiveControllerHost, store: Store, selector: (state: InferStoreState<Store>) => Value) {
|
||||
this.#host = host;
|
||||
this.#store = store;
|
||||
this.#selector = selector;
|
||||
this.#value = selector(store.state);
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): Value {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
// Sync value on reconnect to avoid stale state
|
||||
this.#value = this.#selector(this.#store.state);
|
||||
this.#unsubscribe = this.#store.subscribe(this.#selector, (value) => {
|
||||
this.#value = value;
|
||||
this.#host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.#unsubscribe();
|
||||
this.#unsubscribe = noop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import type { AnyStore } from '../../core/store';
|
||||
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
/**
|
||||
* Subscribes to task state changes.
|
||||
*
|
||||
* Triggers host updates when tasks change.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends LitElement {
|
||||
* #tasks = new TasksController(this, store);
|
||||
*
|
||||
* render() {
|
||||
* const playTask = this.#tasks.value.play;
|
||||
* const isPending = playTask?.status === 'pending';
|
||||
* return html`<button ?disabled=${isPending}>Play</button>`;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class TasksController<Store extends AnyStore> implements ReactiveController {
|
||||
readonly #host: ReactiveControllerHost;
|
||||
readonly #store: Store;
|
||||
|
||||
#value: Store['queue']['tasks'];
|
||||
#unsubscribe = noop;
|
||||
|
||||
constructor(host: ReactiveControllerHost, store: Store) {
|
||||
this.#host = host;
|
||||
this.#store = store;
|
||||
this.#value = store.queue.tasks;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): Store['queue']['tasks'] {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
// Sync value on reconnect to avoid stale state
|
||||
this.#value = this.#store.queue.tasks;
|
||||
this.#unsubscribe = this.#store.queue.subscribe((tasks) => {
|
||||
this.#value = tasks;
|
||||
this.#host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.#unsubscribe();
|
||||
this.#unsubscribe = noop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSlice } from '../../../core/slice';
|
||||
import { createStore as createCoreStore } from '../../../core/store';
|
||||
import { createCoreTestStore, createCustomKeyTestStore, createMockHost, MockMedia } from '../../tests/test-utils';
|
||||
import { MutationController } from '../mutation-controller';
|
||||
|
||||
describe('MutationController', () => {
|
||||
it('returns mutation result with idle status initially', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expect(controller.value.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('registers with host', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expect(host.controllers.has(controller)).toBe(true);
|
||||
});
|
||||
|
||||
it('provides mutate function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expect(typeof controller.value.mutate).toBe('function');
|
||||
});
|
||||
|
||||
it('tracks success state with data', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
controller.hostConnected();
|
||||
|
||||
await controller.value.mutate(0.7);
|
||||
|
||||
expect(controller.value.status).toBe('success');
|
||||
if (controller.value.status === 'success') {
|
||||
expect(controller.value.data).toBe(0.7);
|
||||
}
|
||||
expect(host.updateCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reset clears settled state', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
controller.hostConnected();
|
||||
|
||||
await controller.value.mutate(0.5);
|
||||
expect(controller.value.status).toBe('success');
|
||||
|
||||
controller.value.reset();
|
||||
|
||||
expect(controller.value.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('unsubscribes on hostDisconnected', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
controller.hostConnected();
|
||||
controller.hostDisconnected();
|
||||
|
||||
const updateCountBefore = host.updateCount;
|
||||
await store.request.setVolume!(0.5);
|
||||
|
||||
expect(host.updateCount).toBe(updateCountBefore);
|
||||
});
|
||||
|
||||
it('tracks error with error object', async () => {
|
||||
const host = createMockHost();
|
||||
|
||||
// Create a slice with a failing request for testing
|
||||
const failingSlice = createSlice<MockMedia>()({
|
||||
initialState: { volume: 1, muted: false },
|
||||
getSnapshot: ({ target }) => ({
|
||||
volume: target.volume,
|
||||
muted: target.muted,
|
||||
}),
|
||||
subscribe: () => {},
|
||||
request: {
|
||||
failingRequest: async () => {
|
||||
throw new Error('Test error');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const failingStore = createCoreStore({
|
||||
slices: [failingSlice],
|
||||
onError: noop,
|
||||
});
|
||||
|
||||
const target = new MockMedia();
|
||||
failingStore.attach(target);
|
||||
|
||||
const controller = new MutationController(host, failingStore, 'failingRequest');
|
||||
controller.hostConnected();
|
||||
|
||||
try {
|
||||
await controller.value.mutate();
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(controller.value.status).toBe('error');
|
||||
if (controller.value.status === 'error') {
|
||||
expect(controller.value.error).toBeInstanceOf(Error);
|
||||
expect((controller.value.error as Error).message).toBe('Test error');
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks pending status while mutation is in flight', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'slowSetVolume');
|
||||
controller.hostConnected();
|
||||
|
||||
const promise = controller.value.mutate(0.5);
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(controller.value.status).toBe('pending');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(controller.value.status).toBe('success');
|
||||
});
|
||||
|
||||
it('syncs state on reconnect after disconnect', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
controller.hostConnected();
|
||||
|
||||
await controller.value.mutate(0.5);
|
||||
expect(controller.value.status).toBe('success');
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
// Trigger another mutation while disconnected
|
||||
await store.request.setVolume!(0.8);
|
||||
|
||||
// Reconnect - should sync to current task state
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.value.status).toBe('success');
|
||||
});
|
||||
|
||||
describe('custom key (name !== key)', () => {
|
||||
it('tracks task by name when key differs', async () => {
|
||||
const { store } = createCustomKeyTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
// adjustVolume has name='adjustVolume' but key='audio-settings'
|
||||
const controller = new MutationController(host, store, 'adjustVolume');
|
||||
controller.hostConnected();
|
||||
|
||||
const promise = controller.value.mutate(0.5);
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
expect(controller.value.status).toBe('pending');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(controller.value.status).toBe('success');
|
||||
if (controller.value.status === 'success') {
|
||||
expect(controller.value.data).toBe(0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks correct task when multiple requests share same key', async () => {
|
||||
const { store } = createCustomKeyTestStore();
|
||||
const hostVolume = createMockHost();
|
||||
const hostMute = createMockHost();
|
||||
|
||||
// Both adjustVolume and toggleMute have key='audio-settings'
|
||||
const volumeController = new MutationController(hostVolume, store, 'adjustVolume');
|
||||
const muteController = new MutationController(hostMute, store, 'toggleMute');
|
||||
volumeController.hostConnected();
|
||||
muteController.hostConnected();
|
||||
|
||||
// Start volume adjustment
|
||||
const volumePromise = volumeController.value.mutate(0.5);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
// Volume controller should be pending
|
||||
expect(volumeController.value.status).toBe('pending');
|
||||
// Mute controller should be idle (different name, even though same key)
|
||||
expect(muteController.value.status).toBe('idle');
|
||||
|
||||
await volumePromise;
|
||||
|
||||
expect(volumeController.value.status).toBe('success');
|
||||
expect(muteController.value.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('superseded task shows error status', async () => {
|
||||
const { store } = createCustomKeyTestStore();
|
||||
const hostVolume = createMockHost();
|
||||
const hostMute = createMockHost();
|
||||
|
||||
const volumeController = new MutationController(hostVolume, store, 'adjustVolume');
|
||||
const muteController = new MutationController(hostMute, store, 'toggleMute');
|
||||
volumeController.hostConnected();
|
||||
muteController.hostConnected();
|
||||
|
||||
// Start volume adjustment
|
||||
const volumePromise = volumeController.value.mutate(0.5);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
// Start mute toggle - this will supersede volume because same key
|
||||
const mutePromise = muteController.value.mutate(true);
|
||||
|
||||
// Wait for superseding to happen
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
// Volume task was superseded
|
||||
try {
|
||||
await volumePromise;
|
||||
} catch {
|
||||
// Expected - task was superseded
|
||||
}
|
||||
|
||||
// Tasks are keyed by name, so superseded task shows error status
|
||||
expect(volumeController.value.status).toBe('error');
|
||||
expect(muteController.value.status).toBe('pending');
|
||||
|
||||
await mutePromise;
|
||||
|
||||
expect(muteController.value.status).toBe('success');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSlice } from '../../../core/slice';
|
||||
import { createStore as createCoreStore } from '../../../core/store';
|
||||
import { createCoreTestStore, createCustomKeyTestStore, createMockHost, MockMedia } from '../../tests/test-utils';
|
||||
import { OptimisticController } from '../optimistic-controller';
|
||||
|
||||
describe('OptimisticController', () => {
|
||||
it('returns actual value initially', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expect(controller.value.value).toBe(1);
|
||||
expect(controller.value.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('registers with host', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expect(host.controllers.has(controller)).toBe(true);
|
||||
});
|
||||
|
||||
it('provides setValue function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expect(typeof controller.value.setValue).toBe('function');
|
||||
});
|
||||
|
||||
it('updates actual value after mutation completes', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
await controller.value.setValue(0.3);
|
||||
|
||||
expect(controller.value.value).toBe(0.3);
|
||||
expect(controller.value.status).toBe('success');
|
||||
});
|
||||
|
||||
it('reset clears error state', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
await controller.value.setValue(0.5);
|
||||
|
||||
controller.value.reset();
|
||||
|
||||
expect(controller.value.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('triggers host update when state changes', async () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
target.volume = 0.8;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
expect(host.updateCount).toBeGreaterThan(0);
|
||||
expect(controller.value.value).toBe(0.8);
|
||||
});
|
||||
|
||||
it('unsubscribes on hostDisconnected', async () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
controller.hostDisconnected();
|
||||
|
||||
const updateCountBefore = host.updateCount;
|
||||
target.volume = 0.2;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
expect(host.updateCount).toBe(updateCountBefore);
|
||||
});
|
||||
|
||||
it('shows optimistic value immediately while pending', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'slowSetVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
const promise = controller.value.setValue(0.3);
|
||||
|
||||
// Optimistic value shown immediately
|
||||
expect(controller.value.value).toBe(0.3);
|
||||
expect(host.updateCount).toBeGreaterThan(0);
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(controller.value.status).toBe('pending');
|
||||
expect(controller.value.value).toBe(0.3);
|
||||
|
||||
await promise;
|
||||
|
||||
// After completion, shows actual value
|
||||
expect(controller.value.status).toBe('success');
|
||||
expect(controller.value.value).toBe(0.3);
|
||||
});
|
||||
|
||||
it('reverts to actual value on error', async () => {
|
||||
const host = createMockHost();
|
||||
|
||||
const failingSlice = createSlice<MockMedia>()({
|
||||
initialState: { volume: 1, muted: false },
|
||||
getSnapshot: ({ target }) => ({
|
||||
volume: target.volume,
|
||||
muted: target.muted,
|
||||
}),
|
||||
subscribe: () => {},
|
||||
request: {
|
||||
failingSetVolume: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
throw new Error('Test error');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const failingStore = createCoreStore({
|
||||
slices: [failingSlice],
|
||||
onError: noop,
|
||||
});
|
||||
|
||||
const target = new MockMedia();
|
||||
failingStore.attach(target);
|
||||
|
||||
const controller = new OptimisticController(host, failingStore, 'failingSetVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
const promise = controller.value.setValue(0.5);
|
||||
|
||||
// Optimistic value shown immediately
|
||||
expect(controller.value.value).toBe(0.5);
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// After error, shows actual value (reverted)
|
||||
expect(controller.value.status).toBe('error');
|
||||
expect(controller.value.value).toBe(1); // Original value
|
||||
if (controller.value.status === 'error') {
|
||||
expect(controller.value.error).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles rapid setValue calls (superseding)', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'slowSetVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
// Fire multiple rapid calls
|
||||
const promise1 = controller.value.setValue(0.3);
|
||||
const promise2 = controller.value.setValue(0.5);
|
||||
const promise3 = controller.value.setValue(0.7);
|
||||
|
||||
// Should show latest optimistic value
|
||||
expect(controller.value.value).toBe(0.7);
|
||||
|
||||
// First two get superseded
|
||||
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
|
||||
await expect(promise2).rejects.toMatchObject({ code: 'SUPERSEDED' });
|
||||
await promise3;
|
||||
|
||||
expect(controller.value.status).toBe('success');
|
||||
expect(controller.value.value).toBe(0.7);
|
||||
});
|
||||
|
||||
it('reset when already idle is safe', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.value.status).toBe('idle');
|
||||
|
||||
// Reset when idle should not throw
|
||||
controller.value.reset();
|
||||
|
||||
expect(controller.value.status).toBe('idle');
|
||||
expect(controller.value.value).toBe(1);
|
||||
});
|
||||
|
||||
describe('custom key (name !== key)', () => {
|
||||
it('tracks task by name when key differs', async () => {
|
||||
const { store } = createCustomKeyTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
// adjustVolume has name='adjustVolume' but key='audio-settings'
|
||||
const controller = new OptimisticController(host, store, 'adjustVolume', s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
const promise = controller.value.setValue(0.5);
|
||||
|
||||
// Optimistic value shown immediately
|
||||
expect(controller.value.value).toBe(0.5);
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
expect(controller.value.status).toBe('pending');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(controller.value.status).toBe('success');
|
||||
expect(controller.value.value).toBe(0.5);
|
||||
});
|
||||
|
||||
it('tracks correct task when multiple requests share same key', async () => {
|
||||
const { store } = createCustomKeyTestStore();
|
||||
const hostVolume = createMockHost();
|
||||
const hostMute = createMockHost();
|
||||
|
||||
// Both adjustVolume and toggleMute have key='audio-settings'
|
||||
const volumeController = new OptimisticController(hostVolume, store, 'adjustVolume', s => s.volume);
|
||||
const muteController = new OptimisticController(hostMute, store, 'toggleMute', s => s.muted);
|
||||
volumeController.hostConnected();
|
||||
muteController.hostConnected();
|
||||
|
||||
// Start volume adjustment
|
||||
const volumePromise = volumeController.value.setValue(0.5);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
// Volume controller should be pending
|
||||
expect(volumeController.value.status).toBe('pending');
|
||||
expect(volumeController.value.value).toBe(0.5); // Optimistic
|
||||
|
||||
// Mute controller should be idle (different name, even though same key)
|
||||
expect(muteController.value.status).toBe('idle');
|
||||
expect(muteController.value.value).toBe(false); // Actual
|
||||
|
||||
await volumePromise;
|
||||
|
||||
expect(volumeController.value.status).toBe('success');
|
||||
expect(muteController.value.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('superseded task shows error status', async () => {
|
||||
const { store } = createCustomKeyTestStore();
|
||||
const hostVolume = createMockHost();
|
||||
const hostMute = createMockHost();
|
||||
|
||||
const volumeController = new OptimisticController(hostVolume, store, 'adjustVolume', s => s.volume);
|
||||
const muteController = new OptimisticController(hostMute, store, 'toggleMute', s => s.muted);
|
||||
volumeController.hostConnected();
|
||||
muteController.hostConnected();
|
||||
|
||||
// Start volume adjustment
|
||||
const volumePromise = volumeController.value.setValue(0.5);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
// Start mute toggle - this will supersede volume because same key
|
||||
const mutePromise = muteController.value.setValue(true);
|
||||
|
||||
// Mute shows optimistic immediately
|
||||
expect(muteController.value.value).toBe(true);
|
||||
|
||||
// Volume task was superseded
|
||||
try {
|
||||
await volumePromise;
|
||||
} catch {
|
||||
// Expected - task was superseded
|
||||
}
|
||||
|
||||
// Wait for subscription callbacks to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Tasks are keyed by name, so superseded task shows error status
|
||||
expect(volumeController.value.status).toBe('error');
|
||||
|
||||
// Complete the mute operation
|
||||
await mutePromise;
|
||||
|
||||
expect(muteController.value.status).toBe('success');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
|
||||
import { RequestController } from '../request-controller';
|
||||
|
||||
describe('RequestController', () => {
|
||||
it('returns request function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new RequestController(host, store, 'setVolume');
|
||||
|
||||
expect(typeof controller.value).toBe('function');
|
||||
});
|
||||
|
||||
it('registers with host', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new RequestController(host, store, 'setVolume');
|
||||
|
||||
expect(host.controllers.has(controller)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns stable reference', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new RequestController(host, store, 'setVolume');
|
||||
const first = controller.value;
|
||||
const second = controller.value;
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('request works correctly', async () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new RequestController(host, store, 'setVolume');
|
||||
await controller.value(0.7);
|
||||
|
||||
expect(target.volume).toBe(0.7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
|
||||
import { SelectorController } from '../selector-controller';
|
||||
|
||||
describe('SelectorController', () => {
|
||||
it('returns selected state', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
|
||||
expect(controller.value).toBe(1);
|
||||
});
|
||||
|
||||
it('registers with host', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
|
||||
expect(host.controllers.has(controller)).toBe(true);
|
||||
});
|
||||
|
||||
it('subscribes on hostConnected', () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
target.volume = 0.5;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
expect(controller.value).toBe(0.5);
|
||||
expect(host.updateCount).toBe(1);
|
||||
});
|
||||
|
||||
it('unsubscribes on hostDisconnected', () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
controller.hostConnected();
|
||||
controller.hostDisconnected();
|
||||
|
||||
const updateCountBefore = host.updateCount;
|
||||
target.volume = 0.3;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
expect(host.updateCount).toBe(updateCountBefore);
|
||||
});
|
||||
|
||||
it('syncs value on reconnect after state changed while disconnected', () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.value).toBe(1);
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
target.volume = 0.3;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
// Value should still be stale (not subscribed)
|
||||
expect(controller.value).toBe(1);
|
||||
|
||||
// Reconnect - should have current value
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.value).toBe(0.3);
|
||||
});
|
||||
|
||||
it('does not trigger update when unrelated state changes', () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
controller.hostConnected();
|
||||
|
||||
target.muted = true;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
// Volume didn't change, so no update should be triggered
|
||||
expect(host.updateCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles multiple reconnect cycles', () => {
|
||||
const { store, target } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
|
||||
// First connect/disconnect
|
||||
controller.hostConnected();
|
||||
target.volume = 0.5;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
expect(controller.value).toBe(0.5);
|
||||
controller.hostDisconnected();
|
||||
|
||||
// Change while disconnected
|
||||
target.volume = 0.3;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
// Second connect - should sync to current value
|
||||
controller.hostConnected();
|
||||
expect(controller.value).toBe(0.3);
|
||||
|
||||
// Changes should work again
|
||||
target.volume = 0.8;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
expect(controller.value).toBe(0.8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
|
||||
import { TasksController } from '../tasks-controller';
|
||||
|
||||
describe('TasksController', () => {
|
||||
it('returns tasks record', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
|
||||
expect(controller.value).toEqual({});
|
||||
});
|
||||
|
||||
it('registers with host', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
|
||||
expect(host.controllers.has(controller)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates when task completes', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.value.setVolume).toBeUndefined();
|
||||
|
||||
await store.request.setVolume!(0.5);
|
||||
|
||||
expect(controller.value.setVolume).toBeDefined();
|
||||
expect(controller.value.setVolume?.status).toBe('success');
|
||||
expect(host.updateCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('unsubscribes on hostDisconnected', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
controller.hostConnected();
|
||||
controller.hostDisconnected();
|
||||
|
||||
const updateCountBefore = host.updateCount;
|
||||
await store.request.setVolume!(0.5);
|
||||
|
||||
expect(host.updateCount).toBe(updateCountBefore);
|
||||
});
|
||||
|
||||
it('handles multiple task updates', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
controller.hostConnected();
|
||||
|
||||
// Fire multiple requests
|
||||
await store.request.setVolume!(0.5);
|
||||
await store.request.setMuted!(true);
|
||||
|
||||
// Both tasks should be tracked
|
||||
expect(controller.value.setVolume).toBeDefined();
|
||||
expect(controller.value.setMuted).toBeDefined();
|
||||
expect(controller.value.setVolume?.status).toBe('success');
|
||||
expect(controller.value.setMuted?.status).toBe('success');
|
||||
});
|
||||
|
||||
it('syncs to current tasks on reconnect', async () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
controller.hostConnected();
|
||||
|
||||
await store.request.setVolume!(0.5);
|
||||
expect(controller.value.setVolume?.status).toBe('success');
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
// Trigger another task while disconnected
|
||||
await store.request.setMuted!(true);
|
||||
|
||||
// Reconnect - should sync to current tasks
|
||||
controller.hostConnected();
|
||||
|
||||
expect(controller.value.setVolume?.status).toBe('success');
|
||||
expect(controller.value.setMuted?.status).toBe('success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { MutationResult, OptimisticResult } from '../index';
|
||||
|
||||
import { describe, expectTypeOf, it } from 'vitest';
|
||||
|
||||
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
|
||||
import { MutationController } from '../mutation-controller';
|
||||
import { OptimisticController } from '../optimistic-controller';
|
||||
import { RequestController } from '../request-controller';
|
||||
import { SelectorController } from '../selector-controller';
|
||||
import { TasksController } from '../tasks-controller';
|
||||
|
||||
describe('controller types', () => {
|
||||
describe('SelectorController', () => {
|
||||
it('value has selected type', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => s.volume);
|
||||
|
||||
expectTypeOf(controller.value).toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it('value type matches selector return type', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new SelectorController(host, store, s => ({
|
||||
volume: s.volume,
|
||||
muted: s.muted,
|
||||
}));
|
||||
|
||||
expectTypeOf(controller.value).toEqualTypeOf<{ volume: number; muted: boolean }>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestController', () => {
|
||||
it('value is the request function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new RequestController(host, store, 'setVolume');
|
||||
|
||||
expectTypeOf(controller.value).toBeFunction();
|
||||
expectTypeOf(controller.value).parameter(0).toEqualTypeOf<number>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MutationController', () => {
|
||||
it('value is MutationResult', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expectTypeOf(controller.value).toExtend<MutationResult<unknown, unknown>>();
|
||||
});
|
||||
|
||||
it('value has mutate function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expectTypeOf(controller.value.mutate).toBeFunction();
|
||||
});
|
||||
|
||||
it('value has reset function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expectTypeOf(controller.value.reset).toEqualTypeOf<() => void>();
|
||||
});
|
||||
|
||||
it('value has status property', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new MutationController(host, store, 'setVolume');
|
||||
|
||||
expectTypeOf(controller.value.status).toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OptimisticController', () => {
|
||||
it('value is OptimisticResult', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expectTypeOf(controller.value).toExtend<OptimisticResult<unknown, unknown>>();
|
||||
});
|
||||
|
||||
it('value has selected type', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expectTypeOf(controller.value.value).toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it('value has setValue function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expectTypeOf(controller.value.setValue).toBeFunction();
|
||||
});
|
||||
|
||||
it('value has reset function', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expectTypeOf(controller.value.reset).toEqualTypeOf<() => void>();
|
||||
});
|
||||
|
||||
it('value has status property', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
|
||||
|
||||
expectTypeOf(controller.value.status).toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TasksController', () => {
|
||||
it('value is tasks record', () => {
|
||||
const { store } = createCoreTestStore();
|
||||
const host = createMockHost();
|
||||
|
||||
const controller = new TasksController(host, store);
|
||||
|
||||
expectTypeOf(controller.value).toEqualTypeOf<typeof store.queue.tasks>();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Context } from '@lit/context';
|
||||
import type { ReactiveElement } from '@lit/reactive-element';
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
import type { AnySlice, UnionSliceTarget } from '../core/slice';
|
||||
|
||||
import type { StoreConfig, StoreConsumer, StoreProvider } from '../core/store';
|
||||
|
||||
import { createContext } from '@lit/context';
|
||||
import { Store } from '../core/store';
|
||||
import { createStoreAttachMixin, createStoreMixin, createStoreProviderMixin } from './mixins';
|
||||
|
||||
export const contextKey = Symbol('@videojs/store');
|
||||
|
||||
export interface CreateStoreConfig<Slices extends AnySlice[]> extends StoreConfig<UnionSliceTarget<Slices>, Slices> {}
|
||||
|
||||
export interface CreateStoreResult<Slices extends AnySlice[]> {
|
||||
/**
|
||||
* Combined mixin: provides store via context AND auto-attaches slotted media.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyPlayer extends StoreMixin(LitElement) {}
|
||||
* ```
|
||||
*/
|
||||
StoreMixin: <T extends Constructor<ReactiveElement>>(Base: T) => T & Constructor<StoreProvider<Slices>>;
|
||||
|
||||
/**
|
||||
* Mixin that provides store via context (no auto-attach).
|
||||
*
|
||||
* Use when you need granular control over store provisioning.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyProvider extends StoreProviderMixin(LitElement) {}
|
||||
* ```
|
||||
*/
|
||||
StoreProviderMixin: <T extends Constructor<ReactiveElement>>(Base: T) => T & Constructor<StoreProvider<Slices>>;
|
||||
|
||||
/**
|
||||
* Mixin that auto-attaches slotted media elements (requires store from context).
|
||||
*
|
||||
* Use when inheriting store from a parent provider.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyControls extends StoreAttachMixin(LitElement) {}
|
||||
* ```
|
||||
*/
|
||||
StoreAttachMixin: <T extends Constructor<ReactiveElement>>(Base: T) => T & Constructor<StoreConsumer<Slices>>;
|
||||
|
||||
/**
|
||||
* Context for consuming store in controllers.
|
||||
*
|
||||
* Use this with Lit's `ContextConsumer` or the `@consume` decorator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends LitElement {
|
||||
* @consume({ context, subscribe: true })
|
||||
* readonly store!: ContextType<typeof context>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
context: Context<typeof contextKey, Store<UnionSliceTarget<Slices>, Slices>>;
|
||||
|
||||
/**
|
||||
* Creates a store instance for imperative access.
|
||||
*
|
||||
* Useful for creating a store before rendering or for testing.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const store = create();
|
||||
* store.attach(videoElement);
|
||||
* ```
|
||||
*/
|
||||
create: () => Store<UnionSliceTarget<Slices>, Slices>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a store factory that returns mixins, context, and a create function.
|
||||
*
|
||||
* @param config - Store configuration including slices and optional lifecycle hooks
|
||||
* @returns An object containing mixins, context, and create function
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createStore } from '@videojs/store/lit';
|
||||
* import { playbackSlice } from '@videojs/core/dom';
|
||||
*
|
||||
* const { StoreMixin } = createStore({
|
||||
* slices: [playbackSlice],
|
||||
* });
|
||||
*
|
||||
* // Create a player element with store
|
||||
* class MyPlayer extends StoreMixin(LitElement) {}
|
||||
*
|
||||
* customElements.define('my-player', MyPlayer);
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <my-player>
|
||||
* <video src="video.mp4"></video>
|
||||
* </my-player>
|
||||
* ```
|
||||
*/
|
||||
export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig<Slices>): CreateStoreResult<Slices> {
|
||||
type Target = UnionSliceTarget<Slices>;
|
||||
type ProvidedStore = Store<Target, Slices>;
|
||||
|
||||
const context = createContext<ProvidedStore, typeof contextKey>(contextKey);
|
||||
|
||||
function create(): ProvidedStore {
|
||||
return new Store(config);
|
||||
}
|
||||
|
||||
const StoreProviderMixin = createStoreProviderMixin<Slices>(
|
||||
context,
|
||||
create,
|
||||
);
|
||||
|
||||
const StoreAttachMixin = createStoreAttachMixin<Slices>(context);
|
||||
|
||||
const StoreMixin = createStoreMixin<Slices>(context, create);
|
||||
|
||||
return {
|
||||
StoreMixin,
|
||||
StoreProviderMixin,
|
||||
StoreAttachMixin,
|
||||
context,
|
||||
create,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Controllers
|
||||
export {
|
||||
MutationController,
|
||||
OptimisticController,
|
||||
RequestController,
|
||||
SelectorController,
|
||||
TasksController,
|
||||
} from './controllers';
|
||||
export type {
|
||||
AsyncStatus,
|
||||
MutationResult,
|
||||
OptimisticResult,
|
||||
} from './controllers';
|
||||
|
||||
// createStore factory
|
||||
export { createStore } from './create-store';
|
||||
export type { contextKey, CreateStoreConfig, CreateStoreResult } from './create-store';
|
||||
|
||||
// Mixin factories (for advanced use cases)
|
||||
export { createStoreAttachMixin, createStoreMixin, createStoreProviderMixin } from './mixins';
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Context } from '@lit/context';
|
||||
import type { ReactiveElement } from '@lit/reactive-element';
|
||||
import type { Constructor, Mixin } from '@videojs/utils/types';
|
||||
import type { AnySlice, UnionSliceTarget } from '../../core/slice';
|
||||
import type { Store, StoreConsumer } from '../../core/store';
|
||||
|
||||
import { ContextConsumer } from '@lit/context';
|
||||
import { getSlottedElement, isHTMLMediaElement, listen, querySlot } from '@videojs/utils/dom';
|
||||
import { Disposer } from '@videojs/utils/events';
|
||||
import { noop } from '@videojs/utils/function';
|
||||
import { isNull } from '@videojs/utils/predicate';
|
||||
|
||||
/**
|
||||
* Creates a mixin that consumes a store from context and auto-attaches media elements.
|
||||
*
|
||||
* - Requests store from context (must have a provider ancestor)
|
||||
* - Observes slotted elements for `<video slot="media">` or `<audio slot="media">`
|
||||
* - Falls back to light DOM children if no shadow root
|
||||
* - Calls `store.attach(mediaElement)` when found
|
||||
* - Cleans up on disconnect
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { StoreAttachMixin } = createStore({ slices: [playbackSlice] });
|
||||
*
|
||||
* class MyControls extends StoreAttachMixin(LitElement) {}
|
||||
* ```
|
||||
*/
|
||||
export function createStoreAttachMixin<Slices extends AnySlice[]>(
|
||||
context: Context<unknown, Store<UnionSliceTarget<Slices>, Slices>>,
|
||||
): Mixin<ReactiveElement, StoreConsumer<Slices>> {
|
||||
type ConsumedStore = Store<UnionSliceTarget<Slices>, Slices>;
|
||||
|
||||
return <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => {
|
||||
class StoreAttachElement extends BaseClass implements StoreConsumer<Slices> {
|
||||
#disposer = new Disposer();
|
||||
#detach = noop;
|
||||
|
||||
#consumer = new ContextConsumer(this, {
|
||||
context,
|
||||
callback: () => this.#attachMedia(),
|
||||
subscribe: false,
|
||||
});
|
||||
|
||||
get store(): ConsumedStore | null {
|
||||
return this.#consumer.value ?? null;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const shadow = this.shadowRoot;
|
||||
if (shadow) {
|
||||
const slot = querySlot(shadow, 'media');
|
||||
if (slot) this.#disposer.add(listen(slot, 'slotchange', () => this.#attachMedia()));
|
||||
}
|
||||
|
||||
this.#attachMedia();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.#disposer.dispose();
|
||||
this.#detach();
|
||||
}
|
||||
|
||||
#attachMedia() {
|
||||
const store = this.store;
|
||||
|
||||
if (isNull(store)) return;
|
||||
|
||||
// Check if element is media, or search inside for nested media
|
||||
const findMedia = (el: Element): HTMLMediaElement | null =>
|
||||
isHTMLMediaElement(el) ? el : el.querySelector('video, audio');
|
||||
|
||||
const media = this.shadowRoot
|
||||
? getSlottedElement(this.shadowRoot, 'media', findMedia)
|
||||
: this.querySelector('video, audio');
|
||||
|
||||
if (store.target !== media) {
|
||||
this.#detach();
|
||||
this.#detach = store.attach(media as UnionSliceTarget<Slices>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StoreAttachElement;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Context } from '@lit/context';
|
||||
import type { ReactiveElement } from '@lit/reactive-element';
|
||||
import type { Constructor, Mixin } from '@videojs/utils/types';
|
||||
import type { AnySlice, UnionSliceTarget } from '../../core/slice';
|
||||
|
||||
import type { Store, StoreProvider } from '../../core/store';
|
||||
|
||||
import { createStoreAttachMixin } from './attach-mixin';
|
||||
import { createStoreProviderMixin } from './provider-mixin';
|
||||
|
||||
/**
|
||||
* Creates a combined mixin that both provides a store and auto-attaches media elements.
|
||||
*
|
||||
* Composes `StoreProviderMixin` and `StoreAttachMixin` - the provider mixin provides the store
|
||||
* via context, and the attach mixin consumes it and auto-attaches media elements.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { StoreMixin } = createStore({ slices: [playbackSlice] });
|
||||
*
|
||||
* class MyPlayer extends StoreMixin(LitElement) {
|
||||
* render() {
|
||||
* return html`<slot></slot>`;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createStoreMixin<Slices extends AnySlice[]>(
|
||||
context: Context<unknown, Store<UnionSliceTarget<Slices>, Slices>>,
|
||||
factory: () => Store<UnionSliceTarget<Slices>, Slices>,
|
||||
): Mixin<ReactiveElement, StoreProvider<Slices>> {
|
||||
const ProviderMixin = createStoreProviderMixin<Slices>(context, factory);
|
||||
const AttachMixin = createStoreAttachMixin<Slices>(context);
|
||||
|
||||
return <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => {
|
||||
// ProviderMixin wraps AttachMixin so during connectedCallback:
|
||||
// 1. ProviderMixin runs first (provides store via context)
|
||||
// 2. AttachMixin runs second (consumes store from context)
|
||||
return ProviderMixin(AttachMixin(BaseClass));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createStoreAttachMixin } from './attach-mixin';
|
||||
export { createStoreMixin } from './combined-mixin';
|
||||
export { createStoreProviderMixin } from './provider-mixin';
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Context } from '@lit/context';
|
||||
import type { ReactiveElement } from '@lit/reactive-element';
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
|
||||
import type { AnySlice, UnionSliceTarget } from '../../core/slice';
|
||||
import type { Store, StoreProvider } from '../../core/store';
|
||||
import { ContextProvider } from '@lit/context';
|
||||
import { isNull } from '@videojs/utils/predicate';
|
||||
|
||||
/**
|
||||
* Creates a mixin that provides a store via context.
|
||||
*
|
||||
* - Creates a store instance on first access
|
||||
* - Provides the store to descendants via Lit Context Protocol
|
||||
* - Allows store replacement via setter (notifies all consumers)
|
||||
* - Destroys the store on disconnect (if not externally provided)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { StoreProviderMixin } = createStore({
|
||||
* slices: [playbackSlice]
|
||||
* });
|
||||
*
|
||||
* class MyPlayer extends StoreProviderMixin(LitElement) {
|
||||
* render() {
|
||||
* return html`<slot></slot>`;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createStoreProviderMixin<Slices extends AnySlice[]>(
|
||||
context: Context<unknown, Store<UnionSliceTarget<Slices>, Slices>>,
|
||||
factory: () => Store<UnionSliceTarget<Slices>, Slices>,
|
||||
): <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => Base & Constructor<StoreProvider<Slices>> {
|
||||
type ProvidedStore = Store<UnionSliceTarget<Slices>, Slices>;
|
||||
|
||||
return <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => {
|
||||
class StoreProviderElement extends BaseClass implements StoreProvider<Slices> {
|
||||
#store: ProvidedStore | null = null;
|
||||
#isOwner = false;
|
||||
|
||||
#provider = new ContextProvider(this, {
|
||||
context,
|
||||
initialValue: this.store,
|
||||
});
|
||||
|
||||
get store(): ProvidedStore {
|
||||
if (isNull(this.#store)) {
|
||||
this.#store = factory();
|
||||
this.#isOwner = true;
|
||||
}
|
||||
|
||||
return this.#store;
|
||||
}
|
||||
|
||||
set store(newStore: ProvidedStore) {
|
||||
const wasOwner = this.#isOwner;
|
||||
const oldStore = this.#store;
|
||||
|
||||
this.#store = newStore;
|
||||
this.#isOwner = false;
|
||||
|
||||
if (wasOwner && oldStore && oldStore !== newStore) {
|
||||
oldStore.destroy();
|
||||
}
|
||||
|
||||
this.#provider.setValue(newStore);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.#isOwner && this.#store) {
|
||||
this.#store.destroy();
|
||||
this.#store = null;
|
||||
this.#isOwner = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StoreProviderElement;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils';
|
||||
|
||||
setupDomCleanup();
|
||||
|
||||
describe('createStoreAttachMixin', () => {
|
||||
it('exposes store property (initially null without context)', async () => {
|
||||
const { StoreAttachMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-attach-standalone');
|
||||
|
||||
class TestElement extends StoreAttachMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
// Without a context provider ancestor, store is null
|
||||
expect(el.store).toBeNull();
|
||||
});
|
||||
|
||||
it('can be applied to TestBaseElement', () => {
|
||||
const { StoreAttachMixin } = createLitTestStore();
|
||||
|
||||
class MixedElement extends StoreAttachMixin(TestBaseElement) {}
|
||||
|
||||
expect(MixedElement.prototype).toBeInstanceOf(TestBaseElement);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils';
|
||||
|
||||
setupDomCleanup();
|
||||
|
||||
// Helper to create shadow root with named media slot
|
||||
function createShadowWithSlot(el: HTMLElement): void {
|
||||
const shadow = el.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = '<slot name="media"></slot>';
|
||||
}
|
||||
|
||||
describe('createStoreMixin', () => {
|
||||
it('provides store and attaches media', async () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-combined');
|
||||
|
||||
class TestElement extends StoreMixin(TestBaseElement) {
|
||||
override createRenderRoot() {
|
||||
createShadowWithSlot(this);
|
||||
return this.shadowRoot!;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.store).toBeDefined();
|
||||
expect(el.store.state).toEqual({ volume: 1, muted: false });
|
||||
});
|
||||
|
||||
it('auto-attaches slotted video element', async () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-auto-attach');
|
||||
|
||||
class TestElement extends StoreMixin(TestBaseElement) {
|
||||
override createRenderRoot() {
|
||||
createShadowWithSlot(this);
|
||||
return this.shadowRoot!;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
const video = document.createElement('video');
|
||||
video.slot = 'media';
|
||||
el.appendChild(video);
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
// Wait for slotchange
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
expect(el.store.target).toBe(video);
|
||||
});
|
||||
|
||||
it('auto-attaches light DOM video when no shadow root', async () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-light-dom');
|
||||
|
||||
class TestElement extends StoreMixin(TestBaseElement) {
|
||||
override createRenderRoot() {
|
||||
return this; // Use light DOM
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
const video = document.createElement('video');
|
||||
el.appendChild(video);
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
// Wait for attachment
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
expect(el.store.target).toBe(video);
|
||||
});
|
||||
|
||||
it('finds nested video element', async () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-nested');
|
||||
|
||||
class TestElement extends StoreMixin(TestBaseElement) {
|
||||
override createRenderRoot() {
|
||||
createShadowWithSlot(this);
|
||||
return this.shadowRoot!;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.slot = 'media';
|
||||
const video = document.createElement('video');
|
||||
wrapper.appendChild(video);
|
||||
el.appendChild(wrapper);
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
// Wait for slotchange
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
expect(el.store.target).toBe(video);
|
||||
});
|
||||
|
||||
it('auto-attaches audio element', async () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-audio');
|
||||
|
||||
class TestElement extends StoreMixin(TestBaseElement) {
|
||||
override createRenderRoot() {
|
||||
createShadowWithSlot(this);
|
||||
return this.shadowRoot!;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
const audio = document.createElement('audio');
|
||||
audio.slot = 'media';
|
||||
el.appendChild(audio);
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
// Wait for slotchange
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
expect(el.store.target).toBe(audio);
|
||||
});
|
||||
|
||||
it('attaches only the first media element when multiple exist', async () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-multiple-media');
|
||||
|
||||
class TestElement extends StoreMixin(TestBaseElement) {
|
||||
override createRenderRoot() {
|
||||
createShadowWithSlot(this);
|
||||
return this.shadowRoot!;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
const video1 = document.createElement('video');
|
||||
video1.slot = 'media';
|
||||
const video2 = document.createElement('video');
|
||||
video2.slot = 'media';
|
||||
el.appendChild(video1);
|
||||
el.appendChild(video2);
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
// Wait for slotchange
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Should attach only the first one
|
||||
expect(el.store.target).toBe(video1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils';
|
||||
|
||||
setupDomCleanup();
|
||||
|
||||
describe('createStoreProviderMixin', () => {
|
||||
it('creates store lazily on first access', async () => {
|
||||
const { StoreProviderMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-provider');
|
||||
|
||||
class TestElement extends StoreProviderMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.store).toBeDefined();
|
||||
expect(el.store.state).toEqual({ volume: 1, muted: false });
|
||||
});
|
||||
|
||||
it('reuses same store instance', async () => {
|
||||
const { StoreProviderMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-provider-reuse');
|
||||
|
||||
class TestElement extends StoreProviderMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
const first = el.store;
|
||||
const second = el.store;
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('destroys owned store on disconnect', async () => {
|
||||
const { StoreProviderMixin } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-provider-destroy');
|
||||
|
||||
class TestElement extends StoreProviderMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
const store = el.store;
|
||||
expect(store.destroyed).toBe(false);
|
||||
|
||||
el.remove();
|
||||
|
||||
expect(store.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('allows setting custom store via setter', async () => {
|
||||
const { StoreProviderMixin, create } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-provider-setter');
|
||||
|
||||
class TestElement extends StoreProviderMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
const customStore = create();
|
||||
el.store = customStore;
|
||||
|
||||
expect(el.store).toBe(customStore);
|
||||
});
|
||||
|
||||
it('does not destroy externally provided store on disconnect', async () => {
|
||||
const { StoreProviderMixin, create } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-provider-external');
|
||||
|
||||
class TestElement extends StoreProviderMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
const externalStore = create();
|
||||
el.store = externalStore;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
el.remove();
|
||||
|
||||
// External store should NOT be destroyed
|
||||
expect(externalStore.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it('destroys old owned store when setting new store', async () => {
|
||||
const { StoreProviderMixin, create } = createLitTestStore();
|
||||
const tagName = uniqueTag('test-provider-replace');
|
||||
|
||||
class TestElement extends StoreProviderMixin(TestBaseElement) {}
|
||||
customElements.define(tagName, TestElement);
|
||||
|
||||
const el = document.createElement(tagName) as TestElement;
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
const ownedStore = el.store; // Creates owned store
|
||||
const newStore = create();
|
||||
el.store = newStore;
|
||||
|
||||
// Owned store should be destroyed
|
||||
expect(ownedStore.destroyed).toBe(true);
|
||||
expect(newStore.destroyed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest';
|
||||
|
||||
import { createLitTestStore, TestBaseElement } from '../../tests/test-utils';
|
||||
|
||||
describe('mixin types', () => {
|
||||
it('storeMixin adds store property', () => {
|
||||
const { StoreMixin } = createLitTestStore();
|
||||
const _MixedElement = StoreMixin(TestBaseElement);
|
||||
type Instance = InstanceType<typeof _MixedElement>;
|
||||
|
||||
// Verify store property exists on the mixed type
|
||||
expectTypeOf<Instance>().toHaveProperty('store');
|
||||
});
|
||||
|
||||
it('storeProviderMixin adds store property', () => {
|
||||
const { StoreProviderMixin } = createLitTestStore();
|
||||
const _MixedElement = StoreProviderMixin(TestBaseElement);
|
||||
type Instance = InstanceType<typeof _MixedElement>;
|
||||
|
||||
// Verify store property exists on the mixed type
|
||||
expectTypeOf<Instance>().toHaveProperty('store');
|
||||
});
|
||||
|
||||
it('storeAttachMixin adds store property', () => {
|
||||
const { StoreAttachMixin } = createLitTestStore();
|
||||
const _MixedElement = StoreAttachMixin(TestBaseElement);
|
||||
type Instance = InstanceType<typeof _MixedElement>;
|
||||
|
||||
// Verify store property exists on the mixed type
|
||||
expectTypeOf<Instance>().toHaveProperty('store');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createSlice } from '../../core/slice';
|
||||
import { createStore } from '../create-store';
|
||||
import { TestBaseElement } from './test-utils';
|
||||
|
||||
describe('createStore', () => {
|
||||
// Mock target
|
||||
class MockMedia extends EventTarget {
|
||||
volume = 1;
|
||||
muted = false;
|
||||
}
|
||||
|
||||
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'));
|
||||
return volume;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a store instance', () => {
|
||||
const { create } = createStore({ slices: [audioSlice] });
|
||||
|
||||
const store = create();
|
||||
|
||||
expect(store).toBeDefined();
|
||||
expect(store.state).toEqual({ volume: 1, muted: false });
|
||||
});
|
||||
|
||||
it('creates independent store instances', () => {
|
||||
const { create } = createStore({ slices: [audioSlice] });
|
||||
|
||||
const store1 = create();
|
||||
const store2 = create();
|
||||
|
||||
expect(store1).not.toBe(store2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context', () => {
|
||||
it('contexts share the same key for interoperability', () => {
|
||||
const result1 = createStore({ slices: [audioSlice] });
|
||||
const result2 = createStore({ slices: [audioSlice] });
|
||||
|
||||
// Contexts use a shared key so different store configurations can interoperate
|
||||
expect(result1.context).toBe(result2.context);
|
||||
});
|
||||
|
||||
it('context is defined', () => {
|
||||
const { context } = createStore({ slices: [audioSlice] });
|
||||
|
||||
expect(context).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixins', () => {
|
||||
it('returns StoreMixin', () => {
|
||||
const { StoreMixin } = createStore({ slices: [audioSlice] });
|
||||
|
||||
expect(typeof StoreMixin).toBe('function');
|
||||
});
|
||||
|
||||
it('returns StoreProviderMixin', () => {
|
||||
const { StoreProviderMixin } = createStore({ slices: [audioSlice] });
|
||||
|
||||
expect(typeof StoreProviderMixin).toBe('function');
|
||||
});
|
||||
|
||||
it('returns StoreAttachMixin', () => {
|
||||
const { StoreAttachMixin } = createStore({ slices: [audioSlice] });
|
||||
|
||||
expect(typeof StoreAttachMixin).toBe('function');
|
||||
});
|
||||
|
||||
it('mixins can be applied to TestBaseElement', () => {
|
||||
const { StoreMixin, StoreProviderMixin, StoreAttachMixin } = createStore({ slices: [audioSlice] });
|
||||
|
||||
const Mixed1 = StoreMixin(TestBaseElement);
|
||||
const Mixed2 = StoreProviderMixin(TestBaseElement);
|
||||
const Mixed3 = StoreAttachMixin(TestBaseElement);
|
||||
|
||||
expect(Mixed1.prototype).toBeInstanceOf(TestBaseElement);
|
||||
expect(Mixed2.prototype).toBeInstanceOf(TestBaseElement);
|
||||
expect(Mixed3.prototype).toBeInstanceOf(TestBaseElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('result object', () => {
|
||||
it('returns all expected properties', () => {
|
||||
const result = createStore({ slices: [audioSlice] });
|
||||
|
||||
expect(result).toHaveProperty('StoreMixin');
|
||||
expect(result).toHaveProperty('StoreProviderMixin');
|
||||
expect(result).toHaveProperty('StoreAttachMixin');
|
||||
expect(result).toHaveProperty('context');
|
||||
expect(result).toHaveProperty('create');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import type { AnySlice } from '../../core/slice';
|
||||
import type { Store } from '../../core/store';
|
||||
|
||||
import { ReactiveElement } from '@lit/reactive-element';
|
||||
|
||||
import { noop } from '@videojs/utils/function';
|
||||
|
||||
import { afterEach } from 'vitest';
|
||||
import { createSlice } from '../../core/slice';
|
||||
import { createStore as createCoreStore } from '../../core/store';
|
||||
import { createStore as createLitStore } from '../create-store';
|
||||
|
||||
/** Concrete base class for mixin tests (ReactiveElement is abstract). */
|
||||
export class TestBaseElement extends ReactiveElement {}
|
||||
|
||||
export class MockMedia extends EventTarget {
|
||||
volume = 1;
|
||||
muted = false;
|
||||
}
|
||||
|
||||
export 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 }): number => {
|
||||
target.volume = volume;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
return volume;
|
||||
},
|
||||
setMuted: (muted: boolean, { target }): boolean => {
|
||||
target.muted = muted;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
return muted;
|
||||
},
|
||||
slowSetVolume: async (volume: number, { target }): Promise<number> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
target.volume = volume;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
return volume;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Slice with custom keys (name !== key) for testing superseding behavior. */
|
||||
export const customKeySlice = 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: {
|
||||
// name='adjustVolume', key='audio-settings'
|
||||
adjustVolume: {
|
||||
key: 'audio-settings',
|
||||
handler: async (volume: number, { target }): Promise<number> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
target.volume = volume;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
return volume;
|
||||
},
|
||||
},
|
||||
// name='toggleMute', key='audio-settings' (same key - will supersede adjustVolume)
|
||||
toggleMute: {
|
||||
key: 'audio-settings',
|
||||
handler: async (muted: boolean, { target }): Promise<boolean> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
target.muted = muted;
|
||||
target.dispatchEvent(new Event('volumechange'));
|
||||
return muted;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type TestSlice = typeof audioSlice;
|
||||
type CustomKeySlice = typeof customKeySlice;
|
||||
|
||||
// For controller tests - creates core store with attached target
|
||||
export function createCoreTestStore(): { store: Store<MockMedia, [TestSlice]>; target: MockMedia } {
|
||||
const store = createCoreStore({
|
||||
slices: [audioSlice] as [AnySlice],
|
||||
onError: noop,
|
||||
});
|
||||
|
||||
const target = new MockMedia();
|
||||
store.attach(target);
|
||||
|
||||
return { store, target };
|
||||
}
|
||||
|
||||
/** Creates store with custom key slice (name !== key) for testing superseding. */
|
||||
export function createCustomKeyTestStore(): { store: Store<MockMedia, [CustomKeySlice]>; target: MockMedia } {
|
||||
const store = createCoreStore({
|
||||
slices: [customKeySlice] as [AnySlice],
|
||||
onError: noop,
|
||||
});
|
||||
|
||||
const target = new MockMedia();
|
||||
store.attach(target);
|
||||
|
||||
return { store, target };
|
||||
}
|
||||
|
||||
// For mixin tests - creates lit store factory
|
||||
export function createLitTestStore() {
|
||||
return createLitStore({ slices: [audioSlice] });
|
||||
}
|
||||
|
||||
// Mock ReactiveControllerHost for controller tests
|
||||
export interface MockHost extends ReactiveControllerHost {
|
||||
controllers: Set<unknown>;
|
||||
updateCount: number;
|
||||
}
|
||||
|
||||
export function createMockHost(): MockHost {
|
||||
const controllers = new Set<unknown>();
|
||||
const host: MockHost = {
|
||||
controllers,
|
||||
updateCount: 0,
|
||||
addController(controller: unknown): void {
|
||||
controllers.add(controller);
|
||||
},
|
||||
removeController(controller: unknown): void {
|
||||
controllers.delete(controller);
|
||||
},
|
||||
requestUpdate(): void {
|
||||
host.updateCount++;
|
||||
},
|
||||
updateComplete: Promise.resolve(true),
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
// For mixin tests - unique custom element tags
|
||||
let tagCounter = 0;
|
||||
|
||||
export function uniqueTag(base: string): string {
|
||||
return `${base}-${Date.now()}-${tagCounter++}`;
|
||||
}
|
||||
|
||||
export function setupDomCleanup(): void {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user