ai-chatbot/packages/core/src/registry/scene-api.test.ts
Wassim SAMAD fb91713374 Add node registry primitives (PR 0.1 of node-registry plan)
Introduces the @pascal-app/core/registry surface that future node-bundle
packages (and external plugins) will use to register node kinds with the
host. No runtime behavior changes — registry is empty until subsequent
PRs populate it.

- types.ts: NodeDefinition, Capabilities, Relations, DragAction, Plugin,
  ParametricDescriptor, Affordance, SceneApi, NodeRegistry. Capability
  configs accept an override escape hatch; additive-only after v1.
- registry.ts: nodeRegistry singleton, registerNode, async loadPlugin.
  Validates kind, schemaVersion, apiVersion; rejects duplicate kinds.
- scene-api.ts: createSceneApi factory wrapping the scene store with
  copy-on-write snapshot semantics for pauseHistory/restore/resumeHistory.
- index.ts: barrel re-exporting the public surface.
- core/index.ts + package.json: export * from registry and add the
  ./registry subpath so consumers can import either way.

Tests (27 cases, all bun:test): registry registration / validation /
plugin loading; SceneApi read/write/dirty/history; lazy snapshot capture
with update/upsert/delete reversal via restore and restoreAll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:18:29 -04:00

206 lines
6.9 KiB
TypeScript

import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from '../store/history-control'
import { createSceneApi, type SceneStoreLike } from './scene-api'
function makeFakeStore(initial: Record<string, AnyNode> = {}) {
const state = {
nodes: { ...initial } as Record<AnyNodeId, AnyNode>,
rootNodeIds: [] as AnyNodeId[],
dirtyNodes: new Set<AnyNodeId>(),
createNode(node: AnyNode) {
state.nodes[node.id] = node
},
updateNode(id: AnyNodeId, data: Partial<AnyNode>) {
const existing = state.nodes[id]
if (existing) state.nodes[id] = { ...existing, ...data } as AnyNode
},
deleteNode(id: AnyNodeId) {
delete state.nodes[id]
},
markDirty(id: AnyNodeId) {
state.dirtyNodes.add(id)
},
}
let paused = 0
const temporal = {
getState: () => ({
pause: () => {
paused += 1
},
resume: () => {
paused -= 1
},
}),
}
const store: SceneStoreLike & { _state: typeof state; _pausedCount: () => number } = {
getState: () => state,
temporal,
_state: state,
_pausedCount: () => paused,
}
return store
}
function makeNode(id: string, extra: Record<string, unknown> = {}): AnyNode {
return { id, type: 'site', parentId: null, visible: true, ...extra } as unknown as AnyNode
}
// Tests use short string IDs ("a", "b") for readability. The store's
// AnyNodeId is a branded template-literal type — cast at the boundary.
const id = (s: string) => s as AnyNodeId
const nodes = (store: ReturnType<typeof makeFakeStore>) =>
store._state.nodes as unknown as Record<string, AnyNode>
describe('SceneApi', () => {
beforeEach(() => {
resetSceneHistoryPauseDepth()
})
test('get reads node from store', () => {
const store = makeFakeStore({ a: makeNode('a') })
const api = createSceneApi(store)
expect(api.get(id('a'))).toEqual(makeNode('a'))
expect(api.get(id('missing'))).toBeUndefined()
})
test('update applies patch via store.updateNode', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.update(id('a'), { visible: false } as Partial<AnyNode>)
expect(nodes(store)['a']).toMatchObject({ visible: false })
})
test('upsert calls createNode and returns the id', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
const returnedId = api.upsert(makeNode('a'))
expect(returnedId).toBe(id('a'))
expect(nodes(store)['a']).toBeDefined()
})
test('delete removes node from store', () => {
const store = makeFakeStore({ a: makeNode('a') })
const api = createSceneApi(store)
api.delete(id('a'))
expect(nodes(store)['a']).toBeUndefined()
})
test('markDirty forwards to store', () => {
const store = makeFakeStore({ a: makeNode('a') })
const api = createSceneApi(store)
api.markDirty(id('a'))
expect(store._state.dirtyNodes.has(id('a'))).toBe(true)
})
test('pauseHistory and resumeHistory bracket store.temporal pause/resume', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
expect(store._pausedCount()).toBe(0)
api.pauseHistory()
expect(store._pausedCount()).toBe(1)
api.resumeHistory()
expect(store._pausedCount()).toBe(0)
})
test('nested pause/resume use a depth counter (single pause call to temporal)', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
api.pauseHistory()
api.pauseHistory()
expect(store._pausedCount()).toBe(1) // only one actual pause
api.resumeHistory()
expect(store._pausedCount()).toBe(1) // still paused — inner resume
api.resumeHistory()
expect(store._pausedCount()).toBe(0)
})
})
describe('SceneApi snapshot / restore', () => {
beforeEach(() => {
resetSceneHistoryPauseDepth()
})
test('restore returns a touched node to its pre-pause state', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
expect(nodes(store)['a']).toMatchObject({ visible: false })
api.restore(id('a'))
expect(nodes(store)['a']).toMatchObject({ visible: true })
api.resumeHistory()
})
test('restore on a node never touched is a no-op', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.restore(id('a'))
expect(nodes(store)['a']).toMatchObject({ visible: true })
api.resumeHistory()
})
test('restoreAll reverts every touched node', () => {
const store = makeFakeStore({
a: makeNode('a', { visible: true }),
b: makeNode('b', { visible: true }),
})
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.update(id('b'), { visible: false } as Partial<AnyNode>)
api.restoreAll()
expect(nodes(store)['a']).toMatchObject({ visible: true })
expect(nodes(store)['b']).toMatchObject({ visible: true })
api.resumeHistory()
})
test('restore re-creates a node that was deleted mid-pause', () => {
const original = makeNode('a', { visible: true })
const store = makeFakeStore({ a: original })
const api = createSceneApi(store)
api.pauseHistory()
api.delete(id('a'))
expect(nodes(store)['a']).toBeUndefined()
api.restore(id('a'))
expect(nodes(store)['a']).toEqual(original)
api.resumeHistory()
})
test('restore deletes a node that was upserted mid-pause', () => {
const store = makeFakeStore()
const api = createSceneApi(store)
api.pauseHistory()
api.upsert(makeNode('a'))
expect(nodes(store)['a']).toBeDefined()
api.restore(id('a'))
expect(nodes(store)['a']).toBeUndefined()
api.resumeHistory()
})
test('snapshot is dropped on resumeHistory; restore after resume is a no-op', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.resumeHistory()
api.restore(id('a')) // snapshot gone — no effect
expect(nodes(store)['a']).toMatchObject({ visible: false })
})
test('only the first mutation in a pause window captures the original', () => {
const store = makeFakeStore({ a: makeNode('a', { visible: true }) })
const api = createSceneApi(store)
api.pauseHistory()
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.update(id('a'), { visible: true } as Partial<AnyNode>) // second update — must not overwrite snapshot
api.update(id('a'), { visible: false } as Partial<AnyNode>)
api.restore(id('a'))
expect(nodes(store)['a']).toMatchObject({ visible: true }) // the *first* pre-pause value
api.resumeHistory()
})
})