Files
glitch_automata_lab/backend/test/caStudioApi.test.ts
T

661 lines
20 KiB
TypeScript
Raw Normal View History

2026-07-01 12:10:12 +02:00
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', () => {
2026-07-06 13:11:48 +02:00
it('seeds a useful shelf of elementary 1D CA rules', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.get('/api/ca/engines?dimensions=1&states=2')
.expect(200)
.expect((response) => {
const rules = response.body
.filter((engine: { engine_kind: string }) => engine.engine_kind === 'elementary-1d')
.map((engine: { default_params: { ruleId?: string } }) => engine.default_params.ruleId)
expect(rules).toEqual(expect.arrayContaining([
'Rule 18',
'Rule 22',
'Rule 30',
'Rule 45',
'Rule 54',
'Rule 60',
'Rule 73',
'Rule 90',
'Rule 110',
'Rule 122',
'Rule 126',
'Rule 150',
'Rule 184'
]))
const rule90 = response.body.find((engine: { slug: string }) => engine.slug === 'elementary-rule-90')
expect(rule90.params_schema.ruleId).toMatchObject({
type: 'select',
default: 'Rule 90',
options: [{ value: 'Rule 90', label: 'Rule 90' }]
})
})
})
it('seeds the Naga 3D engine for seven-state voxel spaces', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.get('/api/ca/engines?dimensions=3&states=7')
.expect(200)
.expect((response) => {
const naga = response.body.find((engine: { slug: string }) => engine.slug === 'naga-3d')
expect(naga).toMatchObject({
name: 'Naga 3D',
engine_kind: 'naga-3d',
default_params: {
ruleId: 'naga-3d',
rendererId: 'voxel-3d',
2026-07-06 23:11:42 +02:00
rng_seed: 'naga',
2026-07-06 13:11:48 +02:00
edgeWrap: true
}
})
expect(naga.params_schema.ruleId.options).toEqual([{ value: 'naga-3d', label: '3D Naga' }])
2026-07-06 23:11:42 +02:00
expect(naga.params_schema.rng_seed).toMatchObject({
type: 'string',
label: 'RNG seed',
default: 'naga',
group: 'Randomness'
})
2026-07-06 13:11:48 +02:00
expect(naga.params_schema.edgeWrap).toMatchObject({
type: 'boolean',
label: 'Edge wrap',
default: true,
group: 'Edge settings'
})
})
})
it('seeds the Naga Markov initial condition generator for seven-state voxel spaces', async () => {
const db = await createTestDb()
const app = createCaStudioApi(db)
await request(app)
.get('/api/ca/initial-condition-generators?dimensions=3&states=7')
.expect(200)
.expect((response) => {
const generator = response.body.find((item: { slug: string }) => item.slug === 'naga-markov-chains')
expect(generator).toMatchObject({
name: 'Naga Markov chains',
generator_kind: 'naga-markov',
default_params: {
count: 3,
2026-07-06 23:11:42 +02:00
meanLength: 5,
lengthStdDev: 0,
2026-07-06 13:11:48 +02:00
sameTypeProbability: 0.8,
2026-07-06 23:11:42 +02:00
ic_seed: 'naga-markov'
2026-07-06 13:11:48 +02:00
}
})
2026-07-06 23:11:42 +02:00
expect(generator.params_schema.meanLength).toMatchObject({
type: 'range',
default: 5
})
expect(generator.params_schema.lengthStdDev).toMatchObject({
type: 'range',
default: 0
})
2026-07-06 13:11:48 +02:00
expect(generator.params_schema.sameTypeProbability).toMatchObject({
type: 'range',
default: 0.8
})
2026-07-06 23:11:42 +02:00
expect(generator.params_schema.ic_seed).toMatchObject({
type: 'string',
label: 'IC seed',
default: 'naga-markov'
})
2026-07-06 13:11:48 +02:00
})
})
2026-07-01 12:10:12 +02:00
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 })
})
})
})