ai-chatbot/packages/core/src/services/drag-session.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

248 lines
8.9 KiB
TypeScript

import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry/registry'
import type { AnyNodeDefinition, DragAction, Relations, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { createDragSession } from './drag-session'
const id = (s: string) => s as AnyNodeId
function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
_calls: {
pauseHistory: number
resumeHistory: number
restoreAll: number
markedDirty: AnyNodeId[]
updated: Array<[AnyNodeId, Partial<AnyNode>]>
}
} {
const calls = {
pauseHistory: 0,
resumeHistory: 0,
restoreAll: 0,
markedDirty: [] as AnyNodeId[],
updated: [] as Array<[AnyNodeId, Partial<AnyNode>]>,
}
const nodes = { ...initial }
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: (nid, patch) => {
calls.updated.push([nid, patch])
const existing = nodes[nid as string]
if (existing) nodes[nid as string] = { ...existing, ...patch } as AnyNode
},
upsert: (n: AnyNode) => {
nodes[n.id as string] = n
return n.id
},
delete: (nid) => {
delete nodes[nid as string]
},
restore: () => {},
restoreAll: () => {
calls.restoreAll += 1
},
markDirty: (nid) => {
calls.markedDirty.push(nid)
},
pauseHistory: () => {
calls.pauseHistory += 1
},
resumeHistory: () => {
calls.resumeHistory += 1
},
getSubtree: () => null,
cloneNodesInto: () => null,
_calls: calls,
}
}
function makeDef(kind: string, relations?: Relations): 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 }) },
}
}
function makeAction(): DragAction<{ id: AnyNodeId }, { x: number }> {
return {
begin: ({ node }) => ({ id: node?.id ?? id('default') }),
preview: (_ctx, point) => ({ x: point[0] }),
apply: (_draft, ctx) => [ctx.id],
cancel: () => {},
}
}
describe('createDragSession', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('start pauses history; commit resumes', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
expect(scene._calls.pauseHistory).toBe(1)
expect(scene._calls.resumeHistory).toBe(0)
session.commit()
expect(scene._calls.resumeHistory).toBe(1)
})
test('cancel resumes history and calls restoreAll', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
session.cancel()
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('move runs preview + apply and marks the returned id dirty', () => {
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(session.getDraft()).toEqual({ x: 1 })
expect(scene._calls.markedDirty).toContain(id('a'))
})
test('snap callback is invoked when defined', () => {
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
...makeAction(),
snap: (draft) => ({ x: Math.round(draft.x) }),
}
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([0.7, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(session.getDraft()).toEqual({ x: 1 })
})
test('commit returns false when action.commit returns false; calls action.cancel and restoreAll', () => {
const cancelSpy = mock(() => {})
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
...makeAction(),
cancel: cancelSpy,
commit: () => false,
}
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
const result = session.commit()
expect(result).toBe(false)
expect(cancelSpy).toHaveBeenCalledTimes(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('move is a no-op when session is not active', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(scene._calls.markedDirty.length).toBe(0)
})
test('repeated start is a no-op (re-entry guard)', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
session.start({ point: [99, 99] })
expect(scene._calls.pauseHistory).toBe(1) // only one pause
})
test('dispose mid-drag cancels and cleans up', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
expect(session.isActive()).toBe(true)
session.dispose()
expect(session.isActive()).toBe(false)
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('dispose when inactive is a no-op', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.dispose()
expect(scene._calls.pauseHistory).toBe(0)
expect(scene._calls.resumeHistory).toBe(0)
})
test('onCommit callback fires on successful commit', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.commit()
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(0)
})
test('onCancel callback fires on explicit cancel', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.cancel()
expect(onCommit).toHaveBeenCalledTimes(0)
expect(onCancel).toHaveBeenCalledTimes(1)
})
test('dispose does NOT fire onCancel (silent cleanup)', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.dispose()
expect(onCommit).toHaveBeenCalledTimes(0)
expect(onCancel).toHaveBeenCalledTimes(0)
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('dirty cascade fires once per id even across multiple move ticks', () => {
// Register a kind with no relations — cascade returns just {startId}.
registerNode(makeDef('thing'))
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([2, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([3, 0], { shift: false, alt: false, ctrl: false, meta: false })
// a is marked once, not three times
expect(scene._calls.markedDirty.filter((mid) => mid === id('a')).length).toBe(1)
})
test('dirty cascade follows hosts relations from the registry', () => {
registerNode(makeDef('wall', { hosts: ['door'] }))
registerNode(makeDef('door'))
const scene = makeSpyScene({
w: { id: id('w'), type: 'wall', children: [id('d')] } as any,
d: { id: id('d'), type: 'door', parentId: id('w') } as any,
})
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
begin: () => ({ id: id('w') }),
preview: (_ctx, point) => ({ x: point[0] }),
apply: (_draft, ctx) => [ctx.id],
cancel: () => {},
}
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('w') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
// both wall and door marked dirty
expect(scene._calls.markedDirty).toContain(id('w'))
expect(scene._calls.markedDirty).toContain(id('d'))
})
})