The 7th and last of the legacy interaction flags. The node being placed/moved
now lives inside the interaction scope's `placing`/`moving` variant (carried
inline, since fresh-placement/duplicate drafts aren't in the scene yet), read via
`useMovingNode()` / `getMovingNode()` / `movingNodeOf(scope)`.
- scope.ts: `placing`/`moving` carry `node: AnyNode`; add `movingNodeOf`.
- use-interaction-scope.ts: `useMovingNode` (hook) + `getMovingNode` (imperative);
no useRef snapshot needed — the node is set once at `begin`, stable for the gesture.
- use-editor.tsx: drop the `movingNode` field + the `set({ movingNode })` writes.
`setMovingNode` still drives the scope and still sets `movingNodeOrigin` /
`placementDragMode`, so cross-store subscribers keep firing. Param + ~90 call
sites unchanged.
- migrate ~17 reader sites to `useMovingNode()` / `getMovingNode()`; drop
`movingNode` from lib/scene.ts; export `movingNodeOf`.
Every interaction flag is now derived from the single authoritative scope; only
`movingNodeOrigin` + `placementDragMode` intentionally remain as useEditor flags
(they outlive the scope / gate companion behavior).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
6.3 KiB
TypeScript
187 lines
6.3 KiB
TypeScript
import { afterEach, describe, expect, test } from 'bun:test'
|
|
import type { AnyNode } from '@pascal-app/core'
|
|
import {
|
|
type ActiveInteractionScope,
|
|
editingHoleInfo,
|
|
handleDragInfo,
|
|
isActive,
|
|
isIdle,
|
|
scopeNodeId,
|
|
selectionEnabled,
|
|
} from '../lib/interaction/scope'
|
|
import useInteractionScope from './use-interaction-scope'
|
|
|
|
// A placing/moving scope carries the node inline. Tests only assert on id/type,
|
|
// so a structural stand-in is enough.
|
|
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
|
|
|
|
function reset() {
|
|
useInteractionScope.getState().end()
|
|
}
|
|
afterEach(reset)
|
|
|
|
describe('use-interaction-scope state machine', () => {
|
|
test('starts idle', () => {
|
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
|
expect(isIdle(useInteractionScope.getState().scope)).toBe(true)
|
|
})
|
|
|
|
test('begin enters an interaction; end returns to idle atomically', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({
|
|
kind: 'moving',
|
|
node: mockNode('item_1', 'item'),
|
|
nodeId: 'item_1',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
})
|
|
expect(useInteractionScope.getState().scope).toEqual({
|
|
kind: 'moving',
|
|
node: mockNode('item_1', 'item'),
|
|
nodeId: 'item_1',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
})
|
|
s.end()
|
|
// No interaction payload leaks past end — the scope is plain idle, so a
|
|
// stale nodeId/handle is unrepresentable.
|
|
expect(useInteractionScope.getState().scope).toEqual({ kind: 'idle' })
|
|
expect(scopeNodeId(useInteractionScope.getState().scope)).toBeNull()
|
|
})
|
|
|
|
test('begin is single-owner: a new interaction replaces the prior one', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({ kind: 'drafting', tool: 'wall' })
|
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
|
const scope = useInteractionScope.getState().scope
|
|
expect(scope.kind).toBe('handle-drag')
|
|
// The prior drafting payload is gone — illegal "drafting + handle-drag"
|
|
// combination is unrepresentable.
|
|
expect(scopeNodeId(scope)).toBe('wall_1')
|
|
})
|
|
|
|
test('update patches the live payload of the active scope', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({
|
|
kind: 'placing',
|
|
node: mockNode('i1', 'item'),
|
|
nodeId: 'i1',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
pressDrag: false,
|
|
})
|
|
s.update({ pressDrag: true })
|
|
const scope = useInteractionScope.getState().scope
|
|
expect(scope.kind === 'placing' && scope.pressDrag).toBe(true)
|
|
})
|
|
|
|
test('update is a no-op when idle', () => {
|
|
useInteractionScope.getState().update({
|
|
kind: 'moving',
|
|
node: mockNode('x', 'item'),
|
|
nodeId: 'x',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
})
|
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
|
})
|
|
|
|
test('update cannot change which interaction is running', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({
|
|
kind: 'moving',
|
|
node: mockNode('i1', 'item'),
|
|
nodeId: 'i1',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
})
|
|
s.update({
|
|
kind: 'placing',
|
|
node: mockNode('i1', 'item'),
|
|
nodeId: 'i1',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
pressDrag: true,
|
|
})
|
|
expect(useInteractionScope.getState().scope.kind).toBe('moving')
|
|
})
|
|
|
|
test('selectionEnabled only while idle', () => {
|
|
const s = useInteractionScope.getState()
|
|
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(true)
|
|
s.begin({ kind: 'box-select' })
|
|
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(false)
|
|
expect(isActive(useInteractionScope.getState().scope)).toBe(true)
|
|
})
|
|
|
|
test('end is idempotent', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.end()
|
|
s.end()
|
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
|
})
|
|
})
|
|
|
|
describe('derived flag views are leak-free (no parallel flags)', () => {
|
|
const scope = () => useInteractionScope.getState().scope
|
|
|
|
test('handleDragInfo mirrors handle-drag and clears on end', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
|
expect(handleDragInfo(scope())).toEqual({ nodeId: 'wall_1', label: 'height' })
|
|
s.end()
|
|
// After end the derived view is null — a stale activeHandleDrag is
|
|
// unrepresentable because it is a pure function of the single scope.
|
|
expect(handleDragInfo(scope())).toBeNull()
|
|
})
|
|
|
|
test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 })
|
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
|
|
s.end()
|
|
expect(editingHoleInfo(scope())).toBeNull()
|
|
})
|
|
|
|
test('a non-hole reshape never reads as an editing hole', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' })
|
|
expect(editingHoleInfo(scope())).toBeNull()
|
|
})
|
|
|
|
test('switching interactions never leaks the prior derived view', () => {
|
|
const s = useInteractionScope.getState()
|
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
|
// Single-owner replacement: the handle-drag view must vanish the instant a
|
|
// different interaction begins — the two cannot be simultaneously active.
|
|
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 })
|
|
expect(handleDragInfo(scope())).toBeNull()
|
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
|
|
})
|
|
|
|
test('every active scope kind leaves at most the views it owns', () => {
|
|
const s = useInteractionScope.getState()
|
|
const kinds: ActiveInteractionScope[] = [
|
|
{
|
|
kind: 'placing',
|
|
node: mockNode('i', 'item'),
|
|
nodeId: 'i',
|
|
nodeType: 'item',
|
|
view: '3d',
|
|
pressDrag: false,
|
|
},
|
|
{ kind: 'moving', node: mockNode('i', 'item'), nodeId: 'i', nodeType: 'item', view: '3d' },
|
|
{ kind: 'drafting', tool: 'wall' },
|
|
{ kind: 'box-select' },
|
|
{ kind: 'painting' },
|
|
]
|
|
for (const k of kinds) {
|
|
s.begin(k)
|
|
// None of these own a handle-drag or hole view.
|
|
expect(handleDragInfo(scope())).toBeNull()
|
|
expect(editingHoleInfo(scope())).toBeNull()
|
|
}
|
|
s.end()
|
|
})
|
|
})
|