Adding the lab project

This commit is contained in:
2026-07-01 12:10:12 +02:00
commit 48ce52b52c
56 changed files with 22289 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { listCaEngineRuntimes, registerCaEngineRuntime, stepCaCells } from '../admin/src/caEngines.js'
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',
'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))
})
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)
})
})
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
generateInitialCondition,
getInitialConditionRuntime,
listInitialConditionRuntimes,
registerInitialConditionRuntime
} from '../admin/src/caInitialConditionGenerators.js'
describe('CA initial condition runtimes', () => {
it('registers the built-in random soup generator', () => {
expect(listInitialConditionRuntimes().map((runtime) => runtime.id)).toContain('random-soup')
expect(getInitialConditionRuntime('random-soup')?.label).toBe('Random soup')
})
it('generates deterministic cell coordinates for the same seed and density', () => {
const input = {
caClass: { dimensions: 2, states: 2 },
settings: { simulation: { grid: { size: [5, 5, 1] } } },
params: { density: 0.35, seed: 'repeatable' }
}
expect(generateInitialCondition('random-soup', input)).toEqual(generateInitialCondition('random-soup', input))
})
it('clamps density and records generator provenance in the persisted initial condition', () => {
const generated = generateInitialCondition('random-soup', {
caClass: { dimensions: 2, states: 2 },
settings: { simulation: { grid: { size: [3, 3, 1] } } },
params: { density: 2, seed: 'full' }
})
expect(generated.cells).toHaveLength(9)
expect(generated.generator).toEqual({ kind: 'random-soup', density: 1, seed: 'full' })
})
it('lets custom initial condition runtimes plug into the same contract', () => {
registerInitialConditionRuntime({
id: 'test-single-cell',
label: 'Test single cell',
generate: () => ({ type: 'cells', cells: [[1, 1, 0]], generator: { kind: 'test-single-cell' } })
})
expect(generateInitialCondition('test-single-cell', {
caClass: { dimensions: 2, states: 2 },
settings: {},
params: {}
}).cells).toEqual([[1, 1, 0]])
})
it('rejects duplicate runtime ids and missing implementations', () => {
expect(() =>
registerInitialConditionRuntime({
id: 'random-soup',
label: 'Duplicate random soup',
generate: () => ({ type: 'cells', cells: [], generator: { kind: 'random-soup' } })
})
).toThrow('already registered')
expect(() =>
generateInitialCondition('missing-generator', {
caClass: { dimensions: 2, states: 2 },
settings: {},
params: {}
})
).toThrow('unavailable')
})
})
+76
View File
@@ -0,0 +1,76 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import {
CaRenderer,
getCaRendererRuntime,
listCompatibleCaRendererRuntimes,
listCaRendererRuntimes,
registerCaRendererRuntime
} from '../admin/src/CaRenderer.js'
describe('CA renderer runtimes', () => {
it('registers built-in renderers and resolves aliases', () => {
expect(listCaRendererRuntimes().map((runtime) => runtime.id)).toEqual(
expect.arrayContaining(['2d-canvas', 'elementary-1d', 'wildfire-2d', 'voxel-3d'])
)
expect(getCaRendererRuntime('three-voxel')?.id).toBe('voxel-3d')
expect(getCaRendererRuntime('elementary-ca')?.id).toBe('elementary-1d')
expect(getCaRendererRuntime('forest-fire-2d')?.id).toBe('wildfire-2d')
})
it('lets custom renderers register with the shared renderer contract', () => {
registerCaRendererRuntime({
id: 'test-renderer',
label: 'Test renderer',
supportedClasses: [{ dimensions: 2, states: 3 }],
Component: function TestRenderer() {
return React.createElement('div', null)
}
})
expect(getCaRendererRuntime('test-renderer')?.label).toBe('Test renderer')
})
it('filters renderers by dimensionality and state space', () => {
expect(listCompatibleCaRendererRuntimes({ dimensions: 3, states: 2 }).map((runtime) => runtime.id)).toEqual([
'voxel-3d'
])
expect(listCompatibleCaRendererRuntimes({ dimensions: 1, states: 2 }).map((runtime) => runtime.id)).toEqual([
'elementary-1d'
])
expect(listCompatibleCaRendererRuntimes({ dimensions: 2, states: 3 }).map((runtime) => runtime.id)).toContain(
'test-renderer'
)
})
it('refuses to render a runtime for an incompatible CA space', () => {
const markup = renderToStaticMarkup(
React.createElement(CaRenderer, {
caption: '',
cells: [[false]],
settings: {
caClass: { dimensions: 3, states: 2 },
renderer: { id: '2d-canvas' }
},
onCellsChange: () => undefined
})
)
expect(markup).toContain('does not support this CA space')
expect(markup).not.toContain('Game of Life cell editor')
})
it('rejects duplicate renderer ids', () => {
expect(() =>
registerCaRendererRuntime({
id: '2d-canvas',
label: 'Duplicate canvas',
supportedClasses: [{ dimensions: 2, states: 2 }],
Component: function DuplicateRenderer() {
return React.createElement('div', null)
}
})
).toThrow('already registered')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
assertSameCellsShape,
getBoundaryCondition,
getCaClassFromParams,
numericGridSize,
resolveBoundaryIndex,
supportsCaClass
} from '../admin/src/caRuntime.js'
import type { Cells } from '../admin/src/types.js'
describe('CA runtime helpers', () => {
it('extracts CA class metadata from resolved scene params', () => {
expect(
getCaClassFromParams({
caClass: { dimensions: 2, states: 2, neighborhoodId: 'moore' }
})
).toEqual({ dimensions: 2, states: 2, neighborhoodId: 'moore' })
})
it('matches runtimes by dimensions and states without requiring neighbourhood', () => {
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 2, states: 2 }] },
{ dimensions: 2, states: 2, neighborhoodId: 'von-neumann' }
)
).toBe(true)
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 3, states: 2 }] },
{ dimensions: 2, states: 2 }
)
).toBe(false)
})
it('honors engine neighbourhood metadata when supplied', () => {
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 2, states: 2, neighborhoodId: 'moore' }] },
{ dimensions: 2, states: 2, neighborhoodId: 'moore' }
)
).toBe(true)
expect(
supportsCaClass(
{ supported_classes: [{ dimensions: 2, states: 2, neighborhoodId: 'moore' }] },
{ dimensions: 2, states: 2, neighborhoodId: 'von-neumann' }
)
).toBe(false)
})
it('defaults boundary handling to wrap and keeps legacy wrap=false as fixed', () => {
expect(getBoundaryCondition({ simulation: { grid: {} } })).toBe('wrap')
expect(getBoundaryCondition({ simulation: { grid: { wrap: false } } })).toBe('fixed')
expect(getBoundaryCondition({ simulation: { grid: { boundary: 'mirror' } } })).toBe('mirror')
})
it('resolves wrap, mirror, and fixed boundary indexes', () => {
expect(resolveBoundaryIndex(-1, 5, 'wrap')).toBe(4)
expect(resolveBoundaryIndex(-1, 5, 'mirror')).toBe(1)
expect(resolveBoundaryIndex(-1, 5, 'fixed')).toBeNull()
})
it('resolves grid sizes for 2D and 3D classes', () => {
expect(numericGridSize({ size: [7, 8, 9] }, 2)).toEqual([7, 8, 1])
expect(numericGridSize({ size: [7, 8, 9] }, 3)).toEqual([7, 8, 9])
})
it('rejects invalid engine output shapes', () => {
const input: Cells = [
[false, true],
[true, false]
]
expect(() => assertSameCellsShape(input, [[true]], 'test output')).toThrow('preserve the input grid dimensions')
expect(() => assertSameCellsShape(input, [[true], [false, true]], 'test output')).toThrow('rectangular')
})
})
+548
View File
@@ -0,0 +1,548 @@
import request from 'supertest'
import { describe, expect, it } from 'vitest'
import { createCaStudioApi } from '../src/caStudioApi.js'
import { createTestDb } from './testDb.js'
describe('CA Studio API', () => {
it('lists preset nodes whose resolved settings use an engine', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const engine = await request(app)
.post('/api/ca/engines')
.send({
slug: 'usage-engine',
name: 'Usage Engine',
engineKind: 'usage-engine-kind',
supportedClasses: [{ dimensions: 2, states: 2 }]
})
.expect(201)
.then((response) => response.body)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'usage-tree', name: 'Usage Tree' })
.expect(201)
.then((response) => response.body)
const root = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: { simulation: { engineId: 'usage-engine-kind' } }
})
.expect(201)
.then((response) => response.body)
const inherited = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'inherited',
name: 'Inherited',
kind: 'shot',
params: {}
})
.expect(201)
.then((response) => response.body)
await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'unset',
name: 'Unset',
kind: 'shot',
params: { simulation: { engineId: null } }
})
.expect(201)
await request(app)
.get(`/api/ca/engines/${engine.id}/usage`)
.expect(200)
.expect((response) => {
expect(response.body).toEqual([
{
tree_id: tree.id,
tree_name: 'Usage Tree',
node_id: inherited.id,
node_name: 'Inherited',
node_kind: 'shot'
},
{
tree_id: tree.id,
tree_name: 'Usage Tree',
node_id: root.id,
node_name: 'Root',
node_kind: 'preset_root'
}
])
})
})
it('creates an empty slide and assigns a CA preset later', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'deferred-slide-tree', name: 'Deferred Slide Tree' })
.expect(201)
.then((response) => response.body)
const preset = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'deferred-preset',
name: 'Deferred Preset',
kind: 'shot',
params: { simulation: { ruleId: 'B3/S23' } }
})
.expect(201)
.then((response) => response.body)
const deck = await request(app)
.post('/api/ca/decks')
.send({ slug: 'deferred-slide-deck', title: 'Deferred Slide Deck' })
.expect(201)
.then((response) => response.body)
const scene = await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 1,
title: 'Empty Slide',
presetTreeId: null,
presetNodeId: null,
params: { caption: 'Choose a CA later' }
})
.expect(201)
.then((response) => response.body)
await request(app)
.get(`/api/ca/decks/${deck.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.scenes[0]).toMatchObject({
scene: {
preset_tree_id: null,
preset_node_id: null
},
ancestry: [],
params: { caption: 'Choose a CA later' }
})
})
await request(app)
.patch(`/api/ca/scenes/${scene.id}`)
.send({ presetTreeId: tree.id, presetNodeId: preset.id })
.expect(200)
await request(app)
.get(`/api/ca/scenes/${scene.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params).toMatchObject({
simulation: { ruleId: 'B3/S23' },
caption: 'Choose a CA later'
})
})
})
it('lists preset libraries with node counts and deletes empty libraries', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const emptyTree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'empty-library', name: 'Empty Library' })
.expect(201)
const populatedTree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'populated-library', name: 'Populated Library' })
.expect(201)
await request(app)
.post(`/api/ca/preset-trees/${populatedTree.body.id}/nodes`)
.send({ slug: 'root', name: 'Root', kind: 'preset_root' })
.expect(201)
await request(app)
.get('/api/ca/preset-trees')
.expect(200)
.expect((response) => {
const counts = Object.fromEntries(
response.body.map((tree: { slug: string; node_count: number }) => [tree.slug, tree.node_count])
)
expect(counts).toMatchObject({
'empty-library': 0,
'populated-library': 1
})
})
await request(app).delete(`/api/ca/preset-trees/${emptyTree.body.id}`).expect(204)
await request(app).delete(`/api/ca/preset-trees/${emptyTree.body.id}`).expect(404)
})
it('registers and filters initial condition generators by CA class', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.post('/api/ca/initial-condition-generators')
.send({
slug: 'life-random-soup-api',
name: 'Life Random Soup',
generatorKind: 'random-soup',
supportedClasses: [{ neighborhoodId: 'moore', dimensions: 2, states: 2 }],
defaultParams: { density: 0.33 }
})
.expect(201)
await request(app)
.post('/api/ca/initial-condition-generators')
.send({
slug: 'lattice-gas-api',
name: 'Lattice Gas Seed',
generatorKind: 'lattice-gas-random',
supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 7 }]
})
.expect(201)
await request(app)
.get('/api/ca/initial-condition-generators?neighborhoodId=moore&dimensions=2&states=2')
.expect(200)
.expect((response) => {
expect(response.body.map((generator: { slug: string }) => generator.slug)).toEqual([
'life-random-soup-api'
])
})
})
it('creates a tree, nodes, deck, scene, and returns resolved scene config', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const treeResponse = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'api-tree', name: 'API Tree' })
.expect(201)
const treeId = treeResponse.body.id as string
const rootResponse = await request(app)
.post(`/api/ca/preset-trees/${treeId}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: { simulation: { ruleId: 'life-3d' } }
})
.expect(201)
const sceneBaseResponse = await request(app)
.post(`/api/ca/preset-trees/${treeId}/nodes`)
.send({
parentId: rootResponse.body.id,
slug: 'glider',
name: 'Glider',
kind: 'shot',
params: { simulation: { initialCondition: { patternId: 'glider' } } }
})
.expect(201)
await request(app)
.get(`/api/ca/preset-trees/${treeId}/nodes/${sceneBaseResponse.body.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params.simulation).toEqual({
ruleId: 'life-3d',
initialCondition: { patternId: 'glider' }
})
})
const deckResponse = await request(app)
.post('/api/ca/decks')
.send({ slug: 'api-deck', title: 'API Deck' })
.expect(201)
await request(app)
.get(`/api/ca/preset-trees/${treeId}/node-usage`)
.expect(200)
.expect([])
const sceneResponse = await request(app)
.post(`/api/ca/decks/${deckResponse.body.id}/scenes`)
.send({
orderIndex: 1,
title: 'Glider Recording',
presetTreeId: treeId,
presetNodeId: sceneBaseResponse.body.id,
applyMode: 'reinitialize',
params: { camera: { mode: '2d' } }
})
.expect(201)
await request(app)
.get(`/api/ca/preset-trees/${treeId}/node-usage`)
.expect(200)
.expect([{ node_id: sceneBaseResponse.body.id, scene_count: 1 }])
await request(app)
.get(`/api/ca/scenes/${sceneResponse.body.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params).toEqual({
simulation: {
ruleId: 'life-3d',
initialCondition: { patternId: 'glider' }
},
camera: { mode: '2d' }
})
})
})
it('supports editor CRUD requests for decks, scenes, and preset-driven reloads', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'editor-tree', name: 'Editor Tree' })
.expect(201)
.then((response) => response.body)
const root = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d', grid: { size: [32, 32, 1] } },
renderer: { id: 'classic-voxels' }
}
})
.expect(201)
.then((response) => response.body)
const shot = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'blinker' } },
camera: { mode: '2d', zoom: 1 }
}
})
.expect(201)
.then((response) => response.body)
const deck = await request(app)
.post('/api/ca/decks')
.send({ slug: 'editor-deck', title: 'Editor Deck' })
.expect(201)
.then((response) => response.body)
await request(app)
.patch(`/api/ca/decks/${deck.id}`)
.send({ title: 'Updated Editor Deck', params: { recording: { fps: 60 } } })
.expect(200)
.expect((response) => {
expect(response.body.title).toBe('Updated Editor Deck')
expect(response.body.params).toEqual({ recording: { fps: 60 } })
})
await request(app)
.get('/api/ca/decks')
.expect(200)
.expect((response) => {
expect(response.body.map((item: { slug: string }) => item.slug)).toEqual(['editor-deck'])
})
const scene = await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 1,
title: 'Blinker Recording',
presetTreeId: tree.id,
presetNodeId: shot.id,
params: { camera: { zoom: 1.4 } }
})
.expect(201)
.then((response) => response.body)
await request(app)
.get(`/api/ca/scenes/${scene.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params).toEqual({
simulation: {
ruleId: 'life-3d',
grid: { size: [32, 32, 1] },
initialCondition: { patternId: 'blinker' }
},
renderer: { id: 'classic-voxels' },
camera: { mode: '2d', zoom: 1.4 }
})
})
await request(app)
.patch(`/api/ca/preset-trees/${tree.id}/nodes/${shot.id}`)
.send({
params: {
simulation: { initialCondition: { patternId: 'glider' } },
camera: { mode: '2d', zoom: 0.75 }
}
})
.expect(200)
await request(app)
.get(`/api/ca/scenes/${scene.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.params.camera).toEqual({ mode: '2d', zoom: 1.4 })
expect(response.body.params.simulation.initialCondition).toEqual({ patternId: 'glider' })
})
await request(app)
.patch(`/api/ca/scenes/${scene.id}`)
.send({
orderIndex: 2,
title: 'Glider Patch',
applyMode: 'patch_existing',
params: { simulation: { speed: 1.5 }, overlays: ['clean-recording'] }
})
.expect(200)
.expect((response) => {
expect(response.body).toMatchObject({
order_index: 2,
title: 'Glider Patch',
apply_mode: 'patch_existing',
requires_previous_scene: true
})
})
await request(app)
.get(`/api/ca/decks/${deck.id}/scenes`)
.expect(200)
.expect((response) => {
expect(response.body.map((item: { title: string }) => item.title)).toEqual(['Glider Patch'])
})
await request(app).delete(`/api/ca/scenes/${scene.id}`).expect(204)
await request(app).get(`/api/ca/scenes/${scene.id}`).expect(404)
await request(app).get(`/api/ca/preset-trees/${tree.id}/nodes/${shot.id}`).expect(200)
await request(app).delete(`/api/ca/decks/${deck.id}`).expect(204)
await request(app).get(`/api/ca/decks/${deck.id}`).expect(404)
})
it('returns a resolved deck payload for viewer playback', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
const tree = await request(app)
.post('/api/ca/preset-trees')
.send({ slug: 'viewer-tree', name: 'Viewer Tree' })
.expect(201)
.then((response) => response.body)
const root = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d' },
renderer: { id: 'classic-voxels' }
}
})
.expect(201)
.then((response) => response.body)
const singleCell = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'single-cell',
name: 'Single Cell',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'single-cell' } }
}
})
.expect(201)
.then((response) => response.body)
const blinker = await request(app)
.post(`/api/ca/preset-trees/${tree.id}/nodes`)
.send({
parentId: root.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'blinker' } }
}
})
.expect(201)
.then((response) => response.body)
const deck = await request(app)
.post('/api/ca/decks')
.send({ slug: 'viewer-deck', title: 'Viewer Deck' })
.expect(201)
.then((response) => response.body)
await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 2,
title: 'Blinker',
presetTreeId: tree.id,
presetNodeId: blinker.id,
applyMode: 'patch_existing',
params: { camera: { mode: '2d', zoom: 1.1 } }
})
.expect(201)
await request(app)
.post(`/api/ca/decks/${deck.id}/scenes`)
.send({
orderIndex: 1,
title: 'Single Cell',
presetTreeId: tree.id,
presetNodeId: singleCell.id,
params: { camera: { mode: '2d', zoom: 1.5 } }
})
.expect(201)
await request(app)
.get(`/api/ca/decks/${deck.id}/resolved`)
.expect(200)
.expect((response) => {
expect(response.body.deck.title).toBe('Viewer Deck')
expect(response.body.scenes.map((entry: { scene: { title: string } }) => entry.scene.title)).toEqual([
'Single Cell',
'Blinker'
])
expect(response.body.scenes[0].params).toMatchObject({
simulation: {
ruleId: 'life-3d',
initialCondition: { patternId: 'single-cell' }
},
renderer: { id: 'classic-voxels' },
camera: { mode: '2d', zoom: 1.5 }
})
expect(response.body.scenes[1].scene.requires_previous_scene).toBe(true)
expect(response.body.scenes[1].params.camera).toEqual({ mode: '2d', zoom: 1.1 })
})
})
})
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest'
import {
createDeck,
createInitialConditionGenerator,
createPresetNode,
createPresetTree,
createScene,
deleteDeck,
deletePresetNode,
deleteScene,
getDeck,
getScene,
listInitialConditionGenerators,
listDecks,
listPresetNodes,
listScenes,
resolveDeck,
resolvePresetNode,
resolveScene,
updateDeck,
updatePresetNode,
updateScene
} from '../src/caStudioRepository.js'
import { createTestDb } from './testDb.js'
describe('CA Studio repository', () => {
it('creates preset nodes, resolves inherited params, and treats null as unset', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'ca-series', name: 'CA Series' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 },
simulation: { grid: { size: [32, 32, 1], wrap: true }, ruleId: 'life-3d' },
renderer: { id: 'voxel-instanced', quality: 'high' },
overlays: ['generation-counter']
}
})
const chapter = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'game-of-life',
name: 'Game of Life',
kind: 'chapter',
params: {
simulation: { ruleId: 'game-of-life' },
overlays: ['minimal-title']
}
})
const shot = await createPresetNode(db, {
treeId: tree.id,
parentId: chapter.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { grid: { wrap: null }, initialCondition: { patternId: 'blinker' } },
camera: { mode: '2d' }
}
})
const resolved = await resolvePresetNode(db, tree.id, shot.id)
expect(resolved?.ancestry.map((node) => node.slug)).toEqual(['root', 'game-of-life', 'blinker'])
expect(resolved?.params).toEqual({
simulation: {
grid: { size: [32, 32, 1] },
ruleId: 'game-of-life',
initialCondition: { patternId: 'blinker' }
},
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 },
renderer: { id: 'voxel-instanced', quality: 'high' },
overlays: ['minimal-title'],
camera: { mode: '2d' }
})
})
it('registers initial condition generators and filters them by CA class', async () => {
const db = await createTestDb()
await createInitialConditionGenerator(db, {
slug: 'life-random-soup',
name: 'Life Random Soup',
generatorKind: 'random-soup',
supportedClasses: [{ neighborhoodId: 'moore', dimensions: 2, states: 2 }],
defaultParams: { density: 0.28 }
})
await createInitialConditionGenerator(db, {
slug: 'von-neumann-traffic',
name: 'Traffic Seed',
generatorKind: 'traffic-lanes',
supportedClasses: [{ neighborhoodId: 'von-neumann', dimensions: 2, states: 3 }]
})
expect((await listInitialConditionGenerators(db)).map((generator) => generator.slug)).toEqual([
'life-random-soup',
'von-neumann-traffic'
])
expect(
(await listInitialConditionGenerators(db, {
caClass: { neighborhoodId: 'moore', dimensions: 2, states: 2 }
})).map((generator) => generator.slug)
).toEqual(['life-random-soup'])
})
it('rejects cross-tree parents and duplicate sibling slugs', async () => {
const db = await createTestDb()
const firstTree = await createPresetTree(db, { slug: 'first', name: 'First' })
const secondTree = await createPresetTree(db, { slug: 'second', name: 'Second' })
const root = await createPresetNode(db, {
treeId: firstTree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root'
})
await expect(
createPresetNode(db, {
treeId: secondTree.id,
parentId: root.id,
slug: 'bad-parent',
name: 'Bad Parent'
})
).rejects.toThrow()
await createPresetNode(db, {
treeId: firstTree.id,
parentId: root.id,
slug: 'child',
name: 'Child A'
})
await expect(
createPresetNode(db, {
treeId: firstTree.id,
parentId: root.id,
slug: 'child',
name: 'Child B'
})
).rejects.toThrow()
})
it('prevents parent cycles', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'cycles', name: 'Cycles' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root'
})
const child = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'child',
name: 'Child'
})
await expect(updatePresetNode(db, tree.id, root.id, { parentId: child.id })).rejects.toThrow(
/cycle/i
)
})
it('cascades child preset nodes when deleting a parent', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'delete-tree', name: 'Delete Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root'
})
await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'child',
name: 'Child'
})
expect(await deletePresetNode(db, tree.id, root.id)).toBe(true)
expect(await listPresetNodes(db, tree.id)).toEqual([])
})
it('creates ordered scenes and resolves scene params over preset params', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'deck-tree', name: 'Deck Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d', grid: { size: [16, 16, 16] } },
renderer: { id: 'classic-voxels' }
}
})
const deck = await createDeck(db, { slug: 'ca-001', title: 'CA 001' })
const intro = await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Intro',
presetTreeId: tree.id,
presetNodeId: root.id,
params: { camera: { mode: '2d' } }
})
await createScene(db, {
deckId: deck.id,
orderIndex: 2,
title: 'Patch',
presetTreeId: tree.id,
presetNodeId: root.id,
applyMode: 'patch_existing',
params: { simulation: { speed: 2 } }
})
await expect(
createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Duplicate Order',
presetTreeId: tree.id,
presetNodeId: root.id
})
).rejects.toThrow()
expect((await listScenes(db, deck.id)).map((scene) => scene.title)).toEqual(['Intro', 'Patch'])
expect((await listScenes(db, deck.id))[1].requires_previous_scene).toBe(true)
expect(await resolveScene(db, intro.id)).toMatchObject({
params: {
simulation: { ruleId: 'life-3d', grid: { size: [16, 16, 16] } },
renderer: { id: 'classic-voxels' },
camera: { mode: '2d' }
}
})
})
it('loads scenes through preset references and reflects preset updates on the next resolve', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'studio-tree', name: 'Studio Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { grid: { size: [24, 24, 1], wrap: true }, ruleId: 'life-3d' },
renderer: { id: 'classic-voxels', quality: 'medium' },
overlays: ['title', 'generation-counter']
}
})
const gliderPreset = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'glider',
name: 'Glider',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'glider' }, speed: 1 },
camera: { mode: '2d', zoom: 1 }
}
})
const deck = await createDeck(db, { slug: 'studio-deck', title: 'Studio Deck' })
const scene = await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Glider Shot',
presetTreeId: tree.id,
presetNodeId: gliderPreset.id,
params: {
camera: { zoom: 1.25 },
renderer: { quality: null }
}
})
expect(await resolveScene(db, scene.id)).toMatchObject({
params: {
simulation: {
grid: { size: [24, 24, 1], wrap: true },
ruleId: 'life-3d',
initialCondition: { patternId: 'glider' },
speed: 1
},
renderer: { id: 'classic-voxels' },
overlays: ['title', 'generation-counter'],
camera: { mode: '2d', zoom: 1.25 }
}
})
await updatePresetNode(db, tree.id, gliderPreset.id, {
params: {
simulation: { initialCondition: { patternId: 'lightweight-spaceship' }, speed: 1.75 },
camera: { mode: '2d', zoom: 0.85 }
}
})
expect(await resolveScene(db, scene.id)).toMatchObject({
params: {
simulation: {
grid: { size: [24, 24, 1], wrap: true },
ruleId: 'life-3d',
initialCondition: { patternId: 'lightweight-spaceship' },
speed: 1.75
},
renderer: { id: 'classic-voxels' },
camera: { mode: '2d', zoom: 1.25 }
}
})
})
it('updates deck and scene records used by the editor CRUD flow', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'crud-tree', name: 'CRUD Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: { simulation: { ruleId: 'life-3d' } }
})
const deck = await createDeck(db, {
slug: 'crud-deck',
title: 'CRUD Deck',
params: { recording: { fps: 30 } }
})
const scene = await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Original Scene',
presetTreeId: tree.id,
presetNodeId: root.id,
params: { camera: { mode: '3d' } }
})
const updatedDeck = await updateDeck(db, deck.id, {
title: 'Updated Deck',
params: { recording: { fps: 60, resolution: [1920, 1080] } }
})
expect(updatedDeck).toMatchObject({
title: 'Updated Deck',
params: { recording: { fps: 60, resolution: [1920, 1080] } }
})
expect(await getDeck(db, deck.id)).toMatchObject({ title: 'Updated Deck' })
expect((await listDecks(db)).map((item) => item.slug)).toEqual(['crud-deck'])
const updatedScene = await updateScene(db, scene.id, {
orderIndex: 2,
title: 'Updated Scene',
applyMode: 'patch_existing',
params: { camera: { mode: '2d' }, simulation: { speed: 2 } },
transition: { type: 'cut' }
})
expect(updatedScene).toMatchObject({
order_index: 2,
title: 'Updated Scene',
apply_mode: 'patch_existing',
requires_previous_scene: true,
params: { camera: { mode: '2d' }, simulation: { speed: 2 } },
transition: { type: 'cut' }
})
expect(await getScene(db, scene.id)).toMatchObject({ title: 'Updated Scene' })
expect(await resolveScene(db, scene.id)).toMatchObject({
params: {
simulation: { ruleId: 'life-3d', speed: 2 },
camera: { mode: '2d' }
}
})
expect(await deleteScene(db, scene.id)).toBe(true)
expect(await listScenes(db, deck.id)).toEqual([])
expect(await deleteDeck(db, deck.id)).toBe(true)
expect(await getDeck(db, deck.id)).toBeNull()
})
it('resolves a full deck as ordered playback scenes', async () => {
const db = await createTestDb()
const tree = await createPresetTree(db, { slug: 'playback-tree', name: 'Playback Tree' })
const root = await createPresetNode(db, {
treeId: tree.id,
slug: 'root',
name: 'Root',
kind: 'preset_root',
params: {
simulation: { ruleId: 'life-3d', grid: { size: [40, 40, 1] } },
renderer: { id: 'classic-voxels' },
recording: { fps: 60 }
}
})
const blinker = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'blinker',
name: 'Blinker',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'blinker' } },
camera: { mode: '2d', zoom: 1 }
}
})
const glider = await createPresetNode(db, {
treeId: tree.id,
parentId: root.id,
slug: 'glider',
name: 'Glider',
kind: 'shot',
params: {
simulation: { initialCondition: { patternId: 'glider' } },
camera: { mode: '2d', zoom: 0.9 }
}
})
const deck = await createDeck(db, { slug: 'playback-deck', title: 'Playback Deck' })
await createScene(db, {
deckId: deck.id,
orderIndex: 2,
title: 'Glider Scene',
presetTreeId: tree.id,
presetNodeId: glider.id,
applyMode: 'patch_existing',
params: { camera: { zoom: 1.2 } }
})
await createScene(db, {
deckId: deck.id,
orderIndex: 1,
title: 'Blinker Scene',
presetTreeId: tree.id,
presetNodeId: blinker.id
})
expect(await resolveDeck(db, deck.id)).toMatchObject({
deck: { title: 'Playback Deck' },
scenes: [
{
scene: { order_index: 1, title: 'Blinker Scene' },
params: {
simulation: {
ruleId: 'life-3d',
grid: { size: [40, 40, 1] },
initialCondition: { patternId: 'blinker' }
},
renderer: { id: 'classic-voxels' },
recording: { fps: 60 },
camera: { mode: '2d', zoom: 1 }
}
},
{
scene: {
order_index: 2,
title: 'Glider Scene',
apply_mode: 'patch_existing',
requires_previous_scene: true
},
params: {
simulation: {
ruleId: 'life-3d',
grid: { size: [40, 40, 1] },
initialCondition: { patternId: 'glider' }
},
renderer: { id: 'classic-voxels' },
recording: { fps: 60 },
camera: { mode: '2d', zoom: 1.2 }
}
}
]
})
await updatePresetNode(db, tree.id, root.id, {
params: {
simulation: { ruleId: 'life-3d', grid: { size: [48, 48, 1] } },
renderer: { id: 'neon-voxels' },
recording: { fps: 30 }
}
})
const resolved = await resolveDeck(db, deck.id)
expect(resolved?.scenes.map((entry) => entry.params.renderer)).toEqual([
{ id: 'neon-voxels' },
{ id: 'neon-voxels' }
])
expect(resolved?.scenes.map((entry) => entry.params.recording)).toEqual([{ fps: 30 }, { fps: 30 }])
expect(resolved?.scenes[1].params.camera).toEqual({ mode: '2d', zoom: 1.2 })
})
})
+45
View File
@@ -0,0 +1,45 @@
import { readdir, readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PGlite } from '@electric-sql/pglite'
import { Pool } from 'pg'
const __dirname = dirname(fileURLToPath(import.meta.url))
const migrationsDir = resolve(__dirname, '../src/migrations')
function createSchemaName() {
return `ca_test_${Date.now()}_${Math.random().toString(16).slice(2)}`
}
export async function createTestDb() {
const postgresUrl = process.env.CA_STUDIO_TEST_DATABASE_URL
const migrationFiles = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort()
const migrationSql = await Promise.all(
migrationFiles.map(async (file) => readFile(resolve(migrationsDir, file), 'utf8'))
)
if (postgresUrl) {
const schema = createSchemaName()
const bootstrapPool = new Pool({ connectionString: postgresUrl })
await bootstrapPool.query(`CREATE SCHEMA "${schema}"`)
await bootstrapPool.end()
const pool = new Pool({
connectionString: postgresUrl,
options: `-c search_path=${schema},public`,
allowExitOnIdle: true
})
for (const sql of migrationSql) {
await pool.query(sql)
}
return pool
}
const db = new PGlite()
for (const sql of migrationSql) {
await db.exec(sql)
}
return db
}