mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
208 lines
8.9 KiB
TypeScript
208 lines
8.9 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { State } from "@opencode/core/state"
|
|
import { Effect } from "effect"
|
|
import FastCheck from "fast-check"
|
|
|
|
type Operation = { multiply: number; add: number }
|
|
type Value = { value: number; order: number[] }
|
|
|
|
const operation = FastCheck.record({
|
|
multiply: FastCheck.constantFrom(-3, -2, 2, 3),
|
|
add: FastCheck.integer({ min: -9, max: 9 }),
|
|
})
|
|
const source = FastCheck.integer({ min: -100, max: 100 })
|
|
const target = FastCheck.integer({ min: 0, max: 1 })
|
|
const command = FastCheck.oneof(
|
|
{
|
|
weight: 4,
|
|
arbitrary: FastCheck.record({ type: FastCheck.constant("append"), target, callback: FastCheck.nat(5) }),
|
|
},
|
|
{ weight: 3, arbitrary: FastCheck.record({ type: FastCheck.constant("read"), target }) },
|
|
{
|
|
weight: 2,
|
|
arbitrary: FastCheck.record({ type: FastCheck.constant("dispose"), target, registration: FastCheck.nat(100) }),
|
|
},
|
|
{ weight: 2, arbitrary: FastCheck.record({ type: FastCheck.constant("reload"), target, source }) },
|
|
)
|
|
|
|
// Affine transforms do not generally commute; the modulus keeps long traces exact.
|
|
function apply(value: number, operation: Operation) {
|
|
return (value * operation.multiply + operation.add) % 10_007
|
|
}
|
|
|
|
const parameters = { numRuns: 300 }
|
|
|
|
describe("State replay properties", () => {
|
|
test("matches a full fold across reads, registrations, removals and batched reloads", () =>
|
|
FastCheck.assert(
|
|
FastCheck.property(
|
|
FastCheck.tuple(source, source),
|
|
FastCheck.array(operation, { minLength: 1, maxLength: 6 }),
|
|
FastCheck.array(FastCheck.array(command, { maxLength: 48 }), { minLength: 1, maxLength: 8 }),
|
|
(initial, operations, batches) =>
|
|
Effect.gen(function* () {
|
|
const sources = [...initial]
|
|
const notifications = [0, 0]
|
|
let calls = 0
|
|
const states = sources.map((_, index) =>
|
|
State.create({
|
|
initial: (): Value => ({ value: sources[index], order: [] }),
|
|
editor: (editor) => editor,
|
|
notify: () => Effect.sync(() => void notifications[index]++),
|
|
}),
|
|
)
|
|
const callbacks = operations.map((operation, index) => (editor: Value) => {
|
|
calls++
|
|
editor.value = apply(editor.value, operation)
|
|
editor.order.push(index)
|
|
})
|
|
const registrations: { handle: State.Registration; callback: number; active: boolean }[][] = [[], []]
|
|
const expected = (index: number): Value => {
|
|
const order = registrations[index].filter((entry) => entry.active).map((entry) => entry.callback)
|
|
return {
|
|
value: order.reduce((value, callback) => apply(value, operations[callback]), sources[index]),
|
|
order,
|
|
}
|
|
}
|
|
|
|
yield* Effect.forEach(
|
|
batches,
|
|
(commands) =>
|
|
Effect.gen(function* () {
|
|
const before = [...notifications]
|
|
const dirty = new Set<number>()
|
|
yield* State.batch(
|
|
Effect.gen(function* () {
|
|
yield* Effect.forEach(
|
|
commands,
|
|
(command) =>
|
|
Effect.gen(function* () {
|
|
const state = states[command.target]
|
|
switch (command.type) {
|
|
case "append": {
|
|
const callback = command.callback % callbacks.length
|
|
const handle = yield* state.transform(callbacks[callback])
|
|
registrations[command.target].push({ handle, callback, active: true })
|
|
dirty.add(command.target)
|
|
return
|
|
}
|
|
case "read":
|
|
expect(state.get()).toEqual(expected(command.target))
|
|
return
|
|
case "dispose": {
|
|
const entries = registrations[command.target]
|
|
if (!entries.length) return
|
|
const entry = entries[command.registration % entries.length]
|
|
yield* entry.handle.dispose
|
|
if (!entry.active) return
|
|
entry.active = false
|
|
dirty.add(command.target)
|
|
return
|
|
}
|
|
case "reload":
|
|
sources[command.target] = command.source
|
|
yield* state.reload()
|
|
dirty.add(command.target)
|
|
}
|
|
}),
|
|
{ discard: true },
|
|
)
|
|
expect(notifications).toEqual(before)
|
|
}),
|
|
)
|
|
expect(notifications).toEqual(before.map((count, index) => count + Number(dirty.has(index))))
|
|
const flushed = calls
|
|
states.forEach((state, index) => expect(state.get()).toEqual(expected(index)))
|
|
expect(calls).toBe(flushed)
|
|
}),
|
|
{ discard: true },
|
|
)
|
|
}).pipe(Effect.scoped, Effect.runSync),
|
|
),
|
|
parameters,
|
|
))
|
|
|
|
test("rebuilds all active callbacks once per change, reads for free otherwise, and never touches retained values", () =>
|
|
FastCheck.assert(
|
|
FastCheck.property(
|
|
FastCheck.array(
|
|
FastCheck.record({
|
|
append: FastCheck.array(operation, { minLength: 1, maxLength: 8 }),
|
|
reads: FastCheck.integer({ min: 1, max: 5 }),
|
|
}),
|
|
{ minLength: 1, maxLength: 8 },
|
|
),
|
|
FastCheck.nat(100),
|
|
(chunks, removal) =>
|
|
Effect.gen(function* () {
|
|
let calls = 0
|
|
const state = State.create({
|
|
initial: () => ({ value: 1, order: new Array<number>() }),
|
|
editor: (editor) => editor,
|
|
})
|
|
const registrations: State.Registration[] = []
|
|
const retained: { value: Value; snapshot: Value }[] = []
|
|
let settled = 0
|
|
const remember = () => {
|
|
const value = state.get()
|
|
retained.push({ value, snapshot: { value: value.value, order: [...value.order] } })
|
|
}
|
|
yield* State.batch(
|
|
Effect.gen(function* () {
|
|
yield* Effect.forEach(
|
|
chunks,
|
|
(chunk) =>
|
|
Effect.gen(function* () {
|
|
const before = calls
|
|
const added = yield* Effect.forEach(chunk.append, (operation) =>
|
|
state.transform((editor) => {
|
|
calls++
|
|
editor.value = apply(editor.value, operation)
|
|
editor.order.push(editor.order.length)
|
|
}),
|
|
)
|
|
registrations.push(...added)
|
|
expect(calls).toBe(before)
|
|
// The first read after a change replays every active callback; later reads do nothing.
|
|
state.get()
|
|
expect(calls).toBe(before + registrations.length)
|
|
Array.from({ length: chunk.reads }).forEach(() => {
|
|
const again = state.get()
|
|
expect(again).toBe(state.get())
|
|
})
|
|
expect(calls).toBe(before + registrations.length)
|
|
remember()
|
|
}),
|
|
{ discard: true },
|
|
)
|
|
|
|
const removed = registrations[removal % registrations.length]
|
|
yield* removed.dispose
|
|
const before = calls
|
|
state.get()
|
|
expect(calls).toBe(before + registrations.length - 1)
|
|
remember()
|
|
|
|
const replayed = calls
|
|
yield* removed.dispose
|
|
state.get()
|
|
expect(calls).toBe(replayed)
|
|
|
|
yield* state.reload()
|
|
yield* state.reload()
|
|
expect(calls).toBe(replayed)
|
|
settled = replayed
|
|
}),
|
|
)
|
|
// Batch end notifies, and the two reloads left the value dirty: one more full rebuild.
|
|
expect(calls).toBe(settled + registrations.length - 1)
|
|
state.get()
|
|
expect(calls).toBe(settled + registrations.length - 1)
|
|
retained.forEach((entry) => expect(entry.value).toEqual(entry.snapshot))
|
|
expect(new Set(retained.map((entry) => entry.value)).size).toBe(retained.length)
|
|
}).pipe(Effect.scoped, Effect.runSync),
|
|
),
|
|
parameters,
|
|
))
|
|
})
|