add subinstance tests (#301)

This commit is contained in:
Victor Oliva 2023-07-10 07:36:07 +02:00 committed by GitHub
parent 0c7b337d54
commit 06918bb467
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 323 additions and 73 deletions

View File

@ -108,6 +108,11 @@ export function createStateNode<K extends KeysBaseType, R>(
key: K
}>()
const addInstance = (key: K) => {
const orderedKey = nestedMapKey(key)
if (instances.get(orderedKey)) {
return
}
// Wait until parents have emitted a value
const parent$ = defer(() => {
const instances = parents.map((parent) => parent.getInstance(key))
@ -127,9 +132,8 @@ export function createStateNode<K extends KeysBaseType, R>(
return of(null)
})
// TODO case key already has instance?
instances.set(
nestedMapKey(key),
orderedKey,
createInstance(
key,
parent$.pipe(

View File

@ -1,55 +1,271 @@
import { Subject, filter, map, startWith } from "rxjs"
import { InstanceUpdate, createRoot, subinstance } from "./"
import {
NEVER,
Subject,
filter,
from,
map,
mergeAll,
of,
startWith,
} from "rxjs"
import { InstanceUpdate, createRoot, subinstance, substate } from "./"
describe("subinstance", () => {
it("works", () => {
const root = createRoot()
const instance$ = new Subject<InstanceUpdate<string>>()
const updates$ = new Subject<{ key: string; value: string }>()
const [instanceNode] = subinstance(
root,
"keyName",
() => instance$,
(id) =>
updates$.pipe(
filter((v) => v.key === id),
map((v) => v.value),
startWith(id),
describe("behaviour", () => {
it("creates instances with the specified key name", () => {
const root = createRoot()
const instance$ = new Subject<InstanceUpdate<string>>()
const updates$ = new Subject<{ key: string; value: string }>()
const [instanceNode] = subinstance(
root,
"keyName",
() => instance$,
(id) =>
updates$.pipe(
filter((v) => v.key === id),
map((v) => v.value),
startWith(id),
),
)
root.run()
instance$.next({
type: "add",
key: "a",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("a")
instance$.next({
type: "add",
key: "b",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("a")
expect(instanceNode.getValue({ keyName: "b" })).toEqual("b")
updates$.next({
key: "a",
value: "new A value",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("new A value")
expect(instanceNode.getValue({ keyName: "b" })).toEqual("b")
instance$.next({
type: "remove",
key: "a",
})
expect(() => instanceNode.getValue({ keyName: "a" })).toThrow()
expect(instanceNode.getValue({ keyName: "b" })).toEqual("b")
instance$.next({
type: "remove",
key: "b",
})
expect(() => instanceNode.getValue({ keyName: "b" })).toThrow()
})
it("throws if the key name is already used by one of the parents", () => {
const root = createRoot("rootKey")
const context = substate(root, () => NEVER)
expect(() =>
subinstance(
root,
"rootKey",
() => NEVER,
() => NEVER,
),
)
root.run()
instance$.next({
type: "add",
key: "a",
).toThrow()
expect(() =>
subinstance(
context,
"rootKey",
() => NEVER,
() => NEVER,
),
).toThrow()
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("a")
instance$.next({
type: "add",
key: "b",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("a")
expect(instanceNode.getValue({ keyName: "b" })).toEqual("b")
it("ignores adding duplicate key values", () => {
const root = createRoot()
const instance$ = new Subject<InstanceUpdate<string>>()
const instanceFn = jest.fn((id: string) => of(id))
const [instanceNode, keys] = subinstance(
root,
"keyName",
() => instance$,
instanceFn,
)
root.run()
updates$.next({
key: "a",
value: "new A value",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("new A value")
expect(instanceNode.getValue({ keyName: "b" })).toEqual("b")
instance$.next({
type: "add",
key: "a",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("a")
expect(instanceFn).toHaveBeenCalledTimes(1)
instance$.next({
type: "remove",
key: "a",
})
expect(() => instanceNode.getValue({ keyName: "a" })).toThrow()
expect(instanceNode.getValue({ keyName: "b" })).toEqual("b")
const keysObserver = jest.fn()
keys.getState$().subscribe(keysObserver)
expect(keysObserver).toHaveBeenCalledTimes(1)
instance$.next({
type: "remove",
key: "b",
instance$.next({
type: "add",
key: "a",
})
expect(instanceFn).toHaveBeenCalledTimes(1)
expect(keysObserver).toHaveBeenCalledTimes(1)
instance$.next({
type: "remove",
key: "a",
})
expect(() => instanceNode.getValue({ keyName: "a" })).toThrow()
expect(keysObserver).toHaveBeenCalledTimes(2)
})
it("ignores removing key values that don't exist", () => {
const root = createRoot()
const instance$ = new Subject<InstanceUpdate<string>>()
const [instanceNode] = subinstance(
root,
"keyName",
() => instance$,
(id) => of(id),
)
root.run()
instance$.next({
type: "add",
key: "a",
})
expect(instanceNode.getValue({ keyName: "a" })).toEqual("a")
instance$.next({
type: "remove",
key: "a",
})
expect(() => instanceNode.getValue({ keyName: "a" })).toThrow()
instance$.next({
type: "remove",
key: "a",
})
expect(() => instanceNode.getValue({ keyName: "a" })).toThrow()
})
it("cleans up all instances when the parent dies", () => {
const root = createRoot()
const [instances, keys] = subinstance(
root,
"keyName",
() =>
from(["a", "b"]).pipe(
map((key) => ({
type: "add",
key,
})),
),
(id) => of(id),
)
const stop = root.run()
expect(instances.getValue({ keyName: "a" })).toEqual("a")
expect(instances.getValue({ keyName: "b" })).toEqual("b")
expect([...(keys.getValue() as Set<string>)]).toEqual(["a", "b"])
stop()
expect(() => instances.getValue({ keyName: "a" })).toThrow()
expect(() => instances.getValue({ keyName: "b" })).toThrow()
expect(() => [...(keys.getValue() as Set<string>)]).toThrow()
})
})
describe("key selector", () => {
it("can access values from its context", () => {
const root = createRoot()
const keys = substate(root, () => of(["a", "b"]))
const [_, activeKeys] = subinstance(
keys,
"keyName",
(ctx) =>
from(ctx(keys)).pipe(
map((key) => ({
type: "add",
key,
})),
),
(id) => of(id),
)
root.run()
expect([...(activeKeys.getValue() as Set<string>)]).toEqual(["a", "b"])
})
it("can reference siblings", () => {
const root = createRoot()
// TODO can't do this if root is already running
// Maybe on that case, catch exception and retry activating on microtask?
const [_, activeKeys] = subinstance(
root,
"keyName",
(_, getObs$) =>
getObs$(keys).pipe(
mergeAll(),
map((key) => ({
type: "add",
key,
})),
),
(id) => of(id),
)
const keys = substate(root, () => of(["a", "b"]))
root.run()
expect([...(activeKeys.getValue() as Set<string>)]).toEqual(["a", "b"])
})
})
describe("value selector", () => {
it("can access values from its context", () => {
const root = createRoot()
const values = substate(root, () => of({ a: 1, b: 2 }))
const [instances] = subinstance(
values,
"keyName",
() =>
from(["a", "b"] as const).pipe(
map((key) => ({
type: "add",
key,
})),
),
(id, ctx) => of(ctx(values)[id]),
)
root.run()
expect(instances.getValue({ keyName: "a" })).toEqual(1)
expect(instances.getValue({ keyName: "b" })).toEqual(2)
})
it("can reference siblings", () => {
const root = createRoot()
const [instances] = subinstance(
root,
"keyName",
() =>
from(["a", "b"] as const).pipe(
map((key) => ({
type: "add",
key,
})),
),
(id, _, getObs$) => getObs$(values).pipe(map((values) => values[id])),
)
const values = substate(root, () => of({ a: 1, b: 2 }))
root.run()
expect(instances.getValue({ keyName: "a" })).toEqual(1)
expect(instances.getValue({ keyName: "b" })).toEqual(2)
})
expect(() => instanceNode.getValue({ keyName: "b" })).toThrow()
})
})

View File

@ -1,5 +1,10 @@
import { Observable, map, scan, startWith } from "rxjs"
import { createStateNode, getInternals, trackParentChanges } from "./internal"
import { Observable, filter, map, scan, startWith } from "rxjs"
import {
Wildcard,
createStateNode,
getInternals,
trackParentChanges,
} from "./internal"
import { substate } from "./substate"
import {
CtxFn,
@ -32,31 +37,39 @@ export function subinstance<K extends KeysBaseType, KN extends string, KV, R>(
keySelector: CtxFn<InstanceUpdate<KV>, K>,
instanceObs: InstanceCtxFn<R, MergeKey<K, KN, KV>, KV>,
): [StateNode<R, MergeKey<K, KN, KV>>, StateNode<Set<KV>, K>] {
const instanceKeys = substate(
parent,
(ctx, getObs, key) => {
const keys = Object.assign(new Set<KV>(), {
lastUpdate: null,
} as {
lastUpdate: InstanceUpdate<KV> | null
})
return keySelector(ctx, getObs, key).pipe(
scan((acc, change) => {
acc.lastUpdate = change
if (change.type === "add") {
acc.add(change.key)
} else {
acc.delete(change.key)
}
return acc
}, keys),
startWith(keys),
)
},
() => false,
)
const parentInternals = getInternals(parent)
if (parentInternals.keysOrder.includes(keyName)) {
throw new Error(`Key "${keyName}" is already being used by a parent node`)
}
const instanceKeys = substate(parent, (ctx, getObs, key) => {
const keys = Object.assign(new Set<KV>(), {
lastUpdate: null,
} as {
lastUpdate: InstanceUpdate<KV> | null
})
return keySelector(ctx, getObs, key).pipe(
scan((acc, change) => {
acc.lastUpdate = change
if (change.type === "add") {
if (acc.has(change.key)) {
acc.lastUpdate = null
} else {
acc.add(change.key)
}
} else {
if (acc.has(change.key)) {
acc.delete(change.key)
} else {
acc.lastUpdate = null
}
}
return acc
}, keys),
filter((v) => v.lastUpdate !== null),
startWith(keys),
)
})
const result = createStateNode<MergeKey<K, KN, KV>, R>(
[...parentInternals.keysOrder, keyName],
[parentInternals],
@ -80,6 +93,9 @@ export function subinstance<K extends KeysBaseType, KN extends string, KV, R>(
.pipe(map((v, i) => [v, i] as const))
.subscribe(([v, i]) => {
if (i === 0) {
// TODO ackchyually, this can't happen because `instanceKeys` has startWith(new Set())
// Something I don't like from this is that this also means that there's currently no way of doing one single update with all the changes
// Maybe change API to { type: 'add', keys: key[] }? And also change startWith for defaultStart
for (let instanceKey of v) {
result.addInstance({
...key,
@ -118,8 +134,15 @@ export function subinstance<K extends KeysBaseType, KN extends string, KV, R>(
},
onActive() {},
onReset() {},
onRemoved(_, storage) {
onRemoved(key, storage) {
storage.value.unsubscribe()
const orderedKey = parentInternals.keysOrder.map((k) => key[k])
const instancesToRemove = [
...result.getInstances([...orderedKey, Wildcard] as any),
]
instancesToRemove.forEach((instance) =>
result.removeInstance(instance.key),
)
},
})

View File

@ -7,6 +7,13 @@ import {
type StateNode,
} from "./types"
/**
*
* @param parent
* @param getState$
* @param equalityFn TODO <- this equality function actually refers to the parent?!
* @returns
*/
export const substate = <T, K extends KeysBaseType>(
parent: StateNode<any, K>,
getState$: CtxFn<T, K>,