import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs"; import { DerivedState, GlobalState, SingleUserState, ActiveUserState, KeyDefinition, DeriveDefinition, UserKeyDefinition, } from "../src/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class import { StateUpdateOptions } from "../src/platform/state/state-update-options"; // eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class import { CombinedState, activeMarker } from "../src/platform/state/user-state"; import { UserId } from "../src/types/guid"; import { DerivedStateDependencies } from "../src/types/state"; import { FakeAccountService } from "./fake-account-service"; const DEFAULT_TEST_OPTIONS: StateUpdateOptions = { shouldUpdate: () => true, combineLatestWith: null, msTimeout: 10, }; function populateOptionsWithDefault( options: StateUpdateOptions, ): StateUpdateOptions { return { ...DEFAULT_TEST_OPTIONS, ...options, }; } export class FakeGlobalState implements GlobalState { // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup stateSubject = new ReplaySubject(1); constructor(initialValue?: T) { this.stateSubject.next(initialValue ?? null); } nextState(state: T) { this.stateSubject.next(state); } async update( configureState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions, ): Promise { options = populateOptionsWithDefault(options); if (this.stateSubject["_buffer"].length == 0) { // throw a more helpful not initialized error throw new Error( "You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update", ); } const current = await firstValueFrom(this.state$.pipe(timeout(100))); const combinedDependencies = options.combineLatestWith != null ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) : null; if (!options.shouldUpdate(current, combinedDependencies)) { return current; } const newState = configureState(current, combinedDependencies); this.stateSubject.next(newState); this.nextMock(newState); return newState; } /** Tracks update values resolved by `FakeState.update` */ nextMock = jest.fn(); get state$() { return this.stateSubject.asObservable(); } private _keyDefinition: KeyDefinition | null = null; get keyDefinition() { if (this._keyDefinition == null) { throw new Error( "Key definition not yet set, usually this means your sut has not asked for this state yet", ); } return this._keyDefinition; } set keyDefinition(value: KeyDefinition) { this._keyDefinition = value; } } export class FakeSingleUserState implements SingleUserState { // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup stateSubject = new ReplaySubject<{ syncValue: boolean; combinedState: CombinedState; }>(1); state$: Observable; combinedState$: Observable>; constructor( readonly userId: UserId, initialValue?: T, updateSyncCallback?: (userId: UserId, newValue: T) => Promise, ) { // Inform the state provider of updates to keep active user states in sync this.stateSubject .pipe( filter((next) => next.syncValue), concatMap(async ({ combinedState }) => { await updateSyncCallback?.(...combinedState); }), ) .subscribe(); this.nextState(initialValue ?? null, { syncValue: initialValue != null }); this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); } nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) { this.stateSubject.next({ syncValue, combinedState: [this.userId, state], }); } async update( configureState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions, ): Promise { options = populateOptionsWithDefault(options); const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); const combinedDependencies = options.combineLatestWith != null ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) : null; if (!options.shouldUpdate(current, combinedDependencies)) { return current; } const newState = configureState(current, combinedDependencies); this.nextState(newState); this.nextMock(newState); return newState; } /** Tracks update values resolved by `FakeState.update` */ nextMock = jest.fn(); private _keyDefinition: UserKeyDefinition | null = null; get keyDefinition() { if (this._keyDefinition == null) { throw new Error( "Key definition not yet set, usually this means your sut has not asked for this state yet", ); } return this._keyDefinition; } set keyDefinition(value: UserKeyDefinition) { this._keyDefinition = value; } } export class FakeActiveUserState implements ActiveUserState { [activeMarker]: true; // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup stateSubject = new ReplaySubject<{ syncValue: boolean; combinedState: CombinedState; }>(1); state$: Observable; combinedState$: Observable>; constructor( private accountService: FakeAccountService, initialValue?: T, updateSyncCallback?: (userId: UserId, newValue: T) => Promise, ) { // Inform the state provider of updates to keep single user states in sync this.stateSubject.pipe( filter((next) => next.syncValue), concatMap(async ({ combinedState }) => { await updateSyncCallback?.(...combinedState); }), ); this.nextState(initialValue ?? null, { syncValue: initialValue != null }); this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); } get userId() { return this.accountService.activeUserId; } nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) { this.stateSubject.next({ syncValue, combinedState: [this.userId, state], }); } async update( configureState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions, ): Promise<[UserId, T]> { options = populateOptionsWithDefault(options); const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); const combinedDependencies = options.combineLatestWith != null ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) : null; if (!options.shouldUpdate(current, combinedDependencies)) { return [this.userId, current]; } const newState = configureState(current, combinedDependencies); this.nextState(newState); this.nextMock([this.userId, newState]); return [this.userId, newState]; } /** Tracks update values resolved by `FakeState.update` */ nextMock = jest.fn(); private _keyDefinition: UserKeyDefinition | null = null; get keyDefinition() { if (this._keyDefinition == null) { throw new Error( "Key definition not yet set, usually this means your sut has not asked for this state yet", ); } return this._keyDefinition; } set keyDefinition(value: UserKeyDefinition) { this._keyDefinition = value; } } export class FakeDerivedState implements DerivedState { // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup stateSubject = new ReplaySubject(1); constructor( parentState$: Observable, deriveDefinition: DeriveDefinition, dependencies: TDeps, ) { parentState$ .pipe( concatMap(async (v) => { const newState = deriveDefinition.derive(v, dependencies); if (newState instanceof Promise) { return newState; } return Promise.resolve(newState); }), ) .subscribe((newState) => { this.stateSubject.next(newState); }); } forceValue(value: TTo): Promise { this.stateSubject.next(value); return Promise.resolve(value); } forceValueMock = this.forceValue as jest.MockedFunction; get state$() { return this.stateSubject.asObservable(); } }