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

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 (82) hide show
  1. package/CHANGELOG.md +12 -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/rack-grid-3d.d.ts +18 -7
  10. package/dist/rack-grid-3d.js +372 -69
  11. package/dist/rack-grid-3d.js.map +1 -1
  12. package/dist/rack-grid-cell.d.ts +21 -72
  13. package/dist/rack-grid-cell.js +147 -243
  14. package/dist/rack-grid-cell.js.map +1 -1
  15. package/dist/rack-grid.d.ts +277 -56
  16. package/dist/rack-grid.js +1230 -695
  17. package/dist/rack-grid.js.map +1 -1
  18. package/dist/rack-materials.d.ts +9 -0
  19. package/dist/rack-materials.js +55 -0
  20. package/dist/rack-materials.js.map +1 -0
  21. package/dist/storage-rack-3d.d.ts +15 -0
  22. package/dist/storage-rack-3d.js +131 -30
  23. package/dist/storage-rack-3d.js.map +1 -1
  24. package/dist/storage-rack.d.ts +242 -45
  25. package/dist/storage-rack.js +684 -106
  26. package/dist/storage-rack.js.map +1 -1
  27. package/package.json +3 -3
  28. package/src/crane.ts +1 -1
  29. package/src/index.ts +3 -4
  30. package/src/rack-grid-3d.ts +383 -80
  31. package/src/rack-grid-cell.ts +161 -305
  32. package/src/rack-grid.ts +1263 -762
  33. package/src/rack-materials.ts +61 -0
  34. package/src/storage-rack-3d.ts +144 -30
  35. package/src/storage-rack.ts +763 -111
  36. package/test/test-carrier-lifecycle.ts +361 -0
  37. package/test/test-coord-alignment.ts +201 -0
  38. package/test/test-external-to-rack.ts +461 -0
  39. package/test/test-mover-concurrent-bug.ts +304 -0
  40. package/test/test-mover-rollback.ts +290 -0
  41. package/test/test-r19-place-absorb.ts +174 -0
  42. package/test/test-rack-3d-attach-real.ts +301 -0
  43. package/test/test-rack-concurrent.ts +254 -0
  44. package/test/test-rack-edge-cases.ts +323 -0
  45. package/test/test-rack-grid-cell.ts +318 -0
  46. package/test/test-rack-grid-location.ts +657 -0
  47. package/test/test-real-3d-positioning.ts +158 -0
  48. package/test/test-slot-center-convention.ts +116 -0
  49. package/test/test-slot-target.ts +189 -0
  50. package/test/test-storage-rack-batched.ts +606 -0
  51. package/test/test-storage-rack-click.ts +329 -0
  52. package/test/test-storage-rack-slot-api.ts +357 -0
  53. package/test/test-toscene-convention.ts +162 -0
  54. package/test/test-user-scenario-sequential.ts +334 -0
  55. package/translations/en.json +2 -0
  56. package/translations/ja.json +2 -0
  57. package/translations/ko.json +2 -0
  58. package/translations/ms.json +2 -0
  59. package/translations/zh.json +2 -0
  60. package/tsconfig.tsbuildinfo +1 -1
  61. package/dist/rack-column.d.ts +0 -35
  62. package/dist/rack-column.js +0 -258
  63. package/dist/rack-column.js.map +0 -1
  64. package/dist/rack-grid-helpers.d.ts +0 -28
  65. package/dist/rack-grid-helpers.js +0 -71
  66. package/dist/rack-grid-helpers.js.map +0 -1
  67. package/dist/rack-grid-location.d.ts +0 -37
  68. package/dist/rack-grid-location.js +0 -227
  69. package/dist/rack-grid-location.js.map +0 -1
  70. package/dist/storage-cell-3d.d.ts +0 -25
  71. package/dist/storage-cell-3d.js +0 -88
  72. package/dist/storage-cell-3d.js.map +0 -1
  73. package/dist/storage-cell.d.ts +0 -73
  74. package/dist/storage-cell.js +0 -215
  75. package/dist/storage-cell.js.map +0 -1
  76. package/src/rack-column.ts +0 -340
  77. package/src/rack-grid-helpers.ts +0 -77
  78. package/src/rack-grid-location.ts +0 -286
  79. package/src/storage-cell-3d.ts +0 -101
  80. package/src/storage-cell.ts +0 -267
  81. package/test/test-cell-position.ts +0 -105
  82. package/test/test-rack-grid.ts +0 -77
@@ -0,0 +1,461 @@
1
+ /*
2
+ * 외부 (model-layer) 패키지 → Rack 내부로의 pickAndPlace 검증.
3
+ *
4
+ * 사용자 질문:
5
+ * 1. 외부 → 내부 프로세스가 *완전히* 검증됐나?
6
+ * 2. 한 번 옮긴 후 *같은 외부 위치에서 또 가져오려 시도* 하는 결함 — dispose 가 정상인가?
7
+ *
8
+ * 검증:
9
+ * - 외부 parcel 의 lifecycle: model-layer 자식 → pick → crane 자식 → place → dispose
10
+ * - dispose 후 *id 인덱스 / findById / parent reference 모두 정리되어야*
11
+ * - 두 번째 pickAndPlace 시도 → 같은 parcel reference 가 *유효하지 않아야*
12
+ * - state.data 에 record 로 환원되어 다시 obtain 가능해야 함
13
+ */
14
+
15
+ import 'should'
16
+ import * as THREE from 'three'
17
+
18
+ // ── Mini scene: root + model-layer + rack + crane ──────────────────────────
19
+
20
+ class MiniRoot {
21
+ components: any[] = []
22
+ _idIndex = new Map<string, any>()
23
+
24
+ addComponent(c: any): void {
25
+ c.parent = this
26
+ this.components.push(c)
27
+ if (c.state?.id) this._idIndex.set(c.state.id, c)
28
+ // children recursive add
29
+ for (const child of c.components ?? []) {
30
+ this.addComponent(child)
31
+ }
32
+ }
33
+
34
+ removeComponent(c: any): void {
35
+ const i = this.components.indexOf(c)
36
+ if (i >= 0) this.components.splice(i, 1)
37
+ if (c.state?.id) this._idIndex.delete(c.state.id)
38
+ c.parent = null
39
+ }
40
+
41
+ // Recursive — descendant 도 id 등록
42
+ _registerDescendants(c: any): void {
43
+ if (c.state?.id) this._idIndex.set(c.state.id, c)
44
+ for (const child of c.components ?? []) this._registerDescendants(child)
45
+ }
46
+ _unregisterDescendants(c: any): void {
47
+ if (c.state?.id) this._idIndex.delete(c.state.id)
48
+ for (const child of c.components ?? []) this._unregisterDescendants(child)
49
+ }
50
+
51
+ findById(id: string): any { return this._idIndex.get(id) ?? null }
52
+ }
53
+
54
+ class MiniModelLayer {
55
+ parent: any = null
56
+ components: any[] = []
57
+ state = { id: 'model-layer' }
58
+ object3d = new THREE.Group()
59
+
60
+ constructor() { this.object3d.name = 'model-layer' }
61
+
62
+ addComponent(c: any): void {
63
+ c.parent = this
64
+ this.components.push(c)
65
+ if (c._realObject?.object3d) this.object3d.attach(c._realObject.object3d)
66
+ // root index sync
67
+ const root = this._findRoot()
68
+ if (root) root._registerDescendants(c)
69
+ }
70
+
71
+ removeComponent(c: any): void {
72
+ const i = this.components.indexOf(c)
73
+ if (i >= 0) this.components.splice(i, 1)
74
+ c.parent = null
75
+ const root = this._findRoot()
76
+ if (root) root._unregisterDescendants(c)
77
+ }
78
+
79
+ _findRoot(): MiniRoot | null {
80
+ let p: any = this.parent
81
+ while (p && !(p instanceof MiniRoot)) p = p.parent
82
+ return p as MiniRoot ?? null
83
+ }
84
+
85
+ canReceive(_c: any): boolean { return true }
86
+ async receive(c: any): Promise<void> {
87
+ const p = c.parent
88
+ if (p?.removeComponent) p.removeComponent(c)
89
+ this.addComponent(c)
90
+ }
91
+ }
92
+
93
+ class MiniRack {
94
+ parent: any = null
95
+ state: any = { id: 'rack', data: [] }
96
+ components: any[] = []
97
+ object3d = new THREE.Group()
98
+ _slotAnchors = new Map<string, THREE.Object3D>()
99
+
100
+ constructor() { this.object3d.name = 'rack' }
101
+
102
+ records(): any[] { return this.state.data }
103
+ carrierAt(cellId: string): any {
104
+ return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
105
+ }
106
+ canReceiveAt(cellId: string, carrier?: any): boolean {
107
+ if (this.records().some(r => r?.cellId === cellId)) return false
108
+ const existing = this.carrierAt(cellId)
109
+ if (existing && existing !== carrier) return false
110
+ return true
111
+ }
112
+
113
+ addComponent(c: any): void {
114
+ c.parent = this
115
+ this.components.push(c)
116
+ const root = (this.parent as MiniModelLayer)?._findRoot?.()
117
+ if (root) root._registerDescendants(c)
118
+ }
119
+ removeComponent(c: any): void {
120
+ const i = this.components.indexOf(c)
121
+ if (i >= 0) this.components.splice(i, 1)
122
+ c.parent = null
123
+ const root = (this.parent as MiniModelLayer)?._findRoot?.()
124
+ if (root) root._unregisterDescendants(c)
125
+ }
126
+
127
+ ensureSlotAnchor(cellId: string): THREE.Object3D {
128
+ let a = this._slotAnchors.get(cellId)
129
+ if (!a) {
130
+ a = new THREE.Object3D()
131
+ a.name = `slot:${cellId}`
132
+ this.object3d.add(a)
133
+ this._slotAnchors.set(cellId, a)
134
+ }
135
+ return a
136
+ }
137
+
138
+ slotTargetAt(cellId: string): any {
139
+ const rack = this
140
+ return {
141
+ slotId: cellId,
142
+ holder: rack,
143
+ canReceive: (c: any) => rack.canReceiveAt(cellId, c),
144
+ async receive(c: any) { await rack.receiveAt(cellId, c) }
145
+ }
146
+ }
147
+
148
+ async receiveAt(cellId: string, carrier: any): Promise<void> {
149
+ if (!this.canReceiveAt(cellId, carrier)) {
150
+ const err: any = new Error('slot occupied')
151
+ err.reason = 'slot-occupied'
152
+ throw err
153
+ }
154
+ const p = carrier.parent
155
+ if (p?.removeComponent) p.removeComponent(carrier)
156
+ const obj = carrier._realObject?.object3d
157
+ if (obj?.parent?.remove) obj.parent.remove(obj)
158
+ carrier.dispose?.()
159
+ const rec: any = { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }
160
+ const remaining = this.records().filter(r => r?.cellId !== cellId)
161
+ this.state.data = [...remaining, rec]
162
+ }
163
+
164
+ obtainCarrier(cellId: string): any {
165
+ const existing = this.carrierAt(cellId)
166
+ if (existing) return existing
167
+ const records = this.records()
168
+ const idx = records.findIndex(r => r?.cellId === cellId)
169
+ if (idx === -1) return null
170
+ const record = records[idx]
171
+ const c: any = {
172
+ placement: 'operation',
173
+ state: { ...record, cellId }, // id 명시 안 함 → 새 transient (외부 parcel 과 다른 객체)
174
+ parent: null,
175
+ _disposed: false,
176
+ _realObject: { object3d: new THREE.Group() },
177
+ dispose() {
178
+ this._disposed = true
179
+ if (this._realObject?.object3d?.parent?.remove) {
180
+ this._realObject.object3d.parent.remove(this._realObject.object3d)
181
+ }
182
+ this._realObject.object3d.clear()
183
+ }
184
+ }
185
+ this.addComponent(c)
186
+ const pt = this.ensureSlotAnchor(cellId)
187
+ pt.attach(c._realObject.object3d)
188
+ c._realObject.object3d.position.set(0, 0, 0)
189
+ this.state.data = records.filter(r => r?.cellId !== cellId)
190
+ return c
191
+ }
192
+ }
193
+
194
+ class MiniCrane {
195
+ parent: any = null
196
+ state = { id: 'crane' }
197
+ components: any[] = []
198
+ object3d = new THREE.Group()
199
+ forkObject3d: THREE.Object3D
200
+ constructor() {
201
+ this.object3d.name = 'crane'
202
+ this.forkObject3d = new THREE.Object3D()
203
+ this.forkObject3d.name = 'crane-fork'
204
+ this.object3d.add(this.forkObject3d)
205
+ }
206
+ canReceive(_c: any): boolean {
207
+ return this.components.filter(c => c._transferSlotId === 'forks').length < 1
208
+ }
209
+ async receive(c: any): Promise<void> {
210
+ if (!this.canReceive(c)) {
211
+ const err: any = new Error('crane full')
212
+ err.reason = 'all-slots-full'
213
+ throw err
214
+ }
215
+ const op = c.parent
216
+ if (op?.removeComponent) op.removeComponent(c)
217
+ c.parent = this
218
+ this.components.push(c)
219
+ c._transferSlotId = 'forks'
220
+ const obj = c._realObject?.object3d
221
+ if (obj) {
222
+ if (obj.parent?.remove) obj.parent.remove(obj)
223
+ this.forkObject3d.attach(obj)
224
+ obj.position.set(0, 0, 0)
225
+ }
226
+ }
227
+ removeComponent(c: any): void {
228
+ const i = this.components.indexOf(c)
229
+ if (i >= 0) this.components.splice(i, 1)
230
+ c.parent = null
231
+ }
232
+ async dispatch(c: any, target: any): Promise<void> {
233
+ if (target.canReceive && !target.canReceive(c)) {
234
+ const err: any = new Error('target rejected')
235
+ err.reason = 'cannot-receive'
236
+ throw err
237
+ }
238
+ delete c._transferSlotId
239
+ if (typeof target.receive === 'function') await target.receive(c)
240
+ }
241
+ async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
242
+ // R18 guard — disposed carrier 재사용 차단
243
+ if (carrier?._disposed) {
244
+ throw new Error('Mover.pickAndPlace: carrier is already disposed')
245
+ }
246
+ const sourceParent: any = carrier.parent
247
+ try {
248
+ if (carrier.parent !== this) await this.receive(carrier)
249
+ const timeoutMs = options.timeoutMs ?? 1000
250
+ const started = Date.now()
251
+ while (target.canReceive && !target.canReceive(carrier)) {
252
+ if (Date.now() - started > timeoutMs) throw new Error(`place timeout`)
253
+ await new Promise(r => setTimeout(r, 5))
254
+ }
255
+ await this.dispatch(carrier, target)
256
+ } catch (err) {
257
+ if (this.components.includes(carrier)) {
258
+ if (sourceParent?.canReceive?.(carrier)) {
259
+ try { await this.dispatch(carrier, sourceParent) } catch {}
260
+ } else {
261
+ this.removeComponent(carrier)
262
+ }
263
+ }
264
+ throw err
265
+ }
266
+ }
267
+ }
268
+
269
+ function buildScene() {
270
+ const root = new MiniRoot()
271
+ const modelLayer = new MiniModelLayer()
272
+ const rack = new MiniRack()
273
+ const crane = new MiniCrane()
274
+ root.addComponent(modelLayer)
275
+ modelLayer.parent = root
276
+ rack.parent = modelLayer
277
+ modelLayer.components.push(rack)
278
+ if (rack.state?.id) root._idIndex.set(rack.state.id, rack)
279
+ crane.parent = modelLayer
280
+ modelLayer.components.push(crane)
281
+ if (crane.state?.id) root._idIndex.set(crane.state.id, crane)
282
+ return { root, modelLayer, rack, crane }
283
+ }
284
+
285
+ // ── Group 1: 외부 → 내부 pickAndPlace 기본 흐름 ─────────────────────────────
286
+
287
+ describe('External → Rack: 기본 흐름 검증', () => {
288
+ it('parcel (model-layer 자식) → rack slot — state.data 에 record 추가', async () => {
289
+ const { root, modelLayer, rack, crane } = buildScene()
290
+ const parcel: any = {
291
+ placement: 'operation',
292
+ state: { id: 'parcel', type: 'parcel', sku: 'X' },
293
+ parent: null,
294
+ _disposed: false,
295
+ _realObject: { object3d: new THREE.Group() },
296
+ dispose() {
297
+ this._disposed = true
298
+ if (this._realObject?.object3d?.parent?.remove) this._realObject.object3d.parent.remove(this._realObject.object3d)
299
+ this._realObject.object3d.clear()
300
+ }
301
+ }
302
+ modelLayer.addComponent(parcel)
303
+
304
+ root.findById('parcel').should.equal(parcel)
305
+ modelLayer.components.includes(parcel).should.be.true()
306
+
307
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
308
+
309
+ // 검증: rack 의 state.data 에 record 가 있어야 함
310
+ rack.records().length.should.equal(1)
311
+ rack.records()[0].cellId.should.equal('A')
312
+ rack.records()[0].sku.should.equal('X')
313
+
314
+ // parcel 은 dispose 됐어야 함
315
+ parcel._disposed.should.be.true()
316
+ ;(parcel.parent === null).should.be.true()
317
+ })
318
+
319
+ it('FIX 검증 — dispose 후 findById 가 *parcel 을 못 찾아야*', async () => {
320
+ const { root, modelLayer, rack, crane } = buildScene()
321
+ const parcel: any = {
322
+ placement: 'operation',
323
+ state: { id: 'parcel', type: 'parcel' },
324
+ parent: null,
325
+ _disposed: false,
326
+ _realObject: { object3d: new THREE.Group() },
327
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
328
+ }
329
+ modelLayer.addComponent(parcel)
330
+
331
+ root.findById('parcel').should.equal(parcel) // 시작 — 찾을 수 있음
332
+
333
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
334
+
335
+ // *결정적 검증* — dispose 후엔 못 찾아야 함
336
+ ;(root.findById('parcel') === null).should.be.true()
337
+ })
338
+
339
+ it('parcel.object3d 가 *완전히 detach* — model-layer / crane / rack 어디에도 없음', async () => {
340
+ const { root, modelLayer, rack, crane } = buildScene()
341
+ const parcel: any = {
342
+ placement: 'operation',
343
+ state: { id: 'parcel', type: 'parcel' },
344
+ parent: null,
345
+ _realObject: { object3d: new THREE.Group() },
346
+ dispose() {
347
+ if (this._realObject?.object3d?.parent?.remove) this._realObject.object3d.parent.remove(this._realObject.object3d)
348
+ this._realObject.object3d.clear()
349
+ }
350
+ }
351
+ modelLayer.addComponent(parcel)
352
+ parcel._realObject.object3d.parent!.name.should.equal('model-layer')
353
+
354
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
355
+
356
+ ;(parcel._realObject.object3d.parent === null).should.be.true()
357
+ crane.forkObject3d.children.length.should.equal(0)
358
+ modelLayer.object3d.children.includes(parcel._realObject.object3d).should.be.false()
359
+ })
360
+ })
361
+
362
+ // ── Group 2: dispose 후 *다시* pickAndPlace 시도 ────────────────────────────
363
+
364
+ describe('External → Rack: dispose 후 reference 재사용', () => {
365
+ it('R18 FIX VERIFY — disposed parcel 로 또 pickAndPlace 시도 → *즉시 throw*', async () => {
366
+ const { root, modelLayer, rack, crane } = buildScene()
367
+ const parcel: any = {
368
+ placement: 'operation',
369
+ state: { id: 'parcel', type: 'parcel' },
370
+ parent: null,
371
+ _disposed: false,
372
+ _realObject: { object3d: new THREE.Group() },
373
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
374
+ }
375
+ modelLayer.addComponent(parcel)
376
+
377
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
378
+ parcel._disposed.should.be.true()
379
+ rack.records().length.should.equal(1)
380
+ rack.records()[0].cellId.should.equal('A')
381
+
382
+ // 두 번째 시도 — disposed reference 로 → R18 fix 가 *즉시 throw*
383
+ let thrown: any
384
+ try {
385
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('B'), { timeoutMs: 200 })
386
+ } catch (e) { thrown = e }
387
+
388
+ thrown.should.not.be.null()
389
+ thrown.message.should.match(/disposed/i)
390
+ // 좀비 작업 안 일어남 — state.data 그대로
391
+ rack.records().length.should.equal(1)
392
+ rack.records()[0].cellId.should.equal('A')
393
+ // crane 안 움직임
394
+ crane.components.length.should.equal(0)
395
+ })
396
+
397
+ it('FIX REQUIRED — dispose 된 carrier 는 *재사용 reject 되어야*', async () => {
398
+ const { root, modelLayer, rack, crane } = buildScene()
399
+ const parcel: any = {
400
+ placement: 'operation',
401
+ state: { id: 'parcel', type: 'parcel' },
402
+ parent: null,
403
+ _disposed: false,
404
+ _realObject: { object3d: new THREE.Group() },
405
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
406
+ }
407
+ modelLayer.addComponent(parcel)
408
+
409
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
410
+
411
+ // *원하는 동작* — disposed carrier 로 호출 시 명시 reject
412
+ // 현재 구현엔 _disposed 검사가 없음. 사용자 script 가 disposed reference 를 갖고
413
+ // 또 호출하면 *조용히 새 carrier 처럼 작동* 함 — 사용자가 "왜 같은 외부에서 또
414
+ // 가져오려 하나" 라고 느끼는 정체.
415
+ parcel._disposed.should.be.true()
416
+
417
+ // 사용자가 *해야 할 일* — 두 번째에선 obtainCarrier 로 *새 transient* 를 가져와야 함
418
+ const newCarrier = rack.obtainCarrier('A')
419
+ newCarrier.should.not.be.null()
420
+ newCarrier!.state.cellId.should.equal('A')
421
+ ;(newCarrier!._disposed !== true).should.be.true()
422
+ })
423
+ })
424
+
425
+ // ── Group 3: pickAndPlace 후 *rack 내부 record 로 다시 obtain* ──────────────
426
+
427
+ describe('External → Rack → 다시 외부로 (round trip)', () => {
428
+ it('외부 parcel → rack 진입 → 다시 obtainCarrier → 새 transient — 원본 X', async () => {
429
+ const { root, modelLayer, rack, crane } = buildScene()
430
+ const parcel: any = {
431
+ placement: 'operation',
432
+ state: { id: 'parcel', type: 'parcel', sku: 'X' },
433
+ parent: null,
434
+ _disposed: false,
435
+ _realObject: { object3d: new THREE.Group() },
436
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
437
+ }
438
+ modelLayer.addComponent(parcel)
439
+
440
+ // 1. 외부 → 내부
441
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
442
+ parcel._disposed.should.be.true()
443
+ rack.records().length.should.equal(1)
444
+
445
+ // 2. 내부 → (다시) 새 외부 위치로 — obtainCarrier 로 새 transient
446
+ const newCarrier = rack.obtainCarrier('A')!
447
+ newCarrier.should.not.equal(parcel) // 원본과 다른 객체
448
+ newCarrier._disposed.should.equal(false)
449
+ newCarrier.state.sku.should.equal('X') // 데이터 보존
450
+
451
+ // 3. 새 transient 를 model-layer 등 외부로 보냄
452
+ await crane.pickAndPlace(newCarrier, modelLayer, { timeoutMs: 200 })
453
+
454
+ // 사용자가 보는 결과:
455
+ // - rack.records 비어있음 (carrier 가 외부로 떠났음)
456
+ // - newCarrier 는 model-layer 의 자식
457
+ rack.records().length.should.equal(0)
458
+ modelLayer.components.includes(newCarrier).should.be.true()
459
+ newCarrier.state.sku.should.equal('X')
460
+ })
461
+ })