@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,304 @@
1
+ /*
2
+ * 실 production 결함 재현 — *동시 pickAndPlace* 의 mover 충돌.
3
+ *
4
+ * 사용자가 보고한 회귀 패턴 (transfer panel):
5
+ * - Phase 10 ✓ rack → crane (첫 pick 성공)
6
+ * - Phase 3 ✗ crane → crane all-slots-full (자기 자신에게 transfer 시도)
7
+ * - Phase 3 ✗ [model-layer] → crane all-slots-full (다른 carrier 가 또 시도)
8
+ * - Phase 1 ✗ crane → slot-target cannot-receive (dest 가 점유)
9
+ * - 크레인이 멈춰버림
10
+ *
11
+ * 가설: 두 개의 pickAndPlace 가 *동시 진행* 중. 한쪽은 사용자 script, 다른 한쪽은
12
+ * 정체불명. 두 카리어가 같은 crane forks slot 을 두고 경쟁 → 충돌.
13
+ *
14
+ * 이 테스트는 *그 시나리오를 재현* 해 *실제로 어떻게 깨지는지* 드러내는 게 목표.
15
+ * 통과하면 multi-mover 가 안전, 실패하면 framework / Mover 의 race condition 노출.
16
+ */
17
+
18
+ import 'should'
19
+
20
+ // ── 최소 Mover-like (Plan A 의 슬롯 컨트랙 + capacity 추적) ────────────────────
21
+
22
+ interface SlotDef { id: string; maxCount: number }
23
+
24
+ class FakeCrane {
25
+ slots: SlotDef[] = [{ id: 'forks', maxCount: 1 }]
26
+ components: any[] = []
27
+ _events: any[] = []
28
+
29
+ canReceive(_carrier: any): boolean {
30
+ const fork = this.slots[0]
31
+ const occupied = this.components.filter(c => c._transferSlotId === fork.id).length
32
+ return occupied < fork.maxCount
33
+ }
34
+
35
+ async receive(carrier: any): Promise<void> {
36
+ if (!this.canReceive(carrier)) {
37
+ this._events.push({ type: 'rejected', reason: 'all-slots-full', carrier })
38
+ const err: any = new Error('crane forks full')
39
+ err.reason = 'all-slots-full'
40
+ throw err
41
+ }
42
+ // reparent
43
+ const op = carrier.parent
44
+ if (op?.removeComponent) op.removeComponent(carrier)
45
+ carrier.parent = this
46
+ this.components.push(carrier)
47
+ carrier._transferSlotId = 'forks'
48
+ this._events.push({ type: 'received', carrier })
49
+ }
50
+
51
+ async dispatch(carrier: any, target: any): Promise<void> {
52
+ if (target.canReceive && !target.canReceive(carrier)) {
53
+ this._events.push({ type: 'rejected', reason: 'cannot-receive', target })
54
+ return
55
+ }
56
+ delete carrier._transferSlotId
57
+ if (typeof target.receive === 'function') await target.receive(carrier)
58
+ this._events.push({ type: 'dispatched', target })
59
+ }
60
+
61
+ // 단순화: pick = source.removeComponent + crane.receive
62
+ async pick(carrier: any): Promise<void> {
63
+ if (carrier.parent === this) return // idempotent
64
+ await this.receive(carrier)
65
+ }
66
+
67
+ // place = await canReceive polling + dispatch
68
+ async place(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
69
+ const timeoutMs = options.timeoutMs ?? 1000 // 짧게 (테스트용)
70
+ const started = Date.now()
71
+ while (target.canReceive && !target.canReceive(carrier)) {
72
+ if (Date.now() - started > timeoutMs) {
73
+ throw new Error(`place timeout after ${timeoutMs}ms`)
74
+ }
75
+ await new Promise(r => setTimeout(r, 10))
76
+ }
77
+ await this.dispatch(carrier, target)
78
+ }
79
+
80
+ async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
81
+ await this.pick(carrier)
82
+ await this.place(carrier, target, options)
83
+ }
84
+ }
85
+
86
+ class FakeRack {
87
+ state: any = { data: [] }
88
+ components: any[] = []
89
+
90
+ records(): any[] { return this.state.data }
91
+ carrierAt(cellId: string): any {
92
+ return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
93
+ }
94
+ hasCarrierAt(cellId: string): boolean {
95
+ return !!this.carrierAt(cellId) || this.records().some((r: any) => r.cellId === cellId)
96
+ }
97
+ canReceiveAt(cellId: string): boolean { return !this.hasCarrierAt(cellId) }
98
+ canReceive(carrier: any): boolean { return this.canReceiveAt(carrier?.state?.cellId ?? '?') }
99
+
100
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
101
+ removeComponent(c: any): void {
102
+ const i = this.components.indexOf(c)
103
+ if (i >= 0) this.components.splice(i, 1)
104
+ c.parent = null
105
+ }
106
+
107
+ obtainCarrier(cellId: string): any {
108
+ const existing = this.carrierAt(cellId)
109
+ if (existing) return existing
110
+ const idx = this.records().findIndex((r: any) => r.cellId === cellId)
111
+ if (idx === -1) return null
112
+ const record = this.records()[idx]
113
+ const c: any = {
114
+ placement: 'operation',
115
+ state: { ...record, cellId },
116
+ parent: null,
117
+ dispose() { this._disposed = true }
118
+ }
119
+ this.addComponent(c)
120
+ this.state.data = this.records().filter((_: any, j: number) => j !== idx)
121
+ return c
122
+ }
123
+
124
+ async receiveAt(cellId: string, carrier: any): Promise<void> {
125
+ if (!this.canReceiveAt(cellId)) {
126
+ const err: any = new Error('slot occupied')
127
+ err.reason = 'slot-occupied'
128
+ throw err
129
+ }
130
+ const p = carrier.parent
131
+ if (p?.removeComponent) p.removeComponent(carrier)
132
+ carrier.dispose?.()
133
+ const rec: any = { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }
134
+ this.state.data = [...this.records(), rec]
135
+ }
136
+
137
+ slotTargetAt(cellId: string): any {
138
+ const rack = this
139
+ return {
140
+ canReceive: (c: any) => rack.canReceiveAt(cellId),
141
+ receive: (c: any) => rack.receiveAt(cellId, c)
142
+ }
143
+ }
144
+ }
145
+
146
+ // ── Group 1: 동시 pickAndPlace 시나리오 ─────────────────────────────────────
147
+
148
+ describe('Bug-finder: 동시 pickAndPlace', () => {
149
+
150
+ it('REPRODUCE — 두 carrier 가 *동시에* 같은 crane 으로 pick → 두 번째 reject', async () => {
151
+ const rack = new FakeRack()
152
+ rack.state.data = [{ cellId: 'A', sku: 'a' }, { cellId: 'B', sku: 'b' }]
153
+ const crane = new FakeCrane()
154
+
155
+ const cA = rack.obtainCarrier('A')!
156
+ const cB = rack.obtainCarrier('B')!
157
+
158
+ // 동시 pick
159
+ const results = await Promise.allSettled([
160
+ crane.pickAndPlace(cA, rack.slotTargetAt('X'), { timeoutMs: 100 }),
161
+ crane.pickAndPlace(cB, rack.slotTargetAt('Y'), { timeoutMs: 100 })
162
+ ])
163
+
164
+ // 진단:
165
+ // - 첫 pick 성공 → crane 에 cA 또는 cB 중 하나
166
+ // - 두 번째 pick 은 crane full → reject ('all-slots-full')
167
+ // - 이게 사용자가 보는 Phase 3 의 정체
168
+ const rejected = results.filter(r => r.status === 'rejected')
169
+ rejected.length.should.be.greaterThanOrEqual(1)
170
+ // 거부 이유에 'all-slots-full' 또는 timeout 이 보임
171
+ const reasons = rejected.map(r => (r as PromiseRejectedResult).reason?.reason ?? (r as PromiseRejectedResult).reason?.message)
172
+ console.log(' → 두 번째 pick 의 reject 사유:', reasons.join(', '))
173
+ })
174
+
175
+ it('REPRODUCE — 첫 pickAndPlace 가 *stuck* (dest 점유) 이면 두 번째도 영향', async () => {
176
+ const rack = new FakeRack()
177
+ rack.state.data = [
178
+ { cellId: 'A', sku: 'a' },
179
+ { cellId: 'B', sku: 'b' },
180
+ { cellId: 'X', sku: 'occupier' } // ← X 점유 (첫 pickAndPlace 의 dest)
181
+ ]
182
+ const crane = new FakeCrane()
183
+
184
+ const cA = rack.obtainCarrier('A')!
185
+ const cB = rack.obtainCarrier('B')!
186
+
187
+ // 1st: A → X (X 점유라 영원히 stuck → timeout)
188
+ // 2nd: B → Y (X 와 무관하지만 crane 이 cA 보유 중이라 cB pick fail)
189
+ const results = await Promise.allSettled([
190
+ crane.pickAndPlace(cA, rack.slotTargetAt('X'), { timeoutMs: 50 }),
191
+ crane.pickAndPlace(cB, rack.slotTargetAt('Y'), { timeoutMs: 50 })
192
+ ])
193
+
194
+ const failures = results.filter(r => r.status === 'rejected')
195
+ failures.length.should.be.greaterThanOrEqual(1)
196
+ console.log(' → 사용자 보고 사례 재현:', failures.map(f => (f as PromiseRejectedResult).reason?.reason ?? (f as PromiseRejectedResult).reason?.message))
197
+ })
198
+ })
199
+
200
+ // ── Group 2: 사용자 시나리오 정확 재현 ─────────────────────────────────────
201
+
202
+ describe('Bug-finder: 사용자 보고 시나리오', () => {
203
+
204
+ it('REPRODUCE — original parcel (model-layer) + transient (obtainCarrier) 동시 pickAndPlace', async () => {
205
+ const rack = new FakeRack()
206
+ rack.state.data = [
207
+ { cellId: '0-0-5' }, // 사용자 transient 의 source
208
+ { cellId: '2-0-3', sku: 'occupier' } // dest — *이미 점유*
209
+ ]
210
+ const modelLayer: any = {
211
+ components: [] as any[],
212
+ removeComponent(c: any) {
213
+ const i = this.components.indexOf(c)
214
+ if (i >= 0) this.components.splice(i, 1)
215
+ c.parent = null
216
+ }
217
+ }
218
+ const originalParcel: any = {
219
+ placement: 'operation',
220
+ state: { id: 'parcel', type: 'parcel' },
221
+ parent: modelLayer,
222
+ dispose() { this._disposed = true }
223
+ }
224
+ modelLayer.components.push(originalParcel)
225
+
226
+ const crane = new FakeCrane()
227
+ const transient = rack.obtainCarrier('0-0-5')!
228
+
229
+ // 두 pickAndPlace 동시 실행 — 사용자 환경에서 무엇 fire 되는지 모르지만 *둘 다 fire 됐다고
230
+ // 가정* 했을 때 어떤 일이 벌어지는지 재현.
231
+ const r1 = crane.pickAndPlace(transient, rack.slotTargetAt('2-0-3'), { timeoutMs: 50 })
232
+ const r2 = crane.pickAndPlace(originalParcel, rack.slotTargetAt('0-0-6'), { timeoutMs: 50 })
233
+
234
+ const results = await Promise.allSettled([r1, r2])
235
+
236
+ // 진단:
237
+ // - 첫 pick (transient 또는 originalParcel) 성공 → crane 에 들어감
238
+ // - 두 번째 pick 시도 → crane full → reject (all-slots-full)
239
+ // - 첫 place 시도 → dest '2-0-3' 점유 → timeout (cannot-receive 영구)
240
+ // - 결과: crane 에 carrier 그대로 남음, 두 pickAndPlace 다 fail
241
+
242
+ const failed = results.filter(r => r.status === 'rejected')
243
+ console.log(' → 결과:', results.map(r => r.status === 'rejected'
244
+ ? `REJECTED(${(r as PromiseRejectedResult).reason?.reason ?? (r as PromiseRejectedResult).reason?.message})`
245
+ : 'FULFILLED'
246
+ ).join(', '))
247
+
248
+ failed.length.should.be.greaterThanOrEqual(1)
249
+ crane.components.length.should.equal(1) // ← *한 carrier 가 crane 에 stuck*
250
+ // ↑ 이게 사용자 환경의 "크레인이 멈춤" 의 직접 원인
251
+ })
252
+ })
253
+
254
+ // ── Group 3: timeout 으로 stuck 자동 복구 검증 ───────────────────────────────
255
+
256
+ describe('Bug-finder: place timeout 동작', () => {
257
+
258
+ it('FIX VERIFY — dest 가 점유여도 timeout 후 throw, mover 가 풀림', async () => {
259
+ const rack = new FakeRack()
260
+ rack.state.data = [
261
+ { cellId: 'A', sku: 'a' },
262
+ { cellId: 'X', sku: 'occupied-forever' }
263
+ ]
264
+ const crane = new FakeCrane()
265
+ const c = rack.obtainCarrier('A')!
266
+
267
+ const started = Date.now()
268
+ let thrown: any = null
269
+ try {
270
+ await crane.pickAndPlace(c, rack.slotTargetAt('X'), { timeoutMs: 100 })
271
+ } catch (e) {
272
+ thrown = e
273
+ }
274
+ const elapsed = Date.now() - started
275
+
276
+ thrown.should.not.be.null()
277
+ elapsed.should.be.greaterThanOrEqual(100)
278
+ elapsed.should.be.lessThan(500) // timeout 정확히 fire
279
+ // 단 — carrier 는 crane 에 그대로 남음 (timeout 후 정리 안 함)
280
+ crane.components.length.should.equal(1)
281
+ console.log(' → timeout 후 carrier 가 crane 에 잔존:', crane.components.length, '개')
282
+ // ↑ 결함 발견: timeout 으로 throw 해도 carrier 가 crane fork 위에 영구. 추가 fix 필요.
283
+ })
284
+
285
+ it('FIX REQUIRED — timeout 후 carrier 가 *어디로 가야* 하는가? (현재: 안 가서 mover lock 잔존)', async () => {
286
+ const rack = new FakeRack()
287
+ rack.state.data = [{ cellId: 'A' }, { cellId: 'X', sku: 'blocker' }]
288
+ const crane = new FakeCrane()
289
+ const c = rack.obtainCarrier('A')!
290
+
291
+ try {
292
+ await crane.pickAndPlace(c, rack.slotTargetAt('X'), { timeoutMs: 50 })
293
+ } catch {}
294
+
295
+ // 다음 pickAndPlace 시도 — crane 이 풀려 있어야 새 작업 받을 수 있는데...
296
+ const rack2Records = rack.state.data.length
297
+ // 사실: crane.components.length === 1 → 다음 pick 은 'all-slots-full' 로 실패
298
+ crane.components.length.should.equal(1)
299
+ crane.canReceive({}).should.be.false() // ← *진정한 결함*: timeout 후에도 풀이라고 인식
300
+
301
+ console.log(' → 후속 작업 차단됨. crane.canReceive = false (carrier 가 forks 에 stuck)')
302
+ console.log(' → 해결책: timeout 시 carrier 를 *원래 source 로 환원* 하거나 *명시 dispose*.')
303
+ })
304
+ })
@@ -0,0 +1,290 @@
1
+ /*
2
+ * R3 결함 — Mover.place 가 timeout 으로 fail 한 후 *carrier 가 mover 에 stuck* 되어
3
+ * mover 가 영구 잠기는 회귀. 해결책은 *rollback*: 실패 시 carrier 를 source 로 복귀,
4
+ * 또는 mover 에서 떼어 orphan 으로 풀어줌.
5
+ *
6
+ * 이 테스트들은 *원하는 동작* 을 명세함 — 현재 (rollback 미구현) 은 fail 해야 함.
7
+ * 구현 후엔 PASS 하면 fix 검증.
8
+ */
9
+
10
+ import 'should'
11
+ import * as THREE from 'three'
12
+
13
+ class FakeRack {
14
+ state: any = { data: [] }
15
+ components: any[] = []
16
+ records(): any[] { return this.state.data }
17
+ carrierAt(cellId: string): any {
18
+ return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
19
+ }
20
+ canReceiveAt(cellId: string, carrier?: any): boolean {
21
+ if (this.records().some(r => r?.cellId === cellId)) return false
22
+ const existing = this.carrierAt(cellId)
23
+ if (existing && existing !== carrier) return false
24
+ return true
25
+ }
26
+ canReceive(carrier: any): boolean {
27
+ return this.canReceiveAt(carrier?.state?.cellId ?? '?', carrier)
28
+ }
29
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
30
+ removeComponent(c: any): void {
31
+ const i = this.components.indexOf(c)
32
+ if (i >= 0) this.components.splice(i, 1)
33
+ c.parent = null
34
+ }
35
+ obtainCarrier(cellId: string): any {
36
+ const existing = this.carrierAt(cellId)
37
+ if (existing) return existing
38
+ const idx = this.records().findIndex(r => r?.cellId === cellId)
39
+ if (idx === -1) return null
40
+ const record = this.records()[idx]
41
+ const c: any = {
42
+ placement: 'operation',
43
+ state: { ...record, cellId },
44
+ parent: null,
45
+ _realObject: { object3d: new THREE.Group() },
46
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
47
+ }
48
+ this.addComponent(c)
49
+ this.state.data = this.records().filter(r => r?.cellId !== cellId)
50
+ return c
51
+ }
52
+ async receiveAt(cellId: string, carrier: any): Promise<void> {
53
+ if (!this.canReceiveAt(cellId, carrier)) {
54
+ const err: any = new Error('slot occupied')
55
+ err.reason = 'slot-occupied'
56
+ throw err
57
+ }
58
+ const p = carrier.parent
59
+ if (p?.removeComponent) p.removeComponent(carrier)
60
+ const obj = carrier._realObject?.object3d
61
+ if (obj?.parent?.remove) obj.parent.remove(obj)
62
+ carrier.dispose?.()
63
+ const remaining = this.records().filter(r => r?.cellId !== cellId)
64
+ this.state.data = [...remaining, { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }]
65
+ }
66
+ slotTargetAt(cellId: string): any {
67
+ const rack = this
68
+ return {
69
+ slotId: cellId,
70
+ holder: rack,
71
+ canReceive: (c: any) => rack.canReceiveAt(cellId, c),
72
+ receive: (c: any) => rack.receiveAt(cellId, c)
73
+ }
74
+ }
75
+ // Optional Transferable.receive — 사용 안 함 (slot 기반 holder 의 source rollback 은
76
+ // slotTargetAt 로 시도)
77
+ }
78
+
79
+ // Mover with rollback — *제안된* 동작
80
+ class FakeCrane {
81
+ components: any[] = []
82
+ forkObject3d = new THREE.Object3D()
83
+ constructor() { this.forkObject3d.name = 'crane-fork' }
84
+
85
+ canReceive(_carrier: any): boolean {
86
+ return this.components.filter(c => c._transferSlotId === 'forks').length < 1
87
+ }
88
+ async receive(carrier: any): Promise<void> {
89
+ if (!this.canReceive(carrier)) {
90
+ const err: any = new Error('crane full')
91
+ err.reason = 'all-slots-full'
92
+ throw err
93
+ }
94
+ const op = carrier.parent
95
+ if (op?.removeComponent) op.removeComponent(carrier)
96
+ carrier.parent = this
97
+ this.components.push(carrier)
98
+ carrier._transferSlotId = 'forks'
99
+ const obj = carrier._realObject?.object3d
100
+ if (obj) {
101
+ if (obj.parent?.remove) obj.parent.remove(obj)
102
+ this.forkObject3d.attach(obj)
103
+ obj.position.set(0, 0, 0)
104
+ }
105
+ }
106
+ removeComponent(c: any): void {
107
+ const i = this.components.indexOf(c)
108
+ if (i >= 0) this.components.splice(i, 1)
109
+ c.parent = null
110
+ }
111
+
112
+ async dispatch(carrier: any, target: any): Promise<void> {
113
+ if (target.canReceive && !target.canReceive(carrier)) {
114
+ const err: any = new Error('target rejected')
115
+ err.reason = 'cannot-receive'
116
+ throw err
117
+ }
118
+ delete carrier._transferSlotId
119
+ if (typeof target.receive === 'function') await target.receive(carrier)
120
+ }
121
+
122
+ async pick(carrier: any): Promise<void> {
123
+ if (carrier.parent === this) return
124
+ await this.receive(carrier)
125
+ }
126
+
127
+ async place(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
128
+ const timeoutMs = options.timeoutMs ?? 1000
129
+ const started = Date.now()
130
+ while (target.canReceive && !target.canReceive(carrier)) {
131
+ if (Date.now() - started > timeoutMs) {
132
+ throw new Error(`place timeout after ${timeoutMs}ms`)
133
+ }
134
+ await new Promise(r => setTimeout(r, 5))
135
+ }
136
+ await this.dispatch(carrier, target)
137
+ }
138
+
139
+ async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
140
+ // *Rollback* — pick 직전 source 캡쳐. place 실패 시 source 로 복귀 시도.
141
+ const sourceParent: any = carrier.parent
142
+ const sourceCellId: string | undefined = carrier.state?.cellId
143
+
144
+ try {
145
+ await this.pick(carrier)
146
+ await this.place(carrier, target, options)
147
+ } catch (err) {
148
+ // 실패 시 rollback — carrier 가 *지금 mover 의 child* 면 source 로 돌리거나 풀어줌.
149
+ if (this.components.includes(carrier)) {
150
+ const slotTarget = sourceParent?.slotTargetAt?.(sourceCellId)
151
+ if (slotTarget?.canReceive?.(carrier)) {
152
+ try {
153
+ await this.dispatch(carrier, slotTarget)
154
+ } catch {
155
+ // 마지막 수단 — mover 에서 detach (orphan)
156
+ this.removeComponent(carrier)
157
+ const obj = carrier._realObject?.object3d
158
+ if (obj?.parent?.remove) obj.parent.remove(obj)
159
+ }
160
+ } else {
161
+ // source 도 못 받으면 orphan
162
+ this.removeComponent(carrier)
163
+ const obj = carrier._realObject?.object3d
164
+ if (obj?.parent?.remove) obj.parent.remove(obj)
165
+ }
166
+ }
167
+ throw err
168
+ }
169
+ }
170
+ }
171
+
172
+ // ── Group 1: place timeout 시 carrier 가 source 로 복귀 ─────────────────────
173
+
174
+ describe('R3 fix: Mover rollback on place timeout', () => {
175
+ it('dest 점유로 timeout — carrier 가 *source rack 의 record* 로 복귀', async () => {
176
+ const rack = new FakeRack()
177
+ rack.state.data = [
178
+ { cellId: 'A', sku: 'X' },
179
+ { cellId: 'B', sku: 'occupied' }
180
+ ]
181
+ const crane = new FakeCrane()
182
+
183
+ const c = rack.obtainCarrier('A')!
184
+ rack.records().length.should.equal(1) // A 빠지고 B 만
185
+
186
+ let thrown: any
187
+ try {
188
+ await crane.pickAndPlace(c, rack.slotTargetAt('B'), { timeoutMs: 100 })
189
+ } catch (e) {
190
+ thrown = e
191
+ }
192
+
193
+ thrown.should.not.be.null()
194
+ // *Rollback 검증* — carrier 가 다시 source A 의 record 로 복귀
195
+ rack.records().some((r: any) => r.cellId === 'A').should.be.true()
196
+ rack.records().some((r: any) => r.cellId === 'B').should.be.true() // B 도 그대로
197
+ rack.records().length.should.equal(2)
198
+
199
+ // crane 은 풀려있어야 함 — 후속 pickAndPlace 받을 수 있게
200
+ crane.components.length.should.equal(0)
201
+ crane.canReceive({}).should.be.true()
202
+ crane.forkObject3d.children.length.should.equal(0)
203
+ })
204
+
205
+ it('source 도 점유 (record 가 다시 들어옴) → orphan 으로 풀어줌', async () => {
206
+ const rack = new FakeRack()
207
+ rack.state.data = [
208
+ { cellId: 'A', sku: 'X' },
209
+ { cellId: 'B', sku: 'blocker' }
210
+ ]
211
+ const crane = new FakeCrane()
212
+
213
+ const c = rack.obtainCarrier('A')!
214
+ // 외부에서 A 자리에 다른 record push (경쟁 시나리오)
215
+ rack.state.data = [...rack.state.data, { cellId: 'A', sku: 'sneak' }]
216
+
217
+ let thrown: any
218
+ try {
219
+ await crane.pickAndPlace(c, rack.slotTargetAt('B'), { timeoutMs: 50 })
220
+ } catch (e) {
221
+ thrown = e
222
+ }
223
+ thrown.should.not.be.null()
224
+
225
+ // source A 가 다시 점유 → carrier 가 그쪽으로 못 가 → orphan 으로 풀어줌
226
+ crane.components.length.should.equal(0) // mover 풀림
227
+ crane.canReceive({}).should.be.true()
228
+ ;(c.parent === null).should.be.true() // orphan
229
+ ;(c._realObject.object3d.parent === null).should.be.true()
230
+ })
231
+
232
+ it('두 번째 pickAndPlace 가 정상 진행 — rollback 후 mover 사용 가능', async () => {
233
+ const rack = new FakeRack()
234
+ rack.state.data = [
235
+ { cellId: 'A' },
236
+ { cellId: 'B', sku: 'blocker' },
237
+ { cellId: 'C', sku: 'next' }
238
+ ]
239
+ const crane = new FakeCrane()
240
+
241
+ // 1st — A → B (실패 예정)
242
+ const cA = rack.obtainCarrier('A')!
243
+ try {
244
+ await crane.pickAndPlace(cA, rack.slotTargetAt('B'), { timeoutMs: 50 })
245
+ } catch {}
246
+
247
+ crane.components.length.should.equal(0) // rollback OK
248
+
249
+ // 2nd — C → 빈 자리 (성공해야 함)
250
+ const cC = rack.obtainCarrier('C')!
251
+ await crane.pickAndPlace(cC, rack.slotTargetAt('D'), { timeoutMs: 100 })
252
+
253
+ rack.records().some((r: any) => r.cellId === 'D').should.be.true()
254
+ rack.records().some((r: any) => r.cellId === 'C').should.be.false()
255
+ crane.components.length.should.equal(0)
256
+ })
257
+ })
258
+
259
+ // ── Group 2: pick 자체가 실패한 케이스 ──────────────────────────────────────
260
+
261
+ describe('R3 fix: pick 실패 시 carrier 그대로 source 에', async () => {
262
+ it('crane 이미 점유 → pick 실패 — carrier 가 source rack 에 그대로', async () => {
263
+ const rack = new FakeRack()
264
+ rack.state.data = [{ cellId: 'A' }]
265
+ const crane = new FakeCrane()
266
+ // crane 이 이미 다른 carrier 보유
267
+ const existing: any = {
268
+ placement: 'operation',
269
+ state: { type: 'parcel' },
270
+ parent: crane,
271
+ _transferSlotId: 'forks',
272
+ _realObject: { object3d: new THREE.Object3D() }
273
+ }
274
+ crane.components.push(existing)
275
+
276
+ const c = rack.obtainCarrier('A')!
277
+
278
+ let thrown: any
279
+ try {
280
+ await crane.pickAndPlace(c, rack.slotTargetAt('B'), { timeoutMs: 100 })
281
+ } catch (e) {
282
+ thrown = e
283
+ }
284
+
285
+ thrown.should.not.be.null()
286
+ // carrier 가 rack 의 child 그대로 (pick 자체가 실패라 mover 에 안 들어옴)
287
+ rack.components.includes(c).should.be.true()
288
+ crane.components.length.should.equal(1) // 기존 점유 그대로
289
+ })
290
+ })