641 lines
19 KiB
TypeScript
641 lines
19 KiB
TypeScript
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('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',
|
|
seed: 'naga',
|
|
edgeWrap: true
|
|
}
|
|
})
|
|
expect(naga.params_schema.ruleId.options).toEqual([{ value: 'naga-3d', label: '3D Naga' }])
|
|
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,
|
|
length: 5,
|
|
sameTypeProbability: 0.8,
|
|
seed: 'naga-markov'
|
|
}
|
|
})
|
|
expect(generator.params_schema.sameTypeProbability).toMatchObject({
|
|
type: 'range',
|
|
default: 0.8
|
|
})
|
|
})
|
|
})
|
|
|
|
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 })
|
|
})
|
|
})
|
|
})
|