@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
629 lines (575 loc) • 15.4 kB
text/typescript
import { describe, expect, mock, test } from 'bun:test'
import {
batch as batchNext,
createEffect as createEffectNext,
createMemo,
createState,
} from '../index.ts'
import { Counter, makeGraph, runGraph } from './util/dependency-graph'
import type { Computed, ReactiveFramework } from './util/reactive-framework'
/* === Utility Functions === */
const busy = () => {
let _a = 0
for (let i = 0; i < 1_00; i++) {
_a++
}
}
/* === Framework Adapters === */
const v18: ReactiveFramework = {
name: 'v0.18.0 (graph)',
// @ts-expect-error ReactiveFramework doesn't have non-nullable signals
signal: <T extends {}>(initialValue: T) => {
const s = createState(initialValue)
return { write: s.set, read: s.get }
},
// @ts-expect-error ReactiveFramework doesn't have non-nullable signals
computed: <T extends {}>(fn: () => T) => {
const c = createMemo(fn)
return { read: c.get }
},
effect: (fn: () => undefined) => {
createEffectNext(() => fn())
},
withBatch: fn => batchNext(fn),
withBuild: <T>(fn: () => T) => fn(),
}
const testPullCounts = true
function makeConfig() {
return {
width: 3,
totalLayers: 3,
staticFraction: 1,
nSources: 2,
readFraction: 1,
expected: {},
iterations: 1,
}
}
/* === Parameterized Test Suite === */
for (const framework of [v18]) {
const name = framework.name
describe(`Basic tests [${name}]`, () => {
test('simple dependency executes', () => {
framework.withBuild(() => {
const s = framework.signal(2)
const c = framework.computed(() => s.read() * 2)
expect(c.read()).toEqual(4)
})
})
test('simple write', () => {
framework.withBuild(() => {
const s = framework.signal(2)
const c = framework.computed(() => s.read() * 2)
expect(s.read()).toEqual(2)
expect(c.read()).toEqual(4)
s.write(3)
expect(s.read()).toEqual(3)
expect(c.read()).toEqual(6)
})
})
test('static graph', () => {
const config = makeConfig()
const counter = new Counter()
const graph = makeGraph(framework, config, counter)
const sum = runGraph(graph, 2, 1, framework)
expect(sum).toEqual(16)
if (testPullCounts) {
expect(counter.count).toEqual(11)
} else {
expect(counter.count).toBeGreaterThanOrEqual(11)
}
})
test('static graph, read 2/3 of leaves', () => {
framework.withBuild(() => {
const config = makeConfig()
config.readFraction = 2 / 3
config.iterations = 10
const counter = new Counter()
const graph = makeGraph(framework, config, counter)
const sum = runGraph(graph, 10, 2 / 3, framework)
expect(sum).toEqual(71)
if (testPullCounts) {
expect(counter.count).toEqual(41)
} else {
expect(counter.count).toBeGreaterThanOrEqual(41)
}
})
})
test('dynamic graph', () => {
framework.withBuild(() => {
const config = makeConfig()
config.staticFraction = 0.5
config.width = 4
config.totalLayers = 2
const counter = new Counter()
const graph = makeGraph(framework, config, counter)
const sum = runGraph(graph, 10, 1, framework)
expect(sum).toEqual(72)
if (testPullCounts) {
expect(counter.count).toEqual(22)
} else {
expect(counter.count).toBeGreaterThanOrEqual(22)
}
})
})
test('withBuild', () => {
const r = framework.withBuild(() => {
const s = framework.signal(2)
const c = framework.computed(() => s.read() * 2)
expect(c.read()).toEqual(4)
return c.read()
})
expect(r).toEqual(4)
})
test('effect', () => {
const spy = (_v: number) => {}
const spyMock = mock(spy)
const s = framework.signal(2)
let c: { read: () => number } = { read: () => 0 }
framework.withBuild(() => {
c = framework.computed(() => s.read() * 2)
framework.effect(() => {
spyMock(c.read())
})
})
expect(spyMock.mock.calls.length).toBe(1)
framework.withBatch(() => {
s.write(3)
})
expect(s.read()).toEqual(3)
expect(c.read()).toEqual(6)
expect(spyMock.mock.calls.length).toBe(2)
})
})
describe(`Kairo tests [${name}]`, () => {
test('avoidable propagation', async () => {
const head = framework.signal(0)
const computed1 = framework.computed(() => head.read())
const computed2 = framework.computed(() => {
computed1.read()
return 0
})
const computed3 = framework.computed(() => {
busy()
return computed2.read() + 1
})
const computed4 = framework.computed(() => computed3.read() + 2)
const computed5 = framework.computed(() => computed4.read() + 3)
framework.effect(() => {
computed5.read()
busy()
})
return () => {
framework.withBatch(() => {
head.write(1)
})
expect(computed5.read()).toBe(6)
for (let i = 0; i < 10; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(computed5.read()).toBe(6)
}
}
})
test('broad propagation', async () => {
const head = framework.signal(0)
let last = head as { read: () => number }
const callCounter = new Counter()
for (let i = 0; i < 50; i++) {
const current = framework.computed(() => head.read() + i)
const current2 = framework.computed(() => current.read() + 1)
framework.effect(() => {
current2.read()
callCounter.count++
})
last = current2
}
return () => {
framework.withBatch(() => {
head.write(1)
})
const atleast = 50 * 50
callCounter.count = 0
for (let i = 0; i < 50; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(last.read()).toBe(i + 50)
}
expect(callCounter.count).toBe(atleast)
}
})
test('deep propagation', async () => {
const len = 50
const head = framework.signal(0)
let current = head as { read: () => number }
for (let i = 0; i < len; i++) {
const c = current
current = framework.computed(() => c.read() + 1)
}
const callCounter = new Counter()
framework.effect(() => {
current.read()
callCounter.count++
})
const iter = 50
return () => {
framework.withBatch(() => {
head.write(1)
})
const atleast = iter
callCounter.count = 0
for (let i = 0; i < iter; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(current.read()).toBe(len + i)
}
expect(callCounter.count).toBe(atleast)
}
})
test('diamond', async () => {
const width = 5
const head = framework.signal(0)
const current: { read(): number }[] = []
for (let i = 0; i < width; i++) {
current.push(framework.computed(() => head.read() + 1))
}
const sum = framework.computed(() =>
current.map(x => x.read()).reduce((a, b) => a + b, 0),
)
const callCounter = new Counter()
framework.effect(() => {
sum.read()
callCounter.count++
})
return () => {
framework.withBatch(() => {
head.write(1)
})
expect(sum.read()).toBe(2 * width)
const atleast = 500
callCounter.count = 0
for (let i = 0; i < 500; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(sum.read()).toBe((i + 1) * width)
}
expect(callCounter.count).toBe(atleast)
}
})
test('mux', async () => {
const heads = new Array(100)
.fill(null)
.map(_ => framework.signal(0))
const mux = framework.computed(() =>
Object.fromEntries(heads.map(h => h.read()).entries()),
)
const splited = heads
// biome-ignore lint/style/noNonNullAssertion: test
.map((_, index) => framework.computed(() => mux.read()[index]!))
.map(x => framework.computed(() => x.read() + 1))
for (const x of splited) {
framework.effect(() => {
x.read()
})
}
return () => {
for (let i = 0; i < 10; i++) {
framework.withBatch(() => {
// biome-ignore lint/style/noNonNullAssertion: test
heads[i]!.write(i)
})
// biome-ignore lint/style/noNonNullAssertion: test
expect(splited[i]!.read()).toBe(i + 1)
}
for (let i = 0; i < 10; i++) {
framework.withBatch(() => {
// biome-ignore lint/style/noNonNullAssertion: test
heads[i]!.write(i * 2)
})
// biome-ignore lint/style/noNonNullAssertion: test
expect(splited[i]!.read()).toBe(i * 2 + 1)
}
}
})
test('repeated observers', async () => {
const size = 30
const head = framework.signal(0)
const current = framework.computed(() => {
let result = 0
for (let i = 0; i < size; i++) {
result += head.read()
}
return result
})
const callCounter = new Counter()
framework.effect(() => {
current.read()
callCounter.count++
})
return () => {
framework.withBatch(() => {
head.write(1)
})
expect(current.read()).toBe(size)
const atleast = 100
callCounter.count = 0
for (let i = 0; i < 100; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(current.read()).toBe(i * size)
}
expect(callCounter.count).toBe(atleast)
}
})
test('triangle', async () => {
const width = 10
const head = framework.signal(0)
let current = head as { read: () => number }
const list: { read: () => number }[] = []
for (let i = 0; i < width; i++) {
const c = current
list.push(current)
current = framework.computed(() => c.read() + 1)
}
const sum = framework.computed(() =>
list.map(x => x.read()).reduce((a, b) => a + b, 0),
)
const callCounter = new Counter()
framework.effect(() => {
sum.read()
callCounter.count++
})
return () => {
const count = (number: number) =>
new Array(number)
.fill(0)
.map((_, i) => i + 1)
.reduce((x, y) => x + y, 0)
const constant = count(width)
framework.withBatch(() => {
head.write(1)
})
expect(sum.read()).toBe(constant)
const atleast = 100
callCounter.count = 0
for (let i = 0; i < 100; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(sum.read()).toBe(constant - width + i * width)
}
expect(callCounter.count).toBe(atleast)
}
})
test('unstable', async () => {
const head = framework.signal(0)
const double = framework.computed(() => head.read() * 2)
const inverse = framework.computed(() => -head.read())
const current = framework.computed(() => {
let result = 0
for (let i = 0; i < 20; i++) {
result += head.read() % 2 ? double.read() : inverse.read()
}
return result
})
const callCounter = new Counter()
framework.effect(() => {
current.read()
callCounter.count++
})
return () => {
framework.withBatch(() => {
head.write(1)
})
expect(current.read()).toBe(40)
const atleast = 100
callCounter.count = 0
for (let i = 0; i < 100; i++) {
framework.withBatch(() => {
head.write(i)
})
expect(current.read()).toBe(i % 2 ? i * 2 * 10 : i * -10)
}
expect(callCounter.count).toBe(atleast)
}
})
})
describe(`$mol_wire tests [${name}]`, () => {
test('$mol_wire benchmark', () => {
// @ts-expect-error test
const fib = (n: number) => {
if (n < 2) return 1
return fib(n - 1) + fib(n - 2)
}
const hard = (n: number, _log: string) => n + fib(16)
const numbers = Array.from({ length: 5 }, (_, i) => i)
const res: (() => unknown)[] = []
framework.withBuild(() => {
const A = framework.signal(0)
const B = framework.signal(0)
const C = framework.computed(
() => (A.read() % 2) + (B.read() % 2),
)
const D = framework.computed(() =>
numbers.map(i => ({
x: i + (A.read() % 2) - (B.read() % 2),
})),
)
const E = framework.computed(() =>
// biome-ignore lint/style/noNonNullAssertion: test
hard(C.read() + A.read() + D.read()[0]!.x, 'E'),
)
const F = framework.computed(() =>
// biome-ignore lint/style/noNonNullAssertion: test
hard(D.read()[2]!.x || B.read(), 'F'),
)
const G = framework.computed(
() =>
C.read() +
(C.read() || E.read() % 2) +
// biome-ignore lint/style/noNonNullAssertion: test
D.read()[4]!.x +
F.read(),
)
framework.effect(() => {
res.push(hard(G.read(), 'H'))
})
framework.effect(() => {
res.push(G.read())
})
framework.effect(() => {
res.push(hard(F.read(), 'J'))
})
framework.effect(() => {
res[0] = hard(G.read(), 'H')
})
framework.effect(() => {
res[1] = G.read()
})
framework.effect(() => {
res[2] = hard(F.read(), 'J')
})
return (i: number) => {
res.length = 0
framework.withBatch(() => {
B.write(1)
A.write(1 + i * 2)
})
framework.withBatch(() => {
A.write(2 + i * 2)
B.write(2)
})
}
})
expect(res.toString()).toBe([3201, 1604, 3196].toString())
})
})
describe(`CellX tests [${name}]`, () => {
test('CellX benchmark', () => {
const expected = {
10: [
[3, 6, 2, -2],
[2, 4, -2, -3],
],
20: [
[2, 4, -1, -6],
[-2, 1, -4, -4],
],
50: [
[-2, -4, 1, 6],
[2, -1, 4, 4],
],
}
const cellx = (framework: ReactiveFramework, layers: number) => {
const start = {
prop1: framework.signal(1),
prop2: framework.signal(2),
prop3: framework.signal(3),
prop4: framework.signal(4),
}
type CellxLayer = {
prop1: Computed<number>
prop2: Computed<number>
prop3: Computed<number>
prop4: Computed<number>
}
let layer: CellxLayer = start
for (let i = layers; i > 0; i--) {
const m: CellxLayer = layer
const s = {
prop1: framework.computed(() => m.prop2.read()),
prop2: framework.computed(
() => m.prop1.read() - m.prop3.read(),
),
prop3: framework.computed(
() => m.prop2.read() + m.prop4.read(),
),
prop4: framework.computed(() => m.prop3.read()),
}
framework.effect(() => {
s.prop1.read()
})
framework.effect(() => {
s.prop2.read()
})
framework.effect(() => {
s.prop3.read()
})
framework.effect(() => {
s.prop4.read()
})
framework.effect(() => {
s.prop1.read()
})
framework.effect(() => {
s.prop2.read()
})
framework.effect(() => {
s.prop3.read()
})
framework.effect(() => {
s.prop4.read()
})
s.prop1.read()
s.prop2.read()
s.prop3.read()
s.prop4.read()
layer = s
}
const end = layer
const before = [
end.prop1.read(),
end.prop2.read(),
end.prop3.read(),
end.prop4.read(),
]
framework.withBatch(() => {
start.prop1.write(4)
start.prop2.write(3)
start.prop3.write(2)
start.prop4.write(1)
})
const after = [
end.prop1.read(),
end.prop2.read(),
end.prop3.read(),
end.prop4.read(),
]
return [before, after] as [number[], number[]]
}
for (const layers in expected) {
// @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
const [before, after] = cellx(framework, layers)
// @ts-expect-error - Framework object has incompatible type constraints with ReactiveFramework
const [expectedBefore, expectedAfter] = expected[layers]
expect(before.toString()).toBe(expectedBefore.toString())
expect(after.toString()).toBe(expectedAfter.toString())
}
})
})
}