ai-chatbot/packages/core/src/registry/relations-resolver.test.ts
Wassim SAMAD 1fd59dd9cd
feat(editor): preset-system primitives (#340) (#341)
* feat(editor): preset-system primitives — presettable, sceneApi subtree round-trip, isolate + setCaptureMode enum, headless exports

Per pascalorg/editor#340 (redesigned: single live canvas, no Viewer scene prop).

Core
- `capabilities.presettable` on `NodeDefinition` + `isPresettable` /
  `isPresettableKind` helpers. Explicit `false` on level / building /
  site / zone / spawn / guide / scan / item; implicit `true` for any
  kind with `def.parametrics`.
- `sceneApi.getSubtreeSnapshot(rootId)` + `materializeSubtree(subtree,
  position, parentId?)` for round-tripping a node subtree through
  catalog storage. Strips id / parentId / absolute root position /
  host refs (`wallId`, `wallT`); fresh IDs minted at materialize time;
  child ordering preserved (FIFO walk).

Viewer
- `<Viewer isolate>` prop + `ViewerHandle.setIsolated(ids | null)`.
  Walks `sceneRegistry`, hides every registered group not in the
  isolated set's ancestor + descendant closure. Building block for
  preset capture + future focus-mode UX.

Editor
- `useEditor.captureMode: CaptureMode` discriminated union
  (`idle` | `standard` | `preset`). `isCaptureMode` stays as a derived
  boolean for the existing read sites; `setCaptureMode` accepts both
  the boolean shape (back-compat) and the enum.
- `preset` capture mode in `SnapshotCaptureOverlay`: drag locked to a
  square, mode-picker hidden, transparent flag forwarded through the
  `camera-controls:generate-thumbnail` emitter event.
- Headless exports: `Inspector` (alias of `ParametricInspector`),
  `FloatingMenu` (alias of `FloatingActionMenu`), `ToolbarLeft` /
  `ToolbarRight` (aliases of `ViewerToolbarLeft` / `ViewerToolbarRight`),
  `useSelection` hook returning `{selectedIds, selectedNode, building/
  level/zone}`, plus re-exports of `useScene` / `useViewer` from core /
  viewer so consumer shells (community, embedders) need only one import.

Out of scope by design (see issue #340 "Out of scope"): a separate
offscreen Viewer rendering an arbitrary subtree. The unified preset
modal captures inside the live canvas via isolation + the existing
snapshot pipeline — no `useScene` factory / React context refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): split snapshot/materialize into pure getSubtree + cloneNodesInto; add def.hostRefFields; auto-stage preset capture square

Per pascalorg/editor#340 redesign discussion: the editor's scene API
should expose *pure* primitives and let the host (community modal,
embedders) own storage shape, position stripping, and host-ref
re-derivation policy.

Editor API delta
- `sceneApi.getSubtreeSnapshot(rootId)` → `sceneApi.getSubtree(rootId)`
  Returns the live subtree verbatim (BFS via `children[]`, no clones,
  no stripping). Callers deep-clone if they need persistence.
- `sceneApi.materializeSubtree(subtree, pos, parent?)`
  → `sceneApi.cloneNodesInto(nodes, { rootId, parentId?, position? })`
  Generic clone-and-insert. Deep-clones via JSON, mints fresh ids
  preserving the prefix, rewires parent/children, stamps position +
  parent if supplied. Host-ref-agnostic — `wallId`/`wallT` etc are
  preserved verbatim.
- New `capabilities.hostRefFields?: string[]` on `NodeDefinition`.
  Declares per kind which schema fields are placement-derived so the
  host strips them at preset-save time. Declared on door (`['wallId']`),
  window (`['wallId']`), item (`['wallId', 'wallT']`).
- New `getHostRefFields(def)` exported from `@pascal-app/core`.

Removed the intermediate token-based payload format (`NodeSubtree`,
`buildSubtreeSnapshot`, `materializeSubtree`, `SubtreeNode`).

UX polish
- `<SnapshotCaptureOverlay>` in `preset` mode now auto-stages a centered
  square crop sized to ~75% of the shorter viewport dimension. The
  user can pan / move / resize within square-aspect, but doesn't have
  to drag from scratch — clicking the capture button works
  immediately on entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): lock preset capture frame; allow item presets

- SnapshotCaptureOverlay: in `preset` mode, the auto-staged centered
  square is now fully locked — corner handles hidden, the dim layer is
  click-through (no drag-to-move, no drag-to-resize). The user just
  adjusts the camera (orbit / pan / zoom) and clicks capture. The
  letterbox + dashed border stay visible as a cosmetic frame.
- `item.capabilities.presettable` removed (implicit `true` via
  `def.parametrics`). Enables compositions like "table-with-plants",
  "shelf-with-books" where the preset root may be an item and other
  items ride along as descendants. The GLB-kind item catalog is
  unchanged; presets become siblings of GLB rows under the same
  `items` table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): auto-frame camera on preset capture entry; restore on exit

`<CustomCameraControls>` now watches `useEditor.captureMode` and, when
preset capture mode begins, flies the camera to a pose that fits the
union bounds of the isolated subtree inside the locked square crop —
no more hunting for the subject after opening the modal. The
pre-capture pose is stashed and restored on exit so the user lands
exactly where they were.

The user can still pan / orbit / zoom from the auto-staged pose if
they want a different angle before snapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:49:50 -04:00

211 lines
7.3 KiB
TypeScript

import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { nodeRegistry, registerNode } from './registry'
import { cascadeDirty, collectDescendants, type SpatialQuery } from './relations-resolver'
import type { AnyNodeDefinition, Relations, SceneApi } from './types'
const id = (s: string) => s as AnyNodeId
function makeDef(
kind: string,
relations?: Relations,
overrides: Partial<AnyNodeDefinition> = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
relations,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
...overrides,
}
}
function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): AnyNode {
return {
id: id(idStr),
type: kind,
parentId: null,
visible: true,
...extra,
} as unknown as AnyNode
}
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {},
upsert: () => id(''),
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
}
}
describe('cascadeDirty', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('starting node alone when registry is empty', () => {
const scene = makeFakeScene({ a: makeNode('unknown', 'a') })
const dirty = cascadeDirty(id('a'), { scene })
expect(Array.from(dirty)).toEqual([id('a')])
})
test('starting node alone when no relations declared', () => {
registerNode(makeDef('thing'))
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const dirty = cascadeDirty(id('a'), { scene })
expect(Array.from(dirty)).toEqual([id('a')])
})
test('hosts cascade marks matching children dirty', () => {
registerNode(makeDef('wall', { hosts: ['door', 'window'] }))
registerNode(makeDef('door'))
registerNode(makeDef('window'))
registerNode(makeDef('lamp'))
const wall = makeNode('wall', 'w1', {
children: [id('d1'), id('w2'), id('l1')],
} as Partial<AnyNode>)
const scene = makeFakeScene({
w1: wall,
d1: makeNode('door', 'd1', { parentId: id('w1') }),
w2: makeNode('window', 'w2', { parentId: id('w1') }),
l1: makeNode('lamp', 'l1', { parentId: id('w1') }), // not in hosts list
})
const dirty = cascadeDirty(id('w1'), { scene })
const ids = Array.from(dirty).sort()
expect(ids).toEqual([id('d1'), id('w1'), id('w2')]) // l1 excluded
})
test('hosts cascade is recursive but bounded by maxDepth', () => {
registerNode(makeDef('a', { hosts: ['a'] })) // a hosts more a
const nodes: Record<string, AnyNode> = {}
for (let i = 0; i < 25; i++) {
const childId = i < 24 ? id(`a${i + 1}`) : undefined
nodes[`a${i}`] = makeNode('a', `a${i}`, {
children: childId ? [childId] : [],
} as Partial<AnyNode>)
}
const scene = makeFakeScene(nodes)
const dirty = cascadeDirty(id('a0'), { scene, maxDepth: 5 })
expect(dirty.size).toBe(6) // a0 + 5 descendants
})
test('affectsSpatial cascade uses spatialQuery to find neighbors', () => {
registerNode(makeDef('wall', { affectsSpatial: ['slab', 'zone'] }))
registerNode(makeDef('slab'))
registerNode(makeDef('zone'))
const scene = makeFakeScene({
w1: makeNode('wall', 'w1'),
s1: makeNode('slab', 's1'),
z1: makeNode('zone', 'z1'),
unrelated: makeNode('door', 'unrelated'),
})
const spatialQuery: SpatialQuery = (node, kinds) => {
if (node.id !== id('w1')) return []
const matches: AnyNodeId[] = []
if (kinds.includes('slab')) matches.push(id('s1'))
if (kinds.includes('zone')) matches.push(id('z1'))
return matches
}
const dirty = cascadeDirty(id('w1'), { scene, spatialQuery })
expect(Array.from(dirty).sort()).toEqual([id('s1'), id('w1'), id('z1')])
})
test('affectsSpatial is a no-op when no spatialQuery is provided', () => {
registerNode(makeDef('wall', { affectsSpatial: ['slab'] }))
const scene = makeFakeScene({ w1: makeNode('wall', 'w1') })
const dirty = cascadeDirty(id('w1'), { scene })
expect(Array.from(dirty)).toEqual([id('w1')]) // spatial branch silently skipped
})
test('cycle in hosts cascade does not loop forever', () => {
registerNode(makeDef('a', { hosts: ['a'] }))
const nodes: Record<string, AnyNode> = {
a1: makeNode('a', 'a1', { children: [id('a2')] } as Partial<AnyNode>),
a2: makeNode('a', 'a2', { children: [id('a1')] } as Partial<AnyNode>), // cycle
}
const scene = makeFakeScene(nodes)
const dirty = cascadeDirty(id('a1'), { scene })
expect(dirty.size).toBe(2)
expect(dirty.has(id('a1'))).toBe(true)
expect(dirty.has(id('a2'))).toBe(true)
})
test('custom childQuery overrides the default node.children lookup', () => {
registerNode(makeDef('wall', { hosts: ['door'] }))
registerNode(makeDef('door'))
const scene = makeFakeScene({
w1: makeNode('wall', 'w1'), // no children field
d1: makeNode('door', 'd1', { parentId: id('w1') }),
})
// childQuery iterates the scene to find parentId matches — what you would
// do for kinds that don't carry an explicit children array.
const childQuery = (node: AnyNode) => {
const result: AnyNodeId[] = []
for (const candidate of [id('d1')]) {
const c = scene.get(candidate)
if (c && c.parentId === node.id) result.push(c.id)
}
return result
}
const dirty = cascadeDirty(id('w1'), { scene, childQuery })
expect(Array.from(dirty).sort()).toEqual([id('d1'), id('w1')])
})
})
describe('collectDescendants', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns just the start when no children', () => {
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const result = collectDescendants(id('a'), { scene })
expect(Array.from(result)).toEqual([id('a')])
})
test('returns full subtree regardless of relations declarations', () => {
// No def registered — descendants still found via the children array.
const scene = makeFakeScene({
root: makeNode('thing', 'root', { children: [id('c1'), id('c2')] } as Partial<AnyNode>),
c1: makeNode('thing', 'c1', { children: [id('g1')] } as Partial<AnyNode>),
c2: makeNode('thing', 'c2'),
g1: makeNode('thing', 'g1'),
})
const result = collectDescendants(id('root'), { scene })
expect(Array.from(result).sort()).toEqual([id('c1'), id('c2'), id('g1'), id('root')])
})
test('respects maxDepth', () => {
const scene = makeFakeScene({
a: makeNode('thing', 'a', { children: [id('b')] } as Partial<AnyNode>),
b: makeNode('thing', 'b', { children: [id('c')] } as Partial<AnyNode>),
c: makeNode('thing', 'c', { children: [id('d')] } as Partial<AnyNode>),
d: makeNode('thing', 'd'),
})
const result = collectDescendants(id('a'), { scene, maxDepth: 2 })
expect(Array.from(result).sort()).toEqual([id('a'), id('b'), id('c')]) // d truncated
})
})