Tldraw/packages/state/src/lib/core/__tests__/fuzz.tlstate.test.ts

374 wiersze
10 KiB
TypeScript

import { times } from 'lodash'
import { Atom, atom, isAtom } from '../Atom'
import { Computed, computed, isComputed } from '../Computed'
import { Reactor, reactor } from '../EffectScheduler'
import { transact } from '../transactions'
import { Signal } from '../types'
class RandomSource {
private seed: number
constructor(seed: number) {
this.seed = seed
}
nextFloat(): number {
this.seed = (this.seed * 9301 + 49297) % 233280
return this.seed / 233280
}
nextInt(max: number): number {
return Math.floor(this.nextFloat() * max)
}
nextIntInRange(min: number, max: number): number {
return this.nextInt(max - min) + min
}
nextId(): string {
return this.nextInt(Number.MAX_SAFE_INTEGER).toString(36)
}
selectOne<T>(arr: readonly T[]): T {
return arr[this.nextInt(arr.length)]
}
choice(probability: number): boolean {
return this.nextFloat() < probability
}
executeOne<Result>(
_choices: Record<string, (() => Result) | { weight?: number; do(): Result }>
): Result {
const choices = Object.values(_choices).map((choice) => {
if (typeof choice === 'function') {
return { weight: 1, do: choice }
}
return choice
})
const totalWeight = Object.values(choices).reduce(
(total, choice) => total + (choice.weight ?? 1),
0
)
const randomWeight = this.nextInt(totalWeight)
let weight = 0
for (const choice of Object.values(choices)) {
weight += choice.weight ?? 1
if (randomWeight < weight) {
return choice.do()
}
}
throw new Error('unreachable')
}
}
const LETTERS = ['a', 'b', 'c', 'd', 'e', 'f'] as const
type Letter = (typeof LETTERS)[number]
const unpack = (value: unknown): Letter => {
if (isComputed(value) || isAtom(value)) {
return unpack(value.get()) as Letter
}
return value as Letter
}
type FuzzSystemState = {
atoms: Record<string, Atom<Letter>>
atomsInAtoms: Record<string, Atom<Atom<Letter>>>
derivations: Record<string, { derivation: Computed<Letter>; sneakyGet: () => Letter }>
derivationsInDerivations: Record<string, Computed<Computed<Letter>>>
atomsInDerivations: Record<string, Computed<Atom<Letter>>>
reactors: Record<string, { reactor: Reactor; result: string | null; dependencies: Signal<any>[] }>
}
type Op =
| { type: 'update_atom'; id: string; value: Letter }
| { type: 'update_atom_in_atom'; id: string; atomId: string }
| { type: 'deref_derivation'; id: string }
| { type: 'deref_derivation_in_derivation'; id: string }
| { type: 'deref_atom_in_derivation'; id: string }
| { type: 'run_several_ops_in_transaction'; ops: Op[] }
| { type: 'start_reactor'; id: string }
| { type: 'stop_reactor'; id: string }
const MAX_ATOMS = 10
const MAX_ATOMS_IN_ATOMS = 10
const MAX_DERIVATIONS = 10
const MAX_DERIVATIONS_IN_DERIVATIONS = 10
const MAX_ATOMS_IN_DERIVATIONS = 10
const MAX_REACTORS = 10
const MAX_DEPENDENCIES_PER_ATOM = 3
const MAX_OPS_IN_TRANSACTION = 10
class Test {
source: RandomSource
systemState: FuzzSystemState = {
atoms: {},
atomsInAtoms: {},
derivations: {},
derivationsInDerivations: {},
atomsInDerivations: {},
reactors: {},
}
unpack_sneaky = (value: unknown): Letter => {
if (isComputed(value)) {
if (this.systemState.derivations[value.name]) {
return this.systemState.derivations[value.name].sneakyGet()
}
// @ts-expect-error
return this.unpack_sneaky(value.state) as Letter
} else if (isAtom(value)) {
// @ts-expect-error
return this.unpack_sneaky(value.current) as Letter
}
return value as Letter
}
getResultComparisons() {
const result: { expected: Record<string, string>; actual: Record<string, string | null> } = {
expected: {},
actual: {},
}
for (const [reactorId, { reactor, result: actualResult, dependencies }] of Object.entries(
this.systemState.reactors
)) {
if (!reactor.scheduler.isActivelyListening) continue
result.expected[reactorId] = dependencies.map(this.unpack_sneaky).join(':')
result.actual[reactorId] = actualResult
}
return result
}
constructor(seed: number) {
this.source = new RandomSource(seed)
times(this.source.nextIntInRange(1, MAX_ATOMS), () => {
const atomId = this.source.nextId()
this.systemState.atoms[atomId] = atom(atomId, this.source.selectOne(LETTERS))
})
times(this.source.nextIntInRange(1, MAX_ATOMS_IN_ATOMS), () => {
const atomId = this.source.nextId()
this.systemState.atomsInAtoms[atomId] = atom(
atomId,
this.source.selectOne(Object.values(this.systemState.atoms))
)
})
times(this.source.nextIntInRange(1, MAX_ATOMS_IN_DERIVATIONS), () => {
const derivationId = this.source.nextId()
const atom = this.source.selectOne(Object.values(this.systemState.atoms))
this.systemState.atomsInDerivations[derivationId] = computed(derivationId, () => atom)
})
times(this.source.nextIntInRange(1, MAX_DERIVATIONS), () => {
const derivationId = this.source.nextId()
const derivables = [
...Object.values(this.systemState.atoms),
...Object.values(this.systemState.atomsInAtoms),
...Object.values(this.systemState.atomsInDerivations),
...Object.values(this.systemState.derivations),
]
const inputA = this.source.selectOne(derivables)
const inputB = this.source.selectOne(derivables)
const inputC = this.source.selectOne(derivables)
const inputD = this.source.selectOne(derivables)
this.systemState.derivations[derivationId] = {
derivation: computed(derivationId, () => {
if (unpack(inputA) === unpack(inputB)) {
return unpack(inputC)
} else {
return unpack(inputD)
}
}),
sneakyGet: () => {
if (this.unpack_sneaky(inputA) === this.unpack_sneaky(inputB)) {
return this.unpack_sneaky(inputC)
} else {
return this.unpack_sneaky(inputD)
}
},
}
})
times(this.source.nextIntInRange(1, MAX_DERIVATIONS_IN_DERIVATIONS), () => {
const derivationId = this.source.nextId()
this.systemState.derivationsInDerivations[derivationId] = computed(derivationId, () =>
this.source.selectOne(Object.values(this.systemState.derivations).map((d) => d.derivation))
)
})
times(this.source.nextIntInRange(1, MAX_REACTORS), () => {
const reactorId = this.source.nextId()
const dependencies: Signal<any>[] = []
times(this.source.nextIntInRange(1, MAX_DEPENDENCIES_PER_ATOM), () => {
this.source.executeOne({
'add a random atom': () => {
dependencies.push(this.source.selectOne(Object.values(this.systemState.atoms)))
},
'add a random atom in atom': () => {
dependencies.push(this.source.selectOne(Object.values(this.systemState.atomsInAtoms)))
},
'add a random derivation': () => {
dependencies.push(
this.source.selectOne(
Object.values(this.systemState.derivations).map((d) => d.derivation)
)
)
},
'add a random derivation in derivation': () => {
dependencies.push(
this.source.selectOne(Object.values(this.systemState.derivationsInDerivations))
)
},
'add a random atom in derivation': () => {
dependencies.push(
this.source.selectOne(Object.values(this.systemState.atomsInDerivations))
)
},
})
dependencies.push(this.source.selectOne(Object.values(this.systemState.atoms)))
})
this.systemState.reactors[reactorId] = {
reactor: reactor(reactorId, () => {
this.systemState.reactors[reactorId].result = dependencies.map(unpack).join(':')
}),
result: '',
dependencies,
}
})
}
readonly ops: Op[] = []
getNextOp(): Op {
return this.source.executeOne<Op>({
'update atom': () => {
return {
type: 'update_atom',
id: this.source.selectOne(Object.keys(this.systemState.atoms)),
value: this.source.selectOne(LETTERS),
}
},
'update atom in atom': () => {
return {
type: 'update_atom_in_atom',
id: this.source.selectOne(Object.keys(this.systemState.atomsInAtoms)),
atomId: this.source.selectOne(Object.keys(this.systemState.atoms)),
}
},
'deref atom in derivation': () => {
return {
type: 'deref_atom_in_derivation',
id: this.source.selectOne(Object.keys(this.systemState.atomsInDerivations)),
}
},
'deref derivation in derivation': () => {
return {
type: 'deref_derivation_in_derivation',
id: this.source.selectOne(Object.keys(this.systemState.derivationsInDerivations)),
}
},
'deref derivation': () => {
return {
type: 'deref_derivation',
id: this.source.selectOne(Object.keys(this.systemState.derivations)),
}
},
'run several ops in a transaction': () => {
return {
type: 'run_several_ops_in_transaction',
ops: times(this.source.nextIntInRange(2, MAX_OPS_IN_TRANSACTION), () => this.getNextOp()),
}
},
start_reactor: () => {
return {
type: 'start_reactor',
id: this.source.selectOne(Object.keys(this.systemState.reactors)),
}
},
stop_reactor: () => {
return {
type: 'stop_reactor',
id: this.source.selectOne(Object.keys(this.systemState.reactors)),
}
},
})
}
applyOp(op: Op) {
switch (op.type) {
case 'update_atom': {
this.systemState.atoms[op.id].set(op.value)
break
}
case 'deref_atom_in_derivation': {
this.systemState.atomsInDerivations[op.id].get()
break
}
case 'deref_derivation': {
this.systemState.derivations[op.id].derivation.get()
break
}
case 'deref_derivation_in_derivation': {
this.systemState.derivationsInDerivations[op.id].get()
break
}
case 'update_atom_in_atom': {
this.systemState.atomsInAtoms[op.id].set(this.systemState.atoms[op.atomId])
break
}
case 'run_several_ops_in_transaction': {
transact(() => {
op.ops.forEach((op) => this.applyOp(op))
})
break
}
case 'start_reactor': {
this.systemState.reactors[op.id].reactor.start()
break
}
case 'stop_reactor': {
this.systemState.reactors[op.id].reactor.stop()
break
}
default: {
throw new Error(`Unknown op type: ${op}`)
}
}
}
tick() {
const op = this.getNextOp()
this.ops.push(op)
this.applyOp(op)
}
}
const NUM_TESTS = 20
const NUM_OPS_PER_TEST = 1000
function runTest(seed: number) {
const test = new Test(seed)
for (let i = 0; i < NUM_OPS_PER_TEST; i++) {
test.tick()
const { expected, actual } = test.getResultComparisons()
expect(expected).toEqual(actual)
}
}
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * 1000000)
test('fuzzzzzz ' + seed, () => {
runTest(seed)
})
}
test('regression 728608', () => {
runTest(728608)
})