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.
295 lines
9.9 KiB
TypeScript
295 lines
9.9 KiB
TypeScript
// Side-effect import MUST come first: installs RAF polyfill before core loads.
|
|
import '../bridge/node-shims'
|
|
|
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import { WallNode, ZoneNode } from '@pascal-app/core/schema'
|
|
import useScene from '@pascal-app/core/store'
|
|
import { SceneBridge } from '../bridge/scene-bridge'
|
|
import { registerAgentGuide } from './agent-guide'
|
|
import { registerCatalogItems } from './catalog-items'
|
|
import { registerConstraints } from './constraints'
|
|
import { registerSceneCurrent } from './scene-current'
|
|
import { registerSceneSummary } from './scene-summary'
|
|
|
|
type ClientServerPair = {
|
|
client: Client
|
|
server: McpServer
|
|
bridge: SceneBridge
|
|
close: () => Promise<void>
|
|
}
|
|
|
|
async function spinUp(
|
|
register: (server: McpServer, bridge: SceneBridge) => void,
|
|
): Promise<ClientServerPair> {
|
|
const bridge = new SceneBridge()
|
|
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
|
register(server, bridge)
|
|
const client = new Client({ name: 'test-client', version: '0.0.0' })
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
|
|
return {
|
|
client,
|
|
server,
|
|
bridge,
|
|
close: async () => {
|
|
await client.close()
|
|
await server.close()
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Reset the store between tests so temporal history and nodes don't leak. */
|
|
function resetScene(): void {
|
|
useScene.getState().unloadScene()
|
|
useScene.temporal.getState().clear()
|
|
}
|
|
|
|
describe('pascal://scene/current', () => {
|
|
beforeEach(() => resetScene())
|
|
|
|
test('returns the full scene JSON', async () => {
|
|
const pair = await spinUp(registerSceneCurrent)
|
|
try {
|
|
pair.bridge.loadDefault()
|
|
const res = await pair.client.readResource({ uri: 'pascal://scene/current' })
|
|
expect(res.contents).toHaveLength(1)
|
|
const content = res.contents[0]
|
|
expect(content).toBeDefined()
|
|
const c = content as { uri: string; mimeType?: string; text?: string }
|
|
expect(c.mimeType).toBe('application/json')
|
|
expect(c.uri).toBe('pascal://scene/current')
|
|
const parsed = JSON.parse(c.text ?? '{}')
|
|
expect(parsed).toHaveProperty('nodes')
|
|
expect(parsed).toHaveProperty('rootNodeIds')
|
|
expect(parsed).toHaveProperty('collections')
|
|
expect(Array.isArray(parsed.rootNodeIds)).toBe(true)
|
|
expect(parsed.rootNodeIds.length).toBeGreaterThan(0)
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
|
|
test('reflects mutations to the store', async () => {
|
|
const pair = await spinUp(registerSceneCurrent)
|
|
try {
|
|
pair.bridge.loadDefault()
|
|
const beforeRes = await pair.client.readResource({
|
|
uri: 'pascal://scene/current',
|
|
})
|
|
const beforeText = (beforeRes.contents[0] as { text: string }).text
|
|
const before = JSON.parse(beforeText)
|
|
const beforeCount = Object.keys(before.nodes).length
|
|
|
|
// Add a zone.
|
|
const level = pair.bridge
|
|
.findNodes({ type: 'level' as never })
|
|
.find((n) => n.type === 'level')
|
|
if (!level) throw new Error('no level')
|
|
const zone = ZoneNode.parse({
|
|
name: 'Living',
|
|
parentId: level.id,
|
|
polygon: [
|
|
[0, 0],
|
|
[3, 0],
|
|
[3, 3],
|
|
[0, 3],
|
|
],
|
|
})
|
|
pair.bridge.createNode(zone, level.id as never)
|
|
|
|
const afterRes = await pair.client.readResource({
|
|
uri: 'pascal://scene/current',
|
|
})
|
|
const afterText = (afterRes.contents[0] as { text: string }).text
|
|
const after = JSON.parse(afterText)
|
|
expect(Object.keys(after.nodes).length).toBe(beforeCount + 1)
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('pascal://scene/current/summary', () => {
|
|
beforeEach(() => resetScene())
|
|
|
|
test('returns markdown with counts and bbox', async () => {
|
|
const pair = await spinUp(registerSceneSummary)
|
|
try {
|
|
pair.bridge.loadDefault()
|
|
const res = await pair.client.readResource({
|
|
uri: 'pascal://scene/current/summary',
|
|
})
|
|
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
|
|
expect(content.mimeType).toBe('text/markdown')
|
|
const text = content.text ?? ''
|
|
expect(text.startsWith('# Scene summary')).toBe(true)
|
|
expect(text).toContain('Sites:')
|
|
expect(text).toContain('Buildings:')
|
|
expect(text).toContain('Levels:')
|
|
expect(text).toContain('Scene bbox')
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
|
|
test('estimated floor area sums zone polygon areas', async () => {
|
|
const pair = await spinUp(registerSceneSummary)
|
|
try {
|
|
pair.bridge.loadDefault()
|
|
const level = pair.bridge
|
|
.findNodes({ type: 'level' as never })
|
|
.find((n) => n.type === 'level')
|
|
if (!level) throw new Error('no level')
|
|
const zone = ZoneNode.parse({
|
|
name: 'Big',
|
|
parentId: level.id,
|
|
polygon: [
|
|
[0, 0],
|
|
[4, 0],
|
|
[4, 3],
|
|
[0, 3],
|
|
],
|
|
})
|
|
pair.bridge.createNode(zone, level.id as never)
|
|
|
|
const res = await pair.client.readResource({
|
|
uri: 'pascal://scene/current/summary',
|
|
})
|
|
const text = (res.contents[0] as { text: string }).text
|
|
expect(text).toContain('12.00 m^2')
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
|
|
test('empty scene returns a markdown skeleton without crashing', async () => {
|
|
const pair = await spinUp(registerSceneSummary)
|
|
try {
|
|
// deliberately do NOT call loadDefault()
|
|
const res = await pair.client.readResource({
|
|
uri: 'pascal://scene/current/summary',
|
|
})
|
|
const text = (res.contents[0] as { text: string }).text
|
|
expect(text.startsWith('# Scene summary')).toBe(true)
|
|
expect(text).toContain('Total nodes: 0')
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('pascal://catalog/items', () => {
|
|
beforeEach(() => resetScene())
|
|
|
|
test('returns built-in catalog subset', async () => {
|
|
const pair = await spinUp(registerCatalogItems)
|
|
try {
|
|
const res = await pair.client.readResource({ uri: 'pascal://catalog/items' })
|
|
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
|
|
expect(content.mimeType).toBe('application/json')
|
|
const parsed = JSON.parse(content.text ?? '{}')
|
|
expect(parsed.status).toBe('ok')
|
|
expect(parsed.items.length).toBeGreaterThan(0)
|
|
expect(parsed.items.map((item: { id: string }) => item.id)).toContain('sofa')
|
|
expect(typeof parsed.note).toBe('string')
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('pascal://agent-guide', () => {
|
|
beforeEach(() => resetScene())
|
|
|
|
test('returns MCP-first project guidance', async () => {
|
|
const pair = await spinUp(registerAgentGuide)
|
|
try {
|
|
const res = await pair.client.readResource({ uri: 'pascal://agent-guide' })
|
|
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
|
|
expect(content.mimeType).toBe('text/markdown')
|
|
const text = content.text ?? ''
|
|
expect(text).toContain('create_project')
|
|
expect(text).toContain('save_scene')
|
|
expect(text).toContain('get_project_status')
|
|
expect(text).toContain('editorUrl')
|
|
expect(text).toContain('0 to 1 along the wall')
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
|
|
test('keeps the legacy agent guide URI as an alias', async () => {
|
|
const pair = await spinUp(registerAgentGuide)
|
|
try {
|
|
const res = await pair.client.readResource({ uri: 'pascal://agent/guide' })
|
|
const text = (res.contents[0] as { text?: string }).text ?? ''
|
|
expect(text).toContain('Pascal MCP Agent Guide')
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('pascal://constraints/{levelId}', () => {
|
|
beforeEach(() => resetScene())
|
|
|
|
test('returns slabs + wall footprints for a known level', async () => {
|
|
const pair = await spinUp(registerConstraints)
|
|
try {
|
|
pair.bridge.loadDefault()
|
|
const level = pair.bridge
|
|
.findNodes({ type: 'level' as never })
|
|
.find((n) => n.type === 'level')
|
|
if (!level) throw new Error('no level')
|
|
// Add a wall so wallPolygons is non-empty.
|
|
const wall = WallNode.parse({
|
|
parentId: level.id,
|
|
start: [0, 0],
|
|
end: [4, 0],
|
|
thickness: 0.2,
|
|
})
|
|
pair.bridge.createNode(wall, level.id as never)
|
|
|
|
const res = await pair.client.readResource({
|
|
uri: `pascal://constraints/${level.id}`,
|
|
})
|
|
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
|
|
expect(content.mimeType).toBe('application/json')
|
|
const parsed = JSON.parse(content.text ?? '{}')
|
|
expect(parsed.levelId).toBe(level.id)
|
|
expect(Array.isArray(parsed.slabs)).toBe(true)
|
|
expect(Array.isArray(parsed.wallPolygons)).toBe(true)
|
|
expect(parsed.wallPolygons.length).toBe(1)
|
|
expect(parsed.wallPolygons[0].wallId).toBe(wall.id)
|
|
expect(Array.isArray(parsed.wallPolygons[0].footprint)).toBe(true)
|
|
expect(parsed.wallPolygons[0].footprint.length).toBeGreaterThan(0)
|
|
// Each footprint point should be [x, y].
|
|
for (const pt of parsed.wallPolygons[0].footprint) {
|
|
expect(pt).toHaveLength(2)
|
|
}
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
|
|
test('returns {error:"level_not_found"} for unknown levelId', async () => {
|
|
const pair = await spinUp(registerConstraints)
|
|
try {
|
|
pair.bridge.loadDefault()
|
|
const res = await pair.client.readResource({
|
|
uri: 'pascal://constraints/level_nope',
|
|
})
|
|
const content = res.contents[0] as { text?: string }
|
|
const parsed = JSON.parse(content.text ?? '{}')
|
|
expect(parsed.error).toBe('level_not_found')
|
|
expect(parsed.slabs).toEqual([])
|
|
expect(parsed.wallPolygons).toEqual([])
|
|
} finally {
|
|
await pair.close()
|
|
}
|
|
})
|
|
})
|