ai-chatbot/packages/mcp/src/bridge/scene-bridge.test.ts
Wawa e618175bba Reset core/viewer/editor/mcp packages and apps/editor from private-editor
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
2026-05-09 20:52:26 +00:00

549 lines
20 KiB
TypeScript

import { beforeEach, describe, expect, test } from 'bun:test'
import {
BuildingNode,
DoorNode,
ItemNode,
LevelNode,
SiteNode,
WallNode,
ZoneNode,
} from '@pascal-app/core/schema'
import { SceneBridge } from './scene-bridge'
function tick() {
return new Promise((r) => setTimeout(r, 5))
}
describe('SceneBridge', () => {
let bridge: SceneBridge
beforeEach(() => {
bridge = new SceneBridge()
// Ensure a clean slate even if a prior test left store state around
// (the core store is a module-singleton).
bridge.setScene({}, [])
bridge.clearHistory()
bridge.loadDefault()
bridge.clearHistory()
bridge.flushDirty()
})
describe('loadDefault / getters', () => {
test('creates default Site → Building → Level', () => {
const nodes = bridge.getNodes()
const types = Object.values(nodes)
.map((n) => n.type)
.sort()
expect(types).toEqual(['building', 'level', 'site'])
expect(bridge.getRootNodeIds().length).toBe(1)
})
test('loadDefault is idempotent when scene already loaded', () => {
const before = Object.keys(bridge.getNodes()).length
bridge.loadDefault()
const after = Object.keys(bridge.getNodes()).length
expect(after).toBe(before)
})
test('getNode returns the node by id', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const fetched = bridge.getNode(level.id)
expect(fetched?.id).toBe(level.id)
})
test('getNode returns null for unknown id', () => {
expect(bridge.getNode('wall_does_not_exist')).toBeNull()
})
})
describe('createNode', () => {
test('creates a wall attached to a level', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
const id = bridge.createNode(wall, level.id)
expect(id).toBe(wall.id)
expect(bridge.getNode(wall.id)).not.toBeNull()
// Level should list the wall as a child.
const freshLevel = bridge.getNode(level.id) as any
expect(freshLevel.children).toContain(wall.id)
})
test('created wall has the correct parentId', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const w = bridge.getNode(wall.id)!
expect(w.parentId).toBe(level.id)
})
})
describe('updateNode', () => {
test('merges new fields on existing node', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
bridge.updateNode(wall.id, { thickness: 0.25, height: 3 } as any)
await tick()
const w = bridge.getNode(wall.id) as any
expect(w.thickness).toBe(0.25)
expect(w.height).toBe(3)
})
test('throws on unknown id', () => {
expect(() => bridge.updateNode('wall_missing' as any, { height: 3 } as any)).toThrow(
/node not found/,
)
})
})
describe('deleteNode', () => {
test('deletes a leaf node', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const removed = bridge.deleteNode(wall.id)
expect(removed).toContain(wall.id)
expect(bridge.getNode(wall.id)).toBeNull()
})
test('cascade=false throws if node has children', () => {
// Level (with a child wall) — deleting non-cascaded must throw.
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
expect(() => bridge.deleteNode(level.id, false)).toThrow(/descendant/)
// Node still exists.
expect(bridge.getNode(level.id)).not.toBeNull()
expect(bridge.getNode(wall.id)).not.toBeNull()
})
test('cascade=true removes node and all descendants', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall1 = WallNode.parse({ start: [0, 0], end: [1, 0] })
const wall2 = WallNode.parse({ start: [1, 0], end: [1, 1] })
bridge.createNode(wall1, level.id)
bridge.createNode(wall2, level.id)
const removed = bridge.deleteNode(level.id, true)
expect(removed).toContain(level.id)
expect(removed).toContain(wall1.id)
expect(removed).toContain(wall2.id)
expect(bridge.getNode(level.id)).toBeNull()
expect(bridge.getNode(wall1.id)).toBeNull()
})
test('throws on unknown id', () => {
expect(() => bridge.deleteNode('wall_nope' as any, false)).toThrow(/node not found/)
})
})
describe('undo / redo', () => {
test('round-trips create + update', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
await tick()
bridge.updateNode(wall.id, { thickness: 0.25 } as any)
await tick()
// Undo update
const u1 = bridge.undo()
await tick()
expect(u1).toBe(1)
const w1 = bridge.getNode(wall.id) as any
expect(w1).not.toBeNull()
expect(w1.thickness).not.toBe(0.25)
// Undo create — wall should be gone
const u2 = bridge.undo()
await tick()
expect(u2).toBe(1)
expect(bridge.getNode(wall.id)).toBeNull()
// Redo both
const r = bridge.redo(2)
await tick()
expect(r).toBe(2)
const w3 = bridge.getNode(wall.id) as any
expect(w3).not.toBeNull()
expect(w3.thickness).toBe(0.25)
})
test('getHistory tracks pointers', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await tick()
let h = bridge.getHistory()
expect(h.pastCount).toBe(1)
expect(h.futureCount).toBe(0)
bridge.undo()
await tick()
h = bridge.getHistory()
expect(h.pastCount).toBe(0)
expect(h.futureCount).toBe(1)
})
test('clearHistory wipes past/future', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id)
await tick()
bridge.clearHistory()
const h = bridge.getHistory()
expect(h.pastCount).toBe(0)
expect(h.futureCount).toBe(0)
})
test('undo/redo without history returns 0', () => {
bridge.clearHistory()
expect(bridge.undo()).toBe(0)
expect(bridge.redo()).toBe(0)
})
})
describe('applyPatch', () => {
test('applies mixed create/update/delete atomically', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wallA = WallNode.parse({ start: [0, 0], end: [2, 0] })
const wallB = WallNode.parse({ start: [2, 0], end: [2, 2] })
// pre-seed one wall, then exercise update + delete
bridge.createNode(wallA, level.id)
await tick()
const res = bridge.applyPatch([
{ op: 'create', node: wallB, parentId: level.id },
{ op: 'update', id: wallA.id, data: { thickness: 0.3 } as any },
{ op: 'delete', id: wallA.id },
])
await tick()
expect(res.appliedOps).toBe(3)
expect(res.createdIds).toContain(wallB.id)
expect(res.deletedIds).toContain(wallA.id)
expect(bridge.getNode(wallA.id)).toBeNull()
expect(bridge.getNode(wallB.id)).not.toBeNull()
})
test('is all-or-nothing: invalid op rolls back no changes', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const pre = Object.keys(bridge.getNodes()).length
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
expect(() =>
bridge.applyPatch([
{ op: 'create', node: wall, parentId: level.id },
// This op is invalid — id does not exist.
{ op: 'update', id: 'wall_missing' as any, data: { thickness: 0.1 } as any },
]),
).toThrow(/invalid patch/)
// The wall must NOT have been created.
expect(bridge.getNode(wall.id)).toBeNull()
// Node count is unchanged.
expect(Object.keys(bridge.getNodes()).length).toBe(pre)
})
test('rejects create with non-existent parentId', () => {
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
expect(() =>
bridge.applyPatch([{ op: 'create', node: wall, parentId: 'level_nope' as any }]),
).toThrow(/invalid patch/)
})
test('rejects delete of unknown id', () => {
expect(() => bridge.applyPatch([{ op: 'delete', id: 'wall_nope' as any }])).toThrow(
/invalid patch/,
)
})
test('rejects delete with cascade=false on a node with children', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id)
await tick()
expect(() => bridge.applyPatch([{ op: 'delete', id: level.id, cascade: false }])).toThrow(
/invalid patch/,
)
})
test('accepts delete with cascade=true on a node with children', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await tick()
const res = bridge.applyPatch([{ op: 'delete', id: level.id, cascade: true }])
expect(res.deletedIds).toContain(level.id)
expect(res.deletedIds).toContain(wall.id)
})
test('rejects create with schema-invalid node', () => {
// Bypass .parse so we can feed an invalid node through the union.
const bogus = {
object: 'node',
id: 'wall_bogus',
type: 'wall',
// missing start/end
} as any
expect(() => bridge.applyPatch([{ op: 'create', node: bogus }])).toThrow(/invalid patch/)
})
test('rejects unknown op', () => {
expect(() => bridge.applyPatch([{ op: 'wat', id: 'x' } as any])).toThrow(/invalid patch/)
})
test('rejects update with non-object data', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
expect(() => bridge.applyPatch([{ op: 'update', id: level.id, data: null as any }])).toThrow(
/invalid patch/,
)
})
test('rejects undefined patch entry', () => {
expect(() => bridge.applyPatch([undefined as any])).toThrow(/invalid patch/)
})
})
describe('validateScene', () => {
test('returns valid for default scene', () => {
const res = bridge.validateScene()
expect(res.valid).toBe(true)
expect(res.errors).toEqual([])
})
test('flags bad nodes fed in via setScene', () => {
const site = SiteNode.parse({})
// Bypass the schema by constructing a bogus wall object directly.
const bogus = {
object: 'node',
id: 'wall_bogus',
type: 'wall',
parentId: site.id,
// missing required `start`/`end`
} as any
bridge.setScene({ [site.id]: site, [bogus.id]: bogus }, [site.id])
const res = bridge.validateScene()
expect(res.valid).toBe(false)
expect(res.errors.some((e) => e.nodeId === 'wall_bogus')).toBe(true)
})
})
describe('traversal: site quirk & generic helpers', () => {
test('getChildren uses the flat dict (handles site children-as-objects)', () => {
const site = bridge.findNodes({ type: 'site' })[0]!
const children = bridge.getChildren(site.id)
// Building is the expected child of site via parentId.
const types = children.map((c) => c.type).sort()
expect(types).toContain('building')
})
test('getChildren works for level (children-as-ids)', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const children = bridge.getChildren(level.id)
expect(children.map((c) => c.id)).toContain(wall.id)
})
test('getAncestry walks to root', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const ancestry = bridge.getAncestry(wall.id)
const types = ancestry.map((n) => n.type)
expect(types[0]).toBe('wall')
expect(types).toContain('level')
expect(types).toContain('building')
expect(types).toContain('site')
})
test('getAncestry returns [] for unknown id', () => {
expect(bridge.getAncestry('wall_nope' as any)).toEqual([])
})
test('resolveLevelId returns the enclosing level', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
expect(bridge.resolveLevelId(wall.id)).toBe(level.id)
})
test('resolveLevelId returns null if no level ancestor', () => {
// Site itself has no level ancestor.
const site = bridge.findNodes({ type: 'site' })[0]!
expect(bridge.resolveLevelId(site.id)).toBeNull()
})
test('findNodes filters by type', () => {
const levels = bridge.findNodes({ type: 'level' })
expect(levels.length).toBe(1)
expect(levels[0]?.type).toBe('level')
})
test('findNodes filters by parentId', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const childrenOfLevel = bridge.findNodes({ parentId: level.id })
expect(childrenOfLevel.map((n) => n.id)).toContain(wall.id)
})
test('findNodes filters by levelId (via ancestry)', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const door = DoorNode.parse({ wallId: wall.id })
bridge.createNode(door, wall.id)
// Door lives under wall→level; findNodes with levelId should match.
const filtered = bridge.findNodes({ type: 'door', levelId: level.id })
expect(filtered.map((n) => n.id)).toContain(door.id)
})
test('findNodes with parentId: null finds roots', () => {
const roots = bridge.findNodes({ parentId: null })
expect(roots.map((n) => n.type)).toContain('site')
})
})
describe('setScene / exportJSON / loadJSON', () => {
test('exportJSON returns the scene shape', () => {
const exp = bridge.exportJSON()
expect(typeof exp.nodes).toBe('object')
expect(Array.isArray(exp.rootNodeIds)).toBe(true)
expect(exp.rootNodeIds.length).toBe(1)
})
test('exportJSON deep-clones (mutation does not leak back)', () => {
const exp = bridge.exportJSON()
const someId = Object.keys(exp.nodes)[0]!
;(exp.nodes as any)[someId] = 'tampered'
// Store is unchanged.
expect(typeof bridge.getNodes()[someId]).toBe('object')
})
test('loadJSON accepts a parsed object', () => {
const snap = bridge.exportJSON()
// Unload first so loadJSON does the heavy lift.
bridge.setScene({}, [])
bridge.loadJSON(snap)
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
})
test('loadJSON accepts a JSON string', () => {
const snap = bridge.exportJSON()
const str = JSON.stringify(snap)
bridge.setScene({}, [])
bridge.loadJSON(str)
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
})
test('loadJSON throws on malformed JSON string', () => {
expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/)
})
test('loadJSON throws when parsed JSON is not an object', () => {
expect(() => bridge.loadJSON('null')).toThrow(/expected object/)
expect(() => bridge.loadJSON(null as any)).toThrow(/expected object/)
})
test('loadJSON throws on wrong top-level shape', () => {
expect(() => bridge.loadJSON({} as any)).toThrow(/invalid scene/)
expect(() => bridge.loadJSON({ nodes: 1, rootNodeIds: [] } as any)).toThrow(/invalid scene/)
expect(() => bridge.loadJSON({ nodes: {}, rootNodeIds: 'nope' } as any)).toThrow(
/invalid scene/,
)
})
test('loadJSON rejects prototype-polluting keys in string form', () => {
const bad = '{"nodes": {"__proto__": {"polluted": true}}, "rootNodeIds": []}'
expect(() => bridge.loadJSON(bad)).toThrow(/forbidden key/)
})
test('loadJSON rejects prototype-polluting keys in object form', () => {
// Build object so the key is an actual own-property (not a prototype
// assignment).
const nodes: Record<string, unknown> = {}
Object.defineProperty(nodes, '__proto__', {
enumerable: true,
configurable: true,
writable: true,
value: { polluted: true },
})
const bad = { nodes, rootNodeIds: [] }
expect(() => bridge.loadJSON(bad as any)).toThrow(/forbidden key/)
})
test('setScene round-trip preserves node count', () => {
const pre = Object.keys(bridge.getNodes()).length
const snap = bridge.exportJSON()
bridge.setScene({}, [])
bridge.setScene(snap.nodes as any, snap.rootNodeIds as any)
expect(Object.keys(bridge.getNodes()).length).toBe(pre)
})
})
describe('flushDirty', () => {
test('drains the dirty set', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await tick()
const drained = bridge.flushDirty()
// Wall was just created, should have dirty-marked itself + parent.
expect(drained.length).toBeGreaterThan(0)
// Calling again drains nothing new.
const again = bridge.flushDirty()
expect(again.length).toBe(0)
})
})
describe('composite nodes', () => {
test('can build a small scene via LevelNode/BuildingNode helpers', () => {
// Construct a second site via explicit schema parse to exercise
// exportJSON/setScene on custom shapes.
const level = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level.id] })
const site = SiteNode.parse({ children: [] })
bridge.setScene(
{
[site.id]: { ...site, children: [] } as any,
[building.id]: { ...building, parentId: site.id } as any,
[level.id]: { ...level, parentId: building.id } as any,
},
[site.id],
)
expect(bridge.getNodes()[site.id]).toBeDefined()
expect(bridge.resolveLevelId(level.id)).toBe(level.id)
})
test('zone and item nodes are creatable and discoverable', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const zone = ZoneNode.parse({
name: 'Zone A',
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
})
bridge.createNode(zone, level.id)
const item = ItemNode.parse({
asset: {
id: 'asset_test',
category: 'test',
name: 'Test Asset',
thumbnail: 'data:image/png;base64,',
// AssetUrl validator (asset-url.ts) only allows asset://, blob:,
// data:image/, /path, or https://; `data:model/gltf-binary` is not
// in the allowlist, so this test uses an internal asset handle.
src: 'asset://test/chair.glb',
},
})
// Place item directly on level — ItemNode supports arbitrary parents in the model.
bridge.createNode(item, level.id)
const zones = bridge.findNodes({ type: 'zone' })
const items = bridge.findNodes({ type: 'item' })
expect(zones.map((n) => n.id)).toContain(zone.id)
expect(items.map((n) => n.id)).toContain(item.id)
})
})
})