2026-07-01 12:10:12 +02:00
|
|
|
import { describe, expect, it } from 'vitest'
|
2026-07-06 13:11:48 +02:00
|
|
|
import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells, stepCaSimulation } from '../admin/src/caEngines.js'
|
2026-07-01 12:10:12 +02:00
|
|
|
import type { Cells, SceneParams } from '../admin/src/types.js'
|
|
|
|
|
|
|
|
|
|
function cells(width: number, height: number, alive: Array<[number, number]> = []): Cells {
|
|
|
|
|
const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => false))
|
|
|
|
|
for (const [x, y] of alive) grid[y][x] = true
|
|
|
|
|
return grid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function settings(params: SceneParams = {}): SceneParams {
|
|
|
|
|
return {
|
|
|
|
|
...params,
|
|
|
|
|
simulation: {
|
|
|
|
|
engineId: 'game-of-life-2d',
|
|
|
|
|
ruleId: 'B3/S23',
|
|
|
|
|
neighborhoodId: 'moore',
|
|
|
|
|
...params.simulation
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('CA engine runtimes', () => {
|
|
|
|
|
it('registers built-in runtimes that can be selected by engine id', () => {
|
|
|
|
|
expect(listCaEngineRuntimes().map((engine) => engine.id)).toEqual(
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
'game-of-life-2d',
|
|
|
|
|
'outer-totalistic-2d',
|
|
|
|
|
'elementary-1d',
|
|
|
|
|
'wildfire-2d',
|
2026-07-06 13:11:48 +02:00
|
|
|
'naga-3d',
|
2026-07-01 12:10:12 +02:00
|
|
|
'generic-voxel-ca',
|
|
|
|
|
'noop'
|
|
|
|
|
])
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('allows a drop-in engine to register and step cells through the shared contract', () => {
|
|
|
|
|
registerCaEngineRuntime({
|
|
|
|
|
id: 'test-fill-runtime',
|
|
|
|
|
label: 'Test fill runtime',
|
|
|
|
|
step: (current) => current.map((row) => row.map(() => true))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(stepCaCells(cells(2, 2), settings({ simulation: { engineId: 'test-fill-runtime' } }))).toEqual([
|
|
|
|
|
[true, true],
|
|
|
|
|
[true, true]
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects duplicate engine runtime ids', () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
registerCaEngineRuntime({
|
|
|
|
|
id: 'game-of-life-2d',
|
|
|
|
|
label: 'Duplicate Life',
|
|
|
|
|
step: (current) => current
|
|
|
|
|
})
|
|
|
|
|
).toThrow('already registered')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects engine output that changes the grid contract', () => {
|
|
|
|
|
registerCaEngineRuntime({
|
|
|
|
|
id: 'test-invalid-shape-runtime',
|
|
|
|
|
label: 'Test invalid shape runtime',
|
|
|
|
|
step: () => [[true]]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(() => stepCaCells(cells(2, 2), settings({ simulation: { engineId: 'test-invalid-shape-runtime' } }))).toThrow(
|
|
|
|
|
'preserve the input grid dimensions'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not mutate the input cell grid while stepping', () => {
|
|
|
|
|
const current = cells(3, 3, [
|
|
|
|
|
[0, 1],
|
|
|
|
|
[1, 1],
|
|
|
|
|
[2, 1]
|
|
|
|
|
])
|
|
|
|
|
const before = current.map((row) => [...row])
|
|
|
|
|
|
|
|
|
|
const next = stepCaCells(current, settings())
|
|
|
|
|
|
|
|
|
|
expect(current).toEqual(before)
|
|
|
|
|
expect(next).not.toBe(current)
|
|
|
|
|
expect(next[0]).not.toBe(current[0])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('steps Conway Life oscillators with the outer-totalistic B/S rule contract', () => {
|
|
|
|
|
const blinker = cells(5, 5, [
|
|
|
|
|
[2, 1],
|
|
|
|
|
[2, 2],
|
|
|
|
|
[2, 3]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(stepCaCells(blinker, settings())).toEqual(
|
|
|
|
|
cells(5, 5, [
|
|
|
|
|
[1, 2],
|
|
|
|
|
[2, 2],
|
|
|
|
|
[3, 2]
|
|
|
|
|
])
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('steps elementary 1D rules on the active source row', () => {
|
|
|
|
|
const current = cells(3, 3, [[1, 0]])
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
stepCaCells(current, settings({ simulation: { engineId: 'elementary-1d', ruleId: 'Rule 90' } }))
|
|
|
|
|
).toEqual(cells(3, 3, [
|
|
|
|
|
[0, 0],
|
|
|
|
|
[2, 0]
|
|
|
|
|
]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses fixed boundaries for elementary 1D edge samples when requested', () => {
|
|
|
|
|
const current = cells(3, 3, [[0, 0]])
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
stepCaCells(current, settings({ simulation: { engineId: 'elementary-1d', ruleId: 'Rule 90', grid: { boundary: 'fixed' } } }))
|
|
|
|
|
).toEqual(cells(3, 3, [[1, 0]]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('steps binary wildfire by cooling burning cells and igniting von Neumann neighbours', () => {
|
|
|
|
|
const current = cells(3, 3, [[1, 1]])
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
stepCaCells(current, settings({ simulation: { engineId: 'wildfire-2d', spreadProbability: 1 } }))
|
|
|
|
|
).toEqual(cells(3, 3, [
|
|
|
|
|
[1, 0],
|
|
|
|
|
[0, 1],
|
|
|
|
|
[2, 1],
|
|
|
|
|
[1, 2]
|
|
|
|
|
]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lets binary wildfire suppress spread with deterministic probability settings', () => {
|
|
|
|
|
const current = cells(3, 3, [[1, 1]])
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
stepCaCells(current, settings({ simulation: { engineId: 'wildfire-2d', spreadProbability: 0 } }))
|
|
|
|
|
).toEqual(cells(3, 3))
|
|
|
|
|
})
|
2026-07-06 13:11:48 +02:00
|
|
|
|
|
|
|
|
it('steps Naga 3D sparse coordinates through the shared grid projection contract', () => {
|
|
|
|
|
const current = cells(5, 5)
|
|
|
|
|
const params = settings({
|
|
|
|
|
caClass: { dimensions: 3, states: 7 },
|
|
|
|
|
renderer: { id: 'voxel-3d' },
|
|
|
|
|
simulation: {
|
|
|
|
|
engineId: 'naga-3d',
|
|
|
|
|
ruleId: 'naga-3d',
|
|
|
|
|
grid: { size: [5, 5, 5], boundary: 'wrap' },
|
|
|
|
|
initialCondition: {
|
|
|
|
|
type: 'cells',
|
|
|
|
|
cells: [[2, 2, 2, 1]]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const stepped = stepCaSimulation(current, params)
|
|
|
|
|
|
|
|
|
|
expect(stepped.cells).toEqual(cells(5, 5, [[1, 2]]))
|
|
|
|
|
expect(params.simulation?.initialCondition?.cells).toEqual([[2, 2, 2, 1]])
|
|
|
|
|
expect(params.simulation?.nagaTick).toBeUndefined()
|
|
|
|
|
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[1, 2, 2, 1]])
|
|
|
|
|
expect(stepped.settings.simulation?.nagaTick).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('wraps Naga 3D sparse coordinates before computing voxel addresses', () => {
|
|
|
|
|
const current = cells(5, 5)
|
|
|
|
|
const params = settings({
|
|
|
|
|
caClass: { dimensions: 3, states: 7 },
|
|
|
|
|
renderer: { id: 'voxel-3d' },
|
|
|
|
|
simulation: {
|
|
|
|
|
engineId: 'naga-3d',
|
|
|
|
|
ruleId: 'naga-3d',
|
|
|
|
|
grid: { size: [5, 5, 5], boundary: 'wrap' },
|
|
|
|
|
initialCondition: {
|
|
|
|
|
type: 'cells',
|
|
|
|
|
cells: [[4, 2, 2, 4]]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const stepped = stepCaSimulation(current, params)
|
|
|
|
|
|
|
|
|
|
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[0, 2, 2, 4]])
|
|
|
|
|
expect(stepped.cells).toEqual(cells(5, 5, [[0, 2]]))
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-06 23:11:42 +02:00
|
|
|
it('uses simulation rng_seed to choose deterministic Naga heads', () => {
|
|
|
|
|
const current = cells(6, 6)
|
|
|
|
|
const baseParams = {
|
|
|
|
|
caClass: { dimensions: 3, states: 7 },
|
|
|
|
|
renderer: { id: 'voxel-3d' },
|
|
|
|
|
simulation: {
|
|
|
|
|
engineId: 'naga-3d',
|
|
|
|
|
ruleId: 'naga-3d',
|
|
|
|
|
grid: { size: [6, 6, 6], boundary: 'wrap' },
|
|
|
|
|
initialCondition: {
|
|
|
|
|
type: 'cells',
|
|
|
|
|
cells: [[1, 2, 2, 1], [4, 2, 2, 1]]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} satisfies SceneParams
|
|
|
|
|
|
|
|
|
|
const leftSeed = stepCaSimulation(current, settings({
|
|
|
|
|
...baseParams,
|
|
|
|
|
simulation: { ...baseParams.simulation, rng_seed: 'bar' }
|
|
|
|
|
}))
|
|
|
|
|
const rightSeed = stepCaSimulation(current, settings({
|
|
|
|
|
...baseParams,
|
|
|
|
|
simulation: { ...baseParams.simulation, rng_seed: 'alpha' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
expect(leftSeed.settings.simulation?.initialCondition?.cells).toEqual([[4, 2, 2, 1], [0, 2, 2, 1]])
|
|
|
|
|
expect(rightSeed.settings.simulation?.initialCondition?.cells).toEqual([[1, 2, 2, 1], [3, 2, 2, 1]])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-06 13:11:48 +02:00
|
|
|
it('keeps Naga 3D sparse coordinates in place when fixed boundaries would leave the grid', () => {
|
|
|
|
|
const current = cells(5, 5)
|
|
|
|
|
const params = settings({
|
|
|
|
|
caClass: { dimensions: 3, states: 7 },
|
|
|
|
|
renderer: { id: 'voxel-3d' },
|
|
|
|
|
simulation: {
|
|
|
|
|
engineId: 'naga-3d',
|
|
|
|
|
ruleId: 'naga-3d',
|
|
|
|
|
grid: { size: [5, 5, 5], boundary: 'fixed' },
|
|
|
|
|
initialCondition: {
|
|
|
|
|
type: 'cells',
|
|
|
|
|
cells: [[4, 2, 2, 4]]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const stepped = stepCaSimulation(current, params)
|
|
|
|
|
|
|
|
|
|
expect(stepped.settings.simulation?.initialCondition?.cells).toEqual([[4, 2, 2, 4]])
|
|
|
|
|
expect(stepped.cells).toEqual(cells(5, 5, [[4, 2]]))
|
|
|
|
|
})
|
2026-07-01 12:10:12 +02:00
|
|
|
|
|
|
|
|
it('defaults boundaries to wrap when the node does not specify a boundary condition', () => {
|
|
|
|
|
const current = cells(3, 3, [
|
|
|
|
|
[2, 2],
|
|
|
|
|
[2, 0],
|
|
|
|
|
[0, 2]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(stepCaCells(current, settings())[0][0]).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('supports fixed boundaries and legacy wrap=false nodes', () => {
|
|
|
|
|
const current = cells(3, 3, [
|
|
|
|
|
[2, 2],
|
|
|
|
|
[2, 0],
|
|
|
|
|
[0, 2]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(stepCaCells(current, settings({ simulation: { grid: { boundary: 'fixed' } } }))[0][0]).toBe(false)
|
|
|
|
|
expect(stepCaCells(current, settings({ simulation: { grid: { wrap: false } } }))[0][0]).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('supports mirror boundaries by reflecting out-of-bounds neighbour samples', () => {
|
|
|
|
|
const current = cells(3, 3, [
|
|
|
|
|
[0, 0],
|
|
|
|
|
[1, 0],
|
|
|
|
|
[0, 1]
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(stepCaCells(current, settings({ simulation: { grid: { boundary: 'fixed' } } }))[0][0]).toBe(true)
|
|
|
|
|
expect(stepCaCells(current, settings({ simulation: { grid: { boundary: 'mirror' } } }))[0][0]).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|