Adding the lab project
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user