test: add tests for combineStates

This commit is contained in:
Víctor Oliva 2022-10-12 20:35:48 +02:00
parent cf0ce6d2e6
commit 1c3cc752b7
3 changed files with 128 additions and 5 deletions

View File

@ -0,0 +1,114 @@
import { createRoot } from "./create-root"
import { BehaviorSubject, of, Subject } from "rxjs"
import { substate } from "./substate"
import { combineStates } from "./combineStates"
import { StateNode } from "./types"
import { routeState } from "./route-state"
describe("combineStates", () => {
it("combines state nodes into one", () => {
const root = createRoot()
const nodeA = substate(root, () => of("a"))
const nodeB = substate(root, () => of("b"))
const nodeC = substate(root, () => of("c"))
const combined = combineStates({ nodeA, nodeB, nodeC })
root.run()
expect(combined.getValue()).toEqual({ nodeA: "a", nodeB: "b", nodeC: "c" })
})
it("substates can access the context of either branch", () => {
const root = createRoot()
const contextA = substate(root, () => of("context A"))
const nodeA = substate(contextA, () => of("a"))
const contextB = substate(root, () => of("context B"))
const nodeB = substate(contextB, () => of("b"))
const combined = combineStates({ nodeA, nodeB })
const result: string[] = []
substate(combined, (ctx) => {
// Can't put assertions here because errors get captured and not emitted on globalThis
result.push(ctx(contextA))
result.push(ctx(contextB))
return of("substate")
})
root.run()
expect(result).toEqual(["context A", "context B"])
})
it("only activates if all branches are active", () => {
function createActivableNode(root: StateNode<any>) {
const ctxSource = new BehaviorSubject(false)
const ctxNode = substate(root, () => ctxSource)
const [, { node }] = routeState(
ctxNode,
{
other: null,
node: null,
},
(value) => (value ? "node" : "other"),
)
return [node, (active: boolean) => ctxSource.next(active)] as const
}
const root = createRoot()
const [nodeA, activateA] = createActivableNode(root)
const [nodeB, activateB] = createActivableNode(root)
const combined = combineStates({ nodeA, nodeB })
root.run()
expect(() => combined.getValue()).toThrowError("Inactive Context")
activateA(true)
expect(() => combined.getValue()).toThrowError("Inactive Context")
activateA(false)
activateB(true)
expect(() => combined.getValue()).toThrowError("Inactive Context")
activateA(true)
expect(combined.getValue()).toEqual({
nodeA: true,
nodeB: true,
})
})
it("doesn't emit a value until all branches have one", async () => {
function createSettableNode(root: StateNode<any>) {
const source = new Subject()
const node = substate(root, () => source)
return [node, (value: any) => source.next(value)] as const
}
const root = createRoot()
const [nodeA, setA] = createSettableNode(root)
const [nodeB, setB] = createSettableNode(root)
const combined = combineStates({ nodeA, nodeB })
root.run()
const promise = combined.getValue()
expect(promise).toBeInstanceOf(Promise)
const next = jest.fn()
const complete = jest.fn()
combined.state$().subscribe({ next, complete })
expect(next).not.toHaveBeenCalled()
expect(complete).not.toHaveBeenCalled()
setA("a")
expect(next).not.toHaveBeenCalled()
setB("b")
expect(next).toHaveBeenCalledWith({ nodeA: "a", nodeB: "b" })
expect(complete).not.toHaveBeenCalled()
await expect(promise).resolves.toEqual({ nodeA: "a", nodeB: "b" })
next.mockReset()
setA("a2")
expect(next).toHaveBeenCalledWith({ nodeA: "a2", nodeB: "b" })
expect(complete).not.toHaveBeenCalled()
expect(combined.getValue()).toEqual({ nodeA: "a2", nodeB: "b" })
})
})

View File

@ -16,10 +16,19 @@ type StringRecordNodeToNodeStringRecord<
[K in keyof States]: States[K] extends StateNode<infer V> ? V : never
}>
interface CombinedStateInstance {
inactiveStates: number
emptyStates: number
activeStates: Record<string, boolean>
loadedStates: Record<string, boolean>
latestIsActive: boolean | null
latestIsLoaded: boolean | null
}
export const combineStates = <States extends StringRecord<StateNode<any>>>(
states: States,
): StringRecordNodeToNodeStringRecord<States> => {
const instances = new NestedMap()
const instances = new NestedMap<any[], CombinedStateInstance>()
const nKeys = Object.keys(states).length
const _allFalse = mapRecord(states, () => false)
@ -33,7 +42,7 @@ export const combineStates = <States extends StringRecord<StateNode<any>>>(
recordEntries(states).forEach(([key, node]) => {
parents.push(node)
globalChildRunners.get(node)!.push((ctxKey, isActive, isParentLoaded) => {
let instance: any = instances.get(ctxKey)
let instance = instances.get(ctxKey)
if (!instance) {
instance = {
inactiveStates: nKeys,
@ -53,11 +62,11 @@ export const combineStates = <States extends StringRecord<StateNode<any>>>(
if (isParentLoaded !== instance.loadedStates[key]) {
instance.emptyStates += isParentLoaded ? -1 : 1
instance.loadedStates[key] = isParentLoaded
instance.loadedStates[key] = !!isParentLoaded
}
const isCurrentlyActive = instance.inactiveStates === 0
const isLoaded = instance.activeStates === nKeys
const isLoaded = Object.values(instance.activeStates).every((v) => v)
if (
isCurrentlyActive !== instance.latestIsActive ||
isLoaded !== instance.latestIsLoaded

View File

@ -1,4 +1,4 @@
export class NestedMap<K extends [], V extends Object> {
export class NestedMap<K extends any[], V extends Object> {
private root: Map<K, V>
private rootValue?: V
constructor() {