484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
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 })
|
||
|
|
})
|
||
|
|
})
|