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 }) }) }) })