import { Observable, ReplaySubject, concatMap, 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); } 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>(1); state$: Observable; combinedState$: Observable>; constructor( readonly userId: UserId, initialValue?: T, ) { this.stateSubject.next([userId, initialValue ?? null]); this.combinedState$ = this.stateSubject.asObservable(); this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); } nextState(state: T) { this.stateSubject.next([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.stateSubject.next([this.userId, 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>(1); state$: Observable; combinedState$: Observable>; constructor( private accountService: FakeAccountService, initialValue?: T, ) { this.stateSubject.next([accountService.activeUserId, initialValue ?? null]); this.combinedState$ = this.stateSubject.asObservable(); this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); } get userId() { return this.accountService.activeUserId; } nextState(state: T) { this.stateSubject.next([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.stateSubject.next([this.userId, 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(); } }