@pascal-app/editor 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -0,0 +1,175 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type ItemEvent,
4
+ ItemNode,
5
+ nodeRegistry,
6
+ registerNode,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { Group, Vector3 } from 'three'
11
+ import useEditor from '../../../store/use-editor'
12
+ import useInteractionScope from '../../../store/use-interaction-scope'
13
+ import { itemSurfaceStrategy } from './placement-strategies'
14
+ import type { PlacementContext } from './placement-types'
15
+
16
+ const asset = {
17
+ id: 'table',
18
+ name: 'Table',
19
+ category: 'furniture',
20
+ thumbnail: '',
21
+ src: '/table.glb',
22
+ dimensions: [2, 1, 3],
23
+ }
24
+ let restoreRegistry: () => void
25
+ const editorState = useEditor.getState()
26
+ const scopeState = useInteractionScope.getState()
27
+
28
+ beforeEach(() => {
29
+ restoreRegistry = nodeRegistry._snapshot()
30
+ nodeRegistry._reset()
31
+ registerNode({
32
+ kind: 'item',
33
+ schemaVersion: 1,
34
+ schema: ItemNode,
35
+ category: 'furnish',
36
+ snapProfile: 'item',
37
+ defaults: () => ({}),
38
+ capabilities: {},
39
+ })
40
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
41
+ useEditor.setState({ mode: 'build', tool: 'item', gridSnapStep: 0.5 })
42
+ useEditor.getState().setSnappingMode('item', 'grid')
43
+ })
44
+
45
+ afterEach(() => {
46
+ restoreRegistry()
47
+ sceneRegistry.nodes.delete('item_surface-test')
48
+ useEditor.setState(editorState)
49
+ useInteractionScope.setState(scopeState)
50
+ })
51
+
52
+ function surface(overrides: Partial<ItemNode> = {}) {
53
+ const host = ItemNode.parse({ id: 'item_surface-test', asset, ...overrides })
54
+ const mesh = new Group()
55
+ mesh.position.set(4, 2, -3)
56
+ mesh.rotation.y = Math.PI / 3
57
+ mesh.updateMatrixWorld(true)
58
+ sceneRegistry.nodes.set(host.id, mesh)
59
+ const world = mesh.localToWorld(new Vector3(0.37, 0.81, -0.39))
60
+ const event = {
61
+ node: host,
62
+ object: mesh,
63
+ position: world.toArray(),
64
+ localPosition: [0.37, 0.81, -0.39],
65
+ normal: [0, 1, 0],
66
+ stopPropagation: () => {},
67
+ nativeEvent: {} as ItemEvent['nativeEvent'],
68
+ } as ItemEvent
69
+ return { host, mesh, event }
70
+ }
71
+
72
+ function enterSurface(
73
+ event: ItemEvent,
74
+ dimensions: [number, number, number],
75
+ yaw = 0,
76
+ draftId?: ItemNode['id'],
77
+ ) {
78
+ const draft = ItemNode.parse({ id: draftId, asset: { ...asset, dimensions } })
79
+ return itemSurfaceStrategy.enter(
80
+ {
81
+ asset: draft.asset,
82
+ draftItem: draft,
83
+ state: { surface: 'floor' },
84
+ currentCursorRotationY: yaw,
85
+ } as PlacementContext,
86
+ event,
87
+ )
88
+ }
89
+
90
+ describe('itemSurfaceStrategy production placement', () => {
91
+ test('scaled host accepts centred rotated and oversized footprints', () => {
92
+ const { event } = surface({ scale: [2, 1, 0.5] })
93
+ expect(enterSurface(event, [4, 5, 1.5], 0)).not.toBeNull()
94
+ expect(enterSurface(event, [4, 5, 1.5], Math.PI / 3)).not.toBeNull()
95
+ expect(enterSurface(event, [4.01, 1, 1.5], 0)).not.toBeNull()
96
+ expect(enterSurface(event, [4, 1, 1.51], 0)).not.toBeNull()
97
+ })
98
+
99
+ test('rejects ceiling hosts, low profiles, side hits, and absent normals', () => {
100
+ for (const overrides of [
101
+ { asset: { ...asset, attachTo: 'ceiling' } },
102
+ { asset: { ...asset, dimensions: [2, 0.05, 3] } },
103
+ ]) {
104
+ const { event } = surface(overrides as Partial<ItemNode>)
105
+ expect(enterSurface(event, [0.5, 1, 0.5], 0)).toBeNull()
106
+ }
107
+ const { event } = surface()
108
+ for (const normal of [[1, 0, 0], [0, -1, 0], undefined]) {
109
+ expect(enterSurface({ ...event, normal } as ItemEvent, [0.5, 1, 0.5], 0)).toBeNull()
110
+ }
111
+ })
112
+
113
+ test('uses scaled asset surface height, snapped host-local XZ, and continuous world yaw', () => {
114
+ const { mesh, event } = surface({
115
+ asset: { ...asset, surface: { height: 0.7 } } as ItemNode['asset'],
116
+ scale: [1, 2, 1],
117
+ })
118
+ const pose = enterSurface(event, [0.5, 1, 0.5], Math.PI / 4)!
119
+ expect(pose.nodeUpdate.position!).toEqual([0.25, 1.4, -0.25])
120
+ expect(pose.nodeUpdate.rotation![1]! + Math.PI / 3).toBeCloseTo(Math.PI / 4)
121
+ expect(pose.cursorPosition).toEqual(
122
+ mesh.localToWorld(new Vector3(...pose.nodeUpdate.position!)).toArray(),
123
+ )
124
+ })
125
+
126
+ test('uses local hit Y without surface metadata and honors snapping off', () => {
127
+ useEditor.getState().setSnappingMode('item', 'off')
128
+ const { event } = surface()
129
+ const pose = enterSurface(event, [0.5, 1, 0.5], 0)!
130
+ expect(pose.nodeUpdate.position![0]).toBeCloseTo(0.37)
131
+ expect(pose.nodeUpdate.position![1]).toBeCloseTo(0.81)
132
+ expect(pose.nodeUpdate.position![2]).toBeCloseTo(-0.39)
133
+ })
134
+
135
+ test('rejects the moving node and its descendants', () => {
136
+ const { host, event } = surface({ parentId: 'item_moving' })
137
+ expect(enterSurface(event, [0.5, 1, 0.5], 0, host.id)).toBeNull()
138
+ expect(enterSurface(event, [0.5, 1, 0.5], 0, 'item_moving')).toBeNull()
139
+ })
140
+
141
+ test('catalog strategy keeps its enter pose and position-only move contract', () => {
142
+ const { host, event } = surface()
143
+ const draft = ItemNode.parse({ asset: { ...asset, dimensions: [0.5, 1, 0.5] } })
144
+ const ctx = {
145
+ asset: draft.asset,
146
+ draftItem: draft,
147
+ state: {
148
+ surface: 'floor',
149
+ wallId: null,
150
+ roofSegmentId: null,
151
+ ceilingId: null,
152
+ surfaceItemId: null,
153
+ shelfId: null,
154
+ },
155
+ currentCursorRotationY: 0.2,
156
+ gridPosition: new Vector3(),
157
+ levelId: 'level_test',
158
+ } as PlacementContext
159
+ const enter = itemSurfaceStrategy.enter(ctx, event)!
160
+ expect(enter.nodeUpdate.parentId).toBe(host.id)
161
+ expect(enter.nodeUpdate.rotation?.[1]).toBeCloseTo(0.2 - Math.PI / 3)
162
+ const oldNodes = useScene.getState().nodes
163
+ useScene.setState({ nodes: { ...oldNodes, [host.id]: host } })
164
+ try {
165
+ const move = itemSurfaceStrategy.move(
166
+ { ...ctx, state: { ...ctx.state, surface: 'item-surface', surfaceItemId: host.id } },
167
+ event,
168
+ )!
169
+ expect(move.nodeUpdate).toEqual({ position: enter.gridPosition })
170
+ expect(move.cursorRotationY).toBe(0.2)
171
+ } finally {
172
+ useScene.setState({ nodes: oldNodes })
173
+ }
174
+ })
175
+ })
@@ -0,0 +1,210 @@
1
+ import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
2
+ import * as core from '@pascal-app/core'
3
+ import {
4
+ type ItemEvent,
5
+ ItemNode,
6
+ nodeRegistry,
7
+ registerNode,
8
+ sceneRegistry,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { Group, Vector3 } from 'three'
12
+ import useEditor from '../../../store/use-editor'
13
+ import useInteractionScope from '../../../store/use-interaction-scope'
14
+ import { itemSurfaceStrategy } from './placement-strategies'
15
+ import type { PlacementContext } from './placement-types'
16
+
17
+ const asset = {
18
+ id: 'table',
19
+ name: 'Table',
20
+ category: 'furniture',
21
+ thumbnail: '',
22
+ src: '/table.glb',
23
+ dimensions: [2, 1, 3],
24
+ }
25
+ const host = ItemNode.parse({ id: 'item_strategy-resolver-host', asset, scale: [2, 1, 0.5] })
26
+ const child = ItemNode.parse({
27
+ id: 'item_strategy-resolver-child',
28
+ asset: { ...asset, dimensions: [0.5, 1, 0.5] },
29
+ rotation: [0.1, 0.2, 0.3],
30
+ })
31
+ let restoreRegistry: () => void
32
+ let savedNodes: ReturnType<typeof useScene.getState>['nodes']
33
+ let savedEditor: ReturnType<typeof useEditor.getState>
34
+ let savedScope: ReturnType<typeof useInteractionScope.getState>
35
+ let resolver: ReturnType<typeof spyOn<typeof core, 'resolveSurfacePlacement'>>
36
+
37
+ beforeEach(() => {
38
+ restoreRegistry = nodeRegistry._snapshot()
39
+ nodeRegistry._reset()
40
+ registerNode({
41
+ kind: 'item',
42
+ schemaVersion: 1,
43
+ schema: ItemNode,
44
+ category: 'furnish',
45
+ snapProfile: 'item',
46
+ defaults: () => ({}),
47
+ capabilities: {},
48
+ })
49
+ savedNodes = useScene.getState().nodes
50
+ savedEditor = useEditor.getState()
51
+ savedScope = useInteractionScope.getState()
52
+ useScene.setState({ nodes: { [host.id]: host, [child.id]: child } })
53
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
54
+ useEditor.setState({ mode: 'build', tool: 'item', gridSnapStep: 0.5 })
55
+ useEditor.getState().setSnappingMode('item', 'grid')
56
+ const mesh = new Group()
57
+ mesh.position.set(4, 2, -3)
58
+ mesh.rotation.set(0.1, Math.PI / 3, -0.1)
59
+ mesh.scale.set(2, 1, 0.5)
60
+ mesh.updateMatrixWorld(true)
61
+ sceneRegistry.nodes.set(host.id, mesh)
62
+ resolver = spyOn(core, 'resolveSurfacePlacement')
63
+ })
64
+
65
+ afterEach(() => {
66
+ resolver.mockRestore()
67
+ restoreRegistry()
68
+ sceneRegistry.nodes.delete(host.id)
69
+ useScene.setState({ nodes: savedNodes })
70
+ useEditor.setState(savedEditor)
71
+ useInteractionScope.setState(savedScope)
72
+ })
73
+
74
+ function context(draftItem = child): PlacementContext {
75
+ return {
76
+ asset: draftItem.asset,
77
+ draftItem,
78
+ state: {
79
+ surface: 'floor',
80
+ wallId: null,
81
+ roofSegmentId: null,
82
+ ceilingId: null,
83
+ surfaceItemId: null,
84
+ shelfId: null,
85
+ },
86
+ currentCursorRotationY: 0.2,
87
+ gridPosition: new Vector3(),
88
+ levelId: 'level_test',
89
+ } as PlacementContext
90
+ }
91
+
92
+ function hit(): ItemEvent {
93
+ const object = sceneRegistry.nodes.get(host.id)!
94
+ return {
95
+ node: host,
96
+ object,
97
+ normal: [0, 1, 0],
98
+ position: object.localToWorld(new Vector3(0.37, 0.81, -0.39)).toArray(),
99
+ localPosition: [0.37, 0.81, -0.39],
100
+ nativeEvent: {},
101
+ stopPropagation() {},
102
+ } as ItemEvent
103
+ }
104
+
105
+ function onHost(ctx: PlacementContext): PlacementContext {
106
+ return { ...ctx, state: { ...ctx.state, surface: 'item-surface', surfaceItemId: host.id } }
107
+ }
108
+
109
+ // Expected poses were captured from resolveItemSurfacePlacement at ad2a57b8 before its removal.
110
+ test.each([
111
+ [4.01, 1, 1.5],
112
+ [4, 1, 1.51],
113
+ ] as [
114
+ number,
115
+ number,
116
+ number,
117
+ ][])('enter and move accept oversized objects with their centres on the host: %j', (width, height, depth) => {
118
+ const draft = ItemNode.parse({
119
+ ...child,
120
+ asset: { ...asset, dimensions: [width, height, depth] },
121
+ })
122
+ const ctx = context(draft)
123
+ const event = hit()
124
+ const before = useScene.getState().nodes
125
+ expect(itemSurfaceStrategy.enter(ctx, event)).not.toBeNull()
126
+ const expected: {
127
+ position: [number, number, number]
128
+ worldPosition: [number, number, number]
129
+ } =
130
+ width === 4.01
131
+ ? {
132
+ position: [0.5049999999999999, 0.8099999999999996, -0.25],
133
+ worldPosition: [4.434656461734313, 2.8017166019018256, -3.927442316539199],
134
+ }
135
+ : {
136
+ position: [0.5, 0.8099999999999996, -0.245],
137
+ worldPosition: [4.4318465044173845, 2.8017248933478855, -3.9175249544338326],
138
+ }
139
+ const moved = itemSurfaceStrategy.move(onHost(ctx), event)!
140
+ expect(moved.nodeUpdate).toEqual({ position: expected.position })
141
+ expect(moved.cursorPosition).toEqual(expected.worldPosition)
142
+ expect(moved.cursorRotationY).toBe(ctx.currentCursorRotationY)
143
+ expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true])
144
+ expect(useScene.getState().nodes).toBe(before)
145
+ })
146
+
147
+ test.each([
148
+ 'grid',
149
+ 'off',
150
+ ] as const)('accepted poses match the frozen live capture with snapping %s', (mode) => {
151
+ useEditor.getState().setSnappingMode('item', mode)
152
+ const ctx = context()
153
+ const event = hit()
154
+ const expected: {
155
+ position: [number, number, number]
156
+ worldPosition: [number, number, number]
157
+ rotationY: number
158
+ } = {
159
+ ...(mode === 'grid'
160
+ ? {
161
+ position: [0.25, 0.8099999999999996, -0.25],
162
+ worldPosition: [4.180930399588417, 2.808503845962115, -3.4850883647019826],
163
+ }
164
+ : {
165
+ position: [0.36999999999999966, 0.8099999999999996, -0.39000000000000146],
166
+ worldPosition: [4.239709121156868, 2.808804018339912, -3.728080076057168],
167
+ }),
168
+ rotationY: -0.84936351290154,
169
+ }
170
+ const entered = itemSurfaceStrategy.enter(ctx, event)!
171
+ expect(entered.nodeUpdate).toEqual({
172
+ position: expected.position,
173
+ parentId: host.id,
174
+ rotation: [0.1, expected.rotationY, 0.3],
175
+ })
176
+ expect(entered.cursorPosition).toEqual(expected.worldPosition)
177
+ expect(itemSurfaceStrategy.move(onHost(ctx), event)!.nodeUpdate).toEqual({
178
+ position: expected.position,
179
+ })
180
+ expect(resolver).toHaveBeenCalledTimes(2)
181
+ })
182
+
183
+ test('resolver rejection leaves both enter and move drafts alone', () => {
184
+ resolver.mockReturnValue(null)
185
+ const ctx = context()
186
+ const before = useScene.getState().nodes
187
+ expect(itemSurfaceStrategy.enter(ctx, hit())).toBeNull()
188
+ expect(itemSurfaceStrategy.move(onHost(ctx), hit())).toBeNull()
189
+ expect(useScene.getState().nodes).toBe(before)
190
+ expect(ctx.draftItem).toBe(child)
191
+ })
192
+
193
+ test('move still rejects side hits and missing normals', () => {
194
+ for (const normal of [[1, 0, 0], [0, -1, 0], undefined] as ItemEvent['normal'][]) {
195
+ const event = { ...hit(), normal }
196
+ expect(itemSurfaceStrategy.enter(context(), event)).toBeNull()
197
+ expect(itemSurfaceStrategy.move(onHost(context()), event)).toBeNull()
198
+ }
199
+ })
200
+
201
+ test('enter rejects itself and indirect descendants before asking the resolver', () => {
202
+ expect(itemSurfaceStrategy.enter(context(host), hit())).toBeNull()
203
+ const middle = ItemNode.parse({ id: 'item_strategy-middle', parentId: child.id, asset })
204
+ const descendant = { ...host, parentId: middle.id }
205
+ useScene.setState({
206
+ nodes: { ...useScene.getState().nodes, [middle.id]: middle, [host.id]: descendant },
207
+ })
208
+ expect(itemSurfaceStrategy.enter(context(), { ...hit(), node: descendant })).toBeNull()
209
+ expect(resolver).not.toHaveBeenCalled()
210
+ })