type PickFirst = Array extends [infer First, ...unknown[]] ? First : never; type MatrixOrValue = Array extends [] ? Value : Matrix; type RemoveFirst = T extends [unknown, ...infer Rest] ? Rest : never; /** * A matrix is intended to manage cached values for a set of method arguments. */ export class Matrix { private map: Map, MatrixOrValue, TValue>> = new Map(); /** * This is especially useful for methods on a service that take inputs but return Observables. * Generally when interacting with observables in tests, you want to use a simple SubjectLike * type to back it instead, so that you can easily `next` values to simulate an emission. * * @param mockFunction The function to have a Matrix based implementation added to it. * @param creator The function to use to create the underlying value to return for the given arguments. * @returns A "getter" function that allows you to retrieve the backing value that is used for the given arguments. * * @example * ```ts * interface MyService { * event$(userId: UserId) => Observable * } * * // Test * const myService = mock(); * const eventGetter = Matrix.autoMockMethod(myService.event$, (userId) => BehaviorSubject()); * * eventGetter("userOne").next(new UserEvent()); * eventGetter("userTwo").next(new UserEvent()); * ``` * * This replaces a more manual way of doing things like: * * ```ts * const myService = mock(); * const userOneSubject = new BehaviorSubject(); * const userTwoSubject = new BehaviorSubject(); * myService.event$.mockImplementation((userId) => { * if (userId === "userOne") { * return userOneSubject; * } else if (userId === "userTwo") { * return userTwoSubject; * } * return new BehaviorSubject(); * }); * * userOneSubject.next(new UserEvent()); * userTwoSubject.next(new UserEvent()); * ``` */ static autoMockMethod( mockFunction: jest.Mock, creator: (args: TArgs) => TActualReturn, ): (...args: TArgs) => TActualReturn { const matrix = new Matrix(); const getter = (...args: TArgs) => { return matrix.getOrCreateEntry(args, creator); }; mockFunction.mockImplementation(getter); return getter; } /** * Gives the ability to get or create an entry in the matrix via the given args. * * @note The args are evaulated using Javascript equality so primivites work best. * * @param args The arguments to use to evaluate if an entry in the matrix exists already, * or a value should be created and stored with those arguments. * @param creator The function to call with the arguments to build a value. * @returns The existing entry if one already exists or a new value created with the creator param. */ getOrCreateEntry(args: TKeys, creator: (args: TKeys) => TValue): TValue { if (args.length === 0) { throw new Error("Matrix is not for you."); } if (args.length === 1) { const arg = args[0] as PickFirst; if (this.map.has(arg)) { // Get the cached value return this.map.get(arg) as TValue; } else { const value = creator(args); // Save the value for the next time this.map.set(arg, value as MatrixOrValue, TValue>); return value; } } // There are for sure 2 or more args const [first, ...rest] = args as unknown as [PickFirst, ...RemoveFirst]; let matrix: Matrix, TValue> | null = null; if (this.map.has(first)) { // We've already created a map for this argument matrix = this.map.get(first) as Matrix, TValue>; } else { matrix = new Matrix, TValue>(); this.map.set(first, matrix as MatrixOrValue, TValue>); } return matrix.getOrCreateEntry(rest, () => creator(args)); } }