@operato/scene-storage 10.0.0-beta.41 → 10.0.0-beta.43

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 (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/MIGRATION-plan-a-slot-api.md +266 -0
  3. package/PLAN-A-rack-as-slot-holder.md +164 -0
  4. package/dist/crane.js +1 -1
  5. package/dist/crane.js.map +1 -1
  6. package/dist/index.d.ts +3 -4
  7. package/dist/index.js +1 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/parcel-3d.js +42 -9
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.d.ts +18 -7
  12. package/dist/rack-grid-3d.js +372 -69
  13. package/dist/rack-grid-3d.js.map +1 -1
  14. package/dist/rack-grid-cell.d.ts +21 -72
  15. package/dist/rack-grid-cell.js +147 -243
  16. package/dist/rack-grid-cell.js.map +1 -1
  17. package/dist/rack-grid.d.ts +277 -56
  18. package/dist/rack-grid.js +1230 -695
  19. package/dist/rack-grid.js.map +1 -1
  20. package/dist/rack-materials.d.ts +9 -0
  21. package/dist/rack-materials.js +55 -0
  22. package/dist/rack-materials.js.map +1 -0
  23. package/dist/storage-rack-3d.d.ts +15 -0
  24. package/dist/storage-rack-3d.js +131 -30
  25. package/dist/storage-rack-3d.js.map +1 -1
  26. package/dist/storage-rack.d.ts +242 -45
  27. package/dist/storage-rack.js +684 -106
  28. package/dist/storage-rack.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/crane.ts +1 -1
  31. package/src/index.ts +3 -4
  32. package/src/parcel-3d.ts +41 -9
  33. package/src/rack-grid-3d.ts +383 -80
  34. package/src/rack-grid-cell.ts +161 -305
  35. package/src/rack-grid.ts +1263 -762
  36. package/src/rack-materials.ts +61 -0
  37. package/src/storage-rack-3d.ts +144 -30
  38. package/src/storage-rack.ts +763 -111
  39. package/test/test-carrier-lifecycle.ts +361 -0
  40. package/test/test-coord-alignment.ts +201 -0
  41. package/test/test-external-to-rack.ts +461 -0
  42. package/test/test-mover-concurrent-bug.ts +304 -0
  43. package/test/test-mover-rollback.ts +290 -0
  44. package/test/test-r19-place-absorb.ts +174 -0
  45. package/test/test-rack-3d-attach-real.ts +301 -0
  46. package/test/test-rack-concurrent.ts +254 -0
  47. package/test/test-rack-edge-cases.ts +323 -0
  48. package/test/test-rack-grid-cell.ts +318 -0
  49. package/test/test-rack-grid-location.ts +657 -0
  50. package/test/test-real-3d-positioning.ts +158 -0
  51. package/test/test-slot-center-convention.ts +116 -0
  52. package/test/test-slot-target.ts +189 -0
  53. package/test/test-storage-rack-batched.ts +606 -0
  54. package/test/test-storage-rack-click.ts +329 -0
  55. package/test/test-storage-rack-slot-api.ts +357 -0
  56. package/test/test-toscene-convention.ts +162 -0
  57. package/test/test-user-scenario-sequential.ts +334 -0
  58. package/translations/en.json +2 -0
  59. package/translations/ja.json +2 -0
  60. package/translations/ko.json +2 -0
  61. package/translations/ms.json +2 -0
  62. package/translations/zh.json +2 -0
  63. package/tsconfig.tsbuildinfo +1 -1
  64. package/dist/rack-column.d.ts +0 -35
  65. package/dist/rack-column.js +0 -258
  66. package/dist/rack-column.js.map +0 -1
  67. package/dist/rack-grid-helpers.d.ts +0 -28
  68. package/dist/rack-grid-helpers.js +0 -71
  69. package/dist/rack-grid-helpers.js.map +0 -1
  70. package/dist/rack-grid-location.d.ts +0 -37
  71. package/dist/rack-grid-location.js +0 -227
  72. package/dist/rack-grid-location.js.map +0 -1
  73. package/dist/storage-cell-3d.d.ts +0 -25
  74. package/dist/storage-cell-3d.js +0 -88
  75. package/dist/storage-cell-3d.js.map +0 -1
  76. package/dist/storage-cell.d.ts +0 -73
  77. package/dist/storage-cell.js +0 -215
  78. package/dist/storage-cell.js.map +0 -1
  79. package/src/rack-column.ts +0 -340
  80. package/src/rack-grid-helpers.ts +0 -77
  81. package/src/rack-grid-location.ts +0 -286
  82. package/src/storage-cell-3d.ts +0 -101
  83. package/src/storage-cell.ts +0 -267
  84. package/test/test-cell-position.ts +0 -105
  85. package/test/test-rack-grid.ts +0 -77
@@ -0,0 +1,174 @@
1
+ /*
2
+ * R19 — Mover.place 의 *Plan A absorb 미인식* 회귀.
3
+ *
4
+ * 결함 시퀀스:
5
+ * 1. crane.pickAndPlace(externalParcel, rack.slotTargetAt(cellId))
6
+ * 2. Mover.pick — 외부 parcel 을 crane forks 로
7
+ * 3. Mover.place:
8
+ * a. engage('place') 의 mid-Transfer → SlotTarget.receive → rack.receiveAt
9
+ * → parcel.dispose() + state.data.push(record). 여기서 carrier.parent = null.
10
+ * b. 후속 검사: `if (carrier.parent === holder) return`
11
+ * — holder 는 SlotTarget (wrapper). carrier.parent 는 null. 검사 false → 통과.
12
+ * c. canReceive polling 시작: rack.canReceiveAt(cellId) 가 *방금 push 된* record
13
+ * 때문에 false 영원 반환.
14
+ * d. 30s timeout → throw "Mover.place: holder did not become available within 30000ms"
15
+ * 4. 사용자 script 의 unhandled promise rejection.
16
+ *
17
+ * Fix: `Mover.place` 가 *carrier._disposed === true* 검사 추가. absorb 완료 시 polling
18
+ * 진입 전에 return.
19
+ */
20
+
21
+ import 'should'
22
+
23
+ describe('R19: Mover.place — Plan A absorb 후 30s timeout 결함', () => {
24
+ it('REPRODUCE — engage 가 carrier 를 *absorb* (dispose) 한 후 polling 에서 멈춤', async () => {
25
+ // 결함 시뮬: engage 가 carrier 를 dispose (state.data 에 record push). 그러면
26
+ // canReceive 가 false 영원 → place 가 polling 에서 stuck.
27
+
28
+ const carrier: any = {
29
+ state: { type: 'parcel' },
30
+ parent: 'mover',
31
+ _disposed: false
32
+ }
33
+ let stateData: any[] = []
34
+
35
+ // SlotTarget mock
36
+ const slotTarget: any = {
37
+ slotId: 'A',
38
+ canReceive: (c: any) => !stateData.some(r => r.cellId === 'A'),
39
+ async receive(c: any) {
40
+ // absorb: dispose + push record
41
+ c._disposed = true
42
+ c.parent = null
43
+ stateData.push({ cellId: 'A' })
44
+ }
45
+ }
46
+
47
+ // engage's mid-Transfer 시뮬 — sync executeSync 인 척
48
+ await slotTarget.receive(carrier)
49
+ carrier._disposed.should.be.true()
50
+ stateData.length.should.equal(1)
51
+
52
+ // 결함 검증: 후속 *parent === holder* 검사 가 false → polling 진입
53
+ ;(carrier.parent !== slotTarget).should.be.true()
54
+
55
+ // polling: canReceive 영원 false
56
+ let pollCount = 0
57
+ const start = Date.now()
58
+ while (!slotTarget.canReceive(carrier)) {
59
+ pollCount++
60
+ if (pollCount > 5) break // 시뮬용 짧게
61
+ if (Date.now() - start > 100) break
62
+ await new Promise(r => setTimeout(r, 10))
63
+ }
64
+ pollCount.should.be.greaterThan(1) // ← 실 환경에선 30s 동안 수백 회
65
+ console.log(` → polling 횟수=${pollCount}, canReceive 영원 false (R19 결함 확인)`)
66
+ })
67
+
68
+ it('FIX VERIFY — `_disposed` 검사 가 polling 진입 차단', async () => {
69
+ const carrier: any = {
70
+ state: { type: 'parcel' },
71
+ parent: 'mover',
72
+ _disposed: false
73
+ }
74
+ let stateData: any[] = []
75
+ const slotTarget: any = {
76
+ canReceive: () => !stateData.some(r => r.cellId === 'A'),
77
+ async receive(c: any) {
78
+ c._disposed = true
79
+ c.parent = null
80
+ stateData.push({ cellId: 'A' })
81
+ }
82
+ }
83
+
84
+ // Simulated Mover.place after-engage
85
+ await slotTarget.receive(carrier) // engage's mid-Transfer absorb
86
+
87
+ // R19 fix — _disposed 검사:
88
+ function placeAfterEngage(c: any, holder: any): boolean {
89
+ if (c.parent === holder) return true // skip polling
90
+ if (c._disposed) return true // R19 fix — absorb 완료
91
+ return false // proceed to polling
92
+ }
93
+
94
+ const shouldSkipPolling = placeAfterEngage(carrier, slotTarget)
95
+ shouldSkipPolling.should.be.true() // FIX: polling 진입 X
96
+ })
97
+
98
+ it('FIX VERIFY — disposed 검사 *없으면* polling 영원 (timeout)', () => {
99
+ const carrier: any = {
100
+ state: { type: 'parcel' },
101
+ parent: null, // absorb 후 null
102
+ _disposed: true // absorb 후 disposed
103
+ }
104
+ const slotTarget: any = { /* 점유 — canReceive false */ }
105
+
106
+ function placeAfterEngage_OLD(c: any, holder: any): boolean {
107
+ if (c.parent === holder) return true
108
+ // R19 fix 없음 — polling 진입
109
+ return false
110
+ }
111
+
112
+ const willPoll = !placeAfterEngage_OLD(carrier, slotTarget)
113
+ willPoll.should.be.true() // ← 결함: polling 진입 → 30s timeout 운명
114
+ })
115
+ })
116
+
117
+ describe('R19: 사용자 시나리오 — 1차 성공, 2차 명확 에러', () => {
118
+ it('1차 호출 — 30s timeout 없이 즉시 완료', async () => {
119
+ const carrier: any = {
120
+ state: { type: 'parcel', id: 'parcel' },
121
+ parent: 'mover',
122
+ _disposed: false
123
+ }
124
+ let stateData: any[] = []
125
+ const slot: any = {
126
+ canReceive: () => !stateData.some(r => r.cellId === 'A'),
127
+ async receive(c: any) {
128
+ c._disposed = true
129
+ c.parent = null
130
+ stateData.push({ cellId: 'A' })
131
+ }
132
+ }
133
+
134
+ // Mover.place (R19 fix 포함)
135
+ async function moverPlace(c: any, holder: any): Promise<void> {
136
+ await holder.receive(c)
137
+ if (c.parent === holder) return
138
+ if (c._disposed) return // R19 fix
139
+ // polling (도달하지 않아야 함)
140
+ throw new Error('polling reached — R19 fix not applied')
141
+ }
142
+
143
+ const started = Date.now()
144
+ await moverPlace(carrier, slot)
145
+ const elapsed = Date.now() - started
146
+
147
+ elapsed.should.be.lessThan(100) // 즉시 완료 (no 30s)
148
+ stateData.length.should.equal(1)
149
+ carrier._disposed.should.be.true()
150
+ })
151
+
152
+ it('2차 호출 — disposed carrier 로 R18 가 *즉시 throw*', async () => {
153
+ const disposedCarrier: any = {
154
+ state: { type: 'parcel', id: 'parcel' },
155
+ parent: null,
156
+ _disposed: true // 이미 1차에서 dispose 됨
157
+ }
158
+
159
+ // Mover.pickAndPlace 의 R18 guard
160
+ function pickAndPlaceR18Check(c: any): void {
161
+ if (c._disposed) {
162
+ throw new Error('Mover.pickAndPlace: carrier is already disposed.')
163
+ }
164
+ }
165
+
166
+ let thrown: any
167
+ try {
168
+ pickAndPlaceR18Check(disposedCarrier)
169
+ } catch (e) { thrown = e }
170
+
171
+ thrown.should.not.be.null()
172
+ thrown.message.should.match(/disposed/)
173
+ })
174
+ })
@@ -0,0 +1,301 @@
1
+ /*
2
+ * Plan A 의 *진짜 Three.js* attach/detach 검증.
3
+ *
4
+ * 가짜 Object3D 대신 *실제 THREE.Object3D + Mesh + Group* 으로 ghost 와 텔레포트 의
5
+ * 원인 (Three.js scene graph 의 parent 추적 누락) 을 직접 확인.
6
+ *
7
+ * 시나리오:
8
+ * 1. carrier 의 Three.js parent transition: 없음 → slot anchor → crane fork → 없음
9
+ * 2. receiveAt 이 *실제로* object3d 를 scene graph 에서 분리하는지
10
+ * 3. 새 carrier 가 같은 cellId 로 obtain 됐을 때 *이전 instance 의 mesh 가 잔존하지
11
+ * 않는지* (ghost regression)
12
+ */
13
+
14
+ import 'should'
15
+ import * as THREE from 'three'
16
+
17
+ // ── 실제 things-scene 의 Carriable / RealObject 정신을 가능한 한 가깝게 모사 ─────
18
+
19
+ class RealCarrier {
20
+ state: any
21
+ parent: any = null
22
+ _realObject: { object3d: THREE.Object3D }
23
+ _disposed = false
24
+
25
+ constructor(state: any) {
26
+ this.state = state
27
+ // Parcel3D 처럼 *child mesh 가 있는 Group* 으로 — real 환경 모방
28
+ const group = new THREE.Group()
29
+ group.name = `parcel-${state.id ?? state.cellId ?? '?'}-group`
30
+ const bodyMesh = new THREE.Mesh(
31
+ new THREE.BoxGeometry(50, 50, 50),
32
+ new THREE.MeshBasicMaterial({ color: 0xc8a878 })
33
+ )
34
+ bodyMesh.name = 'parcel-body'
35
+ group.add(bodyMesh)
36
+ this._realObject = { object3d: group }
37
+ }
38
+
39
+ applyHolderAttachPoint(): void {
40
+ const p: any = this.parent
41
+ if (!p?.attachPointFor) return
42
+ const point = p.attachPointFor(this)
43
+ if (!point?.attach) return
44
+ point.attach.attach(this._realObject.object3d)
45
+ if (point.localPosition) {
46
+ this._realObject.object3d.position.set(
47
+ point.localPosition.x, point.localPosition.y, point.localPosition.z
48
+ )
49
+ }
50
+ }
51
+
52
+ dispose(): void {
53
+ this._disposed = true
54
+ // RealObject.dispose 의 clear() 모방 — children 정리, object3d 자체는 안 떼는 결함
55
+ for (const child of [...this._realObject.object3d.children]) {
56
+ const mesh = child as THREE.Mesh
57
+ mesh.geometry?.dispose?.()
58
+ const mats = Array.isArray(mesh.material) ? mesh.material : (mesh.material ? [mesh.material] : [])
59
+ for (const m of mats) m?.dispose?.()
60
+ }
61
+ this._realObject.object3d.clear()
62
+ }
63
+ }
64
+
65
+ class RealRack {
66
+ state: any = { data: [] }
67
+ components: any[] = []
68
+ rootObject3d = new THREE.Group()
69
+ _slotAnchors = new Map<string, THREE.Object3D>()
70
+
71
+ constructor() { this.rootObject3d.name = 'rack-root' }
72
+
73
+ ensureSlotAnchor(cellId: string): THREE.Object3D {
74
+ let a = this._slotAnchors.get(cellId)
75
+ if (!a) {
76
+ a = new THREE.Object3D()
77
+ a.name = `slot:${cellId}`
78
+ this.rootObject3d.add(a)
79
+ this._slotAnchors.set(cellId, a)
80
+ }
81
+ return a
82
+ }
83
+
84
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
85
+ removeComponent(c: any): void {
86
+ const i = this.components.indexOf(c)
87
+ if (i >= 0) this.components.splice(i, 1)
88
+ c.parent = null
89
+ }
90
+
91
+ attachPointFor(carrier: any) {
92
+ const cellId = carrier.state.cellId
93
+ return { attach: this.ensureSlotAnchor(cellId), localPosition: { x: 0, y: 0, z: 0 } }
94
+ }
95
+
96
+ obtainCarrier(cellId: string): RealCarrier | null {
97
+ const i = this.state.data.findIndex((r: any) => r.cellId === cellId)
98
+ if (i === -1) return null
99
+ const record = this.state.data[i]
100
+ const c = new RealCarrier({ ...record, cellId })
101
+ this.addComponent(c)
102
+ c.applyHolderAttachPoint()
103
+ this.state.data = this.state.data.filter((_: any, j: number) => j !== i)
104
+ return c
105
+ }
106
+
107
+ async receiveAt(cellId: string, carrier: RealCarrier): Promise<void> {
108
+ // Plan A flow:
109
+ // 1. removeComponent
110
+ const p = carrier.parent
111
+ if (p?.removeComponent) p.removeComponent(carrier)
112
+
113
+ // 2. 명시적 Three.js detach (RealObject.dispose 만으로 안 떼는 part 보완)
114
+ const obj = carrier._realObject?.object3d
115
+ if (obj?.parent?.remove) obj.parent.remove(obj)
116
+
117
+ // 3. dispose
118
+ carrier.dispose()
119
+
120
+ // 4. record push
121
+ const rec: any = { cellId, type: carrier.state.type ?? 'parcel' }
122
+ for (const k of Object.keys(carrier.state)) {
123
+ if (['id', 'left', 'top', 'zPos', 'cellId'].includes(k)) continue
124
+ rec[k] = carrier.state[k]
125
+ }
126
+ this.state.data.push(rec)
127
+ }
128
+ }
129
+
130
+ class RealCrane {
131
+ components: any[] = []
132
+ forkObject3d = new THREE.Object3D()
133
+ constructor() { this.forkObject3d.name = 'crane-fork' }
134
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
135
+ removeComponent(c: any): void {
136
+ const i = this.components.indexOf(c)
137
+ if (i >= 0) this.components.splice(i, 1)
138
+ c.parent = null
139
+ }
140
+ simulatePick(c: any): void {
141
+ const op = c.parent
142
+ if (op?.removeComponent) op.removeComponent(c)
143
+ this.addComponent(c)
144
+ this.forkObject3d.attach(c._realObject.object3d)
145
+ c._realObject.object3d.position.set(0, 0, 0)
146
+ }
147
+ }
148
+
149
+ // ── Group 1: Three.js scene graph 의 parent transition ────────────────────────
150
+
151
+ describe('Real Three.js: carrier scene-graph parent transitions', () => {
152
+ it('갓 생성 — object3d.parent === null', () => {
153
+ const c = new RealCarrier({ cellId: 'X' })
154
+ ;(c._realObject.object3d.parent === null).should.be.true()
155
+ })
156
+
157
+ it('obtainCarrier 후 — object3d.parent.name === slot:A-0-1', () => {
158
+ const rack = new RealRack()
159
+ rack.state.data = [{ cellId: 'A-0-1' }]
160
+ const c = rack.obtainCarrier('A-0-1')!
161
+ c._realObject.object3d.parent!.name.should.equal('slot:A-0-1')
162
+ })
163
+
164
+ it('crane.simulatePick 후 — object3d.parent.name === crane-fork', () => {
165
+ const rack = new RealRack()
166
+ rack.state.data = [{ cellId: 'A' }]
167
+ const crane = new RealCrane()
168
+ const c = rack.obtainCarrier('A')!
169
+ crane.simulatePick(c)
170
+ c._realObject.object3d.parent!.name.should.equal('crane-fork')
171
+ })
172
+
173
+ it('receiveAt 후 — object3d.parent === null (완전 detach)', async () => {
174
+ const rack = new RealRack()
175
+ rack.state.data = [{ cellId: 'A' }]
176
+ const crane = new RealCrane()
177
+ const c = rack.obtainCarrier('A')!
178
+ crane.simulatePick(c)
179
+ await rack.receiveAt('B', c)
180
+ ;(c._realObject.object3d.parent === null).should.be.true()
181
+ })
182
+ })
183
+
184
+ // ── Group 2: ghost 검증 — receiveAt 후 visible mesh 잔존 X ───────────────────
185
+
186
+ describe('Real Three.js: ghost 없음', () => {
187
+ it('receiveAt 후 — crane fork 의 자식에 아무도 없음 (mover idle)', async () => {
188
+ const rack = new RealRack()
189
+ rack.state.data = [{ cellId: 'A' }]
190
+ const crane = new RealCrane()
191
+ const c = rack.obtainCarrier('A')!
192
+ crane.simulatePick(c)
193
+
194
+ crane.forkObject3d.children.length.should.equal(1) // 들고 있는 동안
195
+
196
+ await rack.receiveAt('B', c)
197
+ crane.forkObject3d.children.length.should.equal(0) // 내려놓은 후 비어있음
198
+ })
199
+
200
+ it('receiveAt 후 — slot anchor (source) 의 자식 비어있음', async () => {
201
+ const rack = new RealRack()
202
+ rack.state.data = [{ cellId: 'A' }]
203
+ const crane = new RealCrane()
204
+ const sourceAnchor = rack.ensureSlotAnchor('A')
205
+
206
+ const c = rack.obtainCarrier('A')!
207
+ sourceAnchor.children.length.should.equal(1) // obtain 직후 attached
208
+
209
+ crane.simulatePick(c)
210
+ sourceAnchor.children.length.should.equal(0) // pick 후 source 비어있음
211
+ })
212
+
213
+ it('receiveAt 후 — dest slot anchor 에도 아무것도 없음 (record 만 있을 뿐)', async () => {
214
+ const rack = new RealRack()
215
+ rack.state.data = [{ cellId: 'A' }]
216
+ const crane = new RealCrane()
217
+ const c = rack.obtainCarrier('A')!
218
+ crane.simulatePick(c)
219
+ await rack.receiveAt('B', c)
220
+
221
+ // B 의 anchor 는 미리 생성되어 있을 수도 / 없을 수도. 어쨌든 carrier 의 object3d 는 거기 없음
222
+ const bAnchor = rack._slotAnchors.get('B')
223
+ if (bAnchor) bAnchor.children.length.should.equal(0)
224
+ // record 는 state.data 에 있어야 함
225
+ rack.state.data.length.should.equal(1)
226
+ rack.state.data[0].cellId.should.equal('B')
227
+ })
228
+
229
+ it('carrier 의 *mesh children* 도 정리됨 (geometry/material disposed)', async () => {
230
+ const rack = new RealRack()
231
+ rack.state.data = [{ cellId: 'A' }]
232
+ const crane = new RealCrane()
233
+ const c = rack.obtainCarrier('A')!
234
+ crane.simulatePick(c)
235
+
236
+ const bodyMesh = c._realObject.object3d.children[0] as THREE.Mesh
237
+ // dispose 전엔 살아있음
238
+ bodyMesh.should.not.be.undefined()
239
+
240
+ await rack.receiveAt('B', c)
241
+
242
+ // carrier object3d 자체는 empty Group
243
+ c._realObject.object3d.children.length.should.equal(0)
244
+ })
245
+ })
246
+
247
+ // ── Group 3: 반복 pickAndPlace — ghost 누적 없음 ─────────────────────────────
248
+
249
+ describe('Real Three.js: 반복 pickAndPlace 회귀 차단', () => {
250
+ it('A→B 10번 왕복 — crane fork 와 두 slot anchor 가 매 사이클마다 깨끗', async () => {
251
+ const rack = new RealRack()
252
+ rack.state.data = [{ cellId: 'A', sku: 'X' }]
253
+ const crane = new RealCrane()
254
+ const anchorA = rack.ensureSlotAnchor('A')
255
+ const anchorB = rack.ensureSlotAnchor('B')
256
+
257
+ for (let i = 0; i < 10; i++) {
258
+ // A → B
259
+ const cAB = rack.obtainCarrier('A')!
260
+ cAB.should.not.be.null()
261
+ crane.simulatePick(cAB)
262
+ await rack.receiveAt('B', cAB)
263
+ crane.forkObject3d.children.length.should.equal(0)
264
+ anchorA.children.length.should.equal(0)
265
+ anchorB.children.length.should.equal(0)
266
+
267
+ // B → A
268
+ const cBA = rack.obtainCarrier('B')!
269
+ cBA.should.not.be.null()
270
+ crane.simulatePick(cBA)
271
+ await rack.receiveAt('A', cBA)
272
+ crane.forkObject3d.children.length.should.equal(0)
273
+ anchorA.children.length.should.equal(0)
274
+ anchorB.children.length.should.equal(0)
275
+ }
276
+
277
+ // 끝에 정확히 1 record at A
278
+ rack.state.data.length.should.equal(1)
279
+ rack.state.data[0].cellId.should.equal('A')
280
+ rack.state.data[0].sku.should.equal('X')
281
+ })
282
+
283
+ it('대량 데이터 (100 records) — 모두 다른 dest 로 이동, ghost X', async () => {
284
+ const rack = new RealRack()
285
+ rack.state.data = Array.from({ length: 100 }, (_, i) => ({ cellId: `src-${i}`, sku: `S${i}` }))
286
+ const crane = new RealCrane()
287
+
288
+ for (let i = 0; i < 100; i++) {
289
+ const c = rack.obtainCarrier(`src-${i}`)!
290
+ crane.simulatePick(c)
291
+ await rack.receiveAt(`dst-${i}`, c)
292
+ }
293
+
294
+ rack.state.data.length.should.equal(100)
295
+ rack.state.data.every((r: any) => r.cellId.startsWith('dst-')).should.be.true()
296
+ crane.components.length.should.equal(0)
297
+ crane.forkObject3d.children.length.should.equal(0)
298
+ // 모든 src anchor 와 dst anchor 의 자식 0
299
+ for (const [, a] of rack._slotAnchors) a.children.length.should.equal(0)
300
+ })
301
+ })