@operato/scene-storage 10.0.0-beta.40 → 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 (102) hide show
  1. package/CHANGELOG.md +29 -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/box.js +18 -0
  5. package/dist/box.js.map +1 -1
  6. package/dist/crane-3d.d.ts +47 -2
  7. package/dist/crane-3d.js +246 -89
  8. package/dist/crane-3d.js.map +1 -1
  9. package/dist/crane.d.ts +96 -12
  10. package/dist/crane.js +395 -100
  11. package/dist/crane.js.map +1 -1
  12. package/dist/index.d.ts +3 -4
  13. package/dist/index.js +1 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/pallet.d.ts +15 -0
  16. package/dist/pallet.js +38 -2
  17. package/dist/pallet.js.map +1 -1
  18. package/dist/parcel-3d.js +22 -18
  19. package/dist/parcel-3d.js.map +1 -1
  20. package/dist/parcel.d.ts +4 -3
  21. package/dist/parcel.js +24 -5
  22. package/dist/parcel.js.map +1 -1
  23. package/dist/rack-grid-3d.d.ts +18 -7
  24. package/dist/rack-grid-3d.js +372 -69
  25. package/dist/rack-grid-3d.js.map +1 -1
  26. package/dist/rack-grid-cell.d.ts +21 -72
  27. package/dist/rack-grid-cell.js +147 -243
  28. package/dist/rack-grid-cell.js.map +1 -1
  29. package/dist/rack-grid.d.ts +277 -56
  30. package/dist/rack-grid.js +1230 -695
  31. package/dist/rack-grid.js.map +1 -1
  32. package/dist/rack-materials.d.ts +9 -0
  33. package/dist/rack-materials.js +55 -0
  34. package/dist/rack-materials.js.map +1 -0
  35. package/dist/storage-rack-3d.d.ts +15 -0
  36. package/dist/storage-rack-3d.js +165 -29
  37. package/dist/storage-rack-3d.js.map +1 -1
  38. package/dist/storage-rack.d.ts +253 -32
  39. package/dist/storage-rack.js +726 -66
  40. package/dist/storage-rack.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/box.ts +18 -0
  43. package/src/crane-3d.ts +258 -93
  44. package/src/crane.ts +445 -110
  45. package/src/index.ts +3 -4
  46. package/src/pallet.ts +50 -1
  47. package/src/parcel-3d.ts +23 -18
  48. package/src/parcel.ts +24 -5
  49. package/src/rack-grid-3d.ts +383 -80
  50. package/src/rack-grid-cell.ts +161 -305
  51. package/src/rack-grid.ts +1263 -762
  52. package/src/rack-materials.ts +61 -0
  53. package/src/storage-rack-3d.ts +182 -29
  54. package/src/storage-rack.ts +819 -67
  55. package/test/test-carrier-lifecycle.ts +361 -0
  56. package/test/test-coord-alignment.ts +201 -0
  57. package/test/test-crane-geometry.ts +167 -0
  58. package/test/test-external-to-rack.ts +461 -0
  59. package/test/test-mover-concurrent-bug.ts +304 -0
  60. package/test/test-mover-rollback.ts +290 -0
  61. package/test/test-phase-h-carrier-pickable.ts +4 -3
  62. package/test/test-r19-place-absorb.ts +174 -0
  63. package/test/test-rack-3d-attach-real.ts +301 -0
  64. package/test/test-rack-concurrent.ts +254 -0
  65. package/test/test-rack-edge-cases.ts +323 -0
  66. package/test/test-rack-grid-cell.ts +318 -0
  67. package/test/test-rack-grid-location.ts +657 -0
  68. package/test/test-real-3d-positioning.ts +158 -0
  69. package/test/test-slot-center-convention.ts +116 -0
  70. package/test/test-slot-target.ts +189 -0
  71. package/test/test-storage-rack-batched.ts +606 -0
  72. package/test/test-storage-rack-click.ts +329 -0
  73. package/test/test-storage-rack-slot-api.ts +357 -0
  74. package/test/test-toscene-convention.ts +162 -0
  75. package/test/test-user-scenario-sequential.ts +334 -0
  76. package/translations/en.json +7 -1
  77. package/translations/ja.json +7 -1
  78. package/translations/ko.json +7 -1
  79. package/translations/ms.json +7 -1
  80. package/translations/zh.json +7 -1
  81. package/tsconfig.tsbuildinfo +1 -1
  82. package/dist/rack-column.d.ts +0 -35
  83. package/dist/rack-column.js +0 -258
  84. package/dist/rack-column.js.map +0 -1
  85. package/dist/rack-grid-helpers.d.ts +0 -28
  86. package/dist/rack-grid-helpers.js +0 -71
  87. package/dist/rack-grid-helpers.js.map +0 -1
  88. package/dist/rack-grid-location.d.ts +0 -37
  89. package/dist/rack-grid-location.js +0 -227
  90. package/dist/rack-grid-location.js.map +0 -1
  91. package/dist/storage-cell-3d.d.ts +0 -25
  92. package/dist/storage-cell-3d.js +0 -88
  93. package/dist/storage-cell-3d.js.map +0 -1
  94. package/dist/storage-cell.d.ts +0 -70
  95. package/dist/storage-cell.js +0 -197
  96. package/dist/storage-cell.js.map +0 -1
  97. package/src/rack-column.ts +0 -340
  98. package/src/rack-grid-helpers.ts +0 -77
  99. package/src/rack-grid-location.ts +0 -286
  100. package/src/storage-cell-3d.ts +0 -101
  101. package/src/storage-cell.ts +0 -247
  102. package/test/test-rack-grid.ts +0 -77
@@ -0,0 +1,606 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * StorageRack batched mode behavioural tests — FakeBatchedRack 으로 contract 검증.
5
+ *
6
+ * 실제 storage-rack.ts import 는 @hatiolab/things-scene 번들의 Component named export
7
+ * 누락으로 mocha 환경에서 불가. 기존 test-storage-rack-crane.ts 와 동일하게 Fake 패턴.
8
+ *
9
+ * 검증하는 contract:
10
+ * - 점유 cell materialize → carrier 자동 생성 + payload 에서 제거
11
+ * - 빈 cell materialize → empty cell
12
+ * - canReceive 가 자식 carrier 갯수 기반으로 정직히 동작
13
+ * - dematerialize → record 복귀, 컴포넌트 정리
14
+ * - cellComponent / carrierAt / cell.carrier 의 lookup 정확성
15
+ * - patchStock 의 incremental update + state.data sync
16
+ * - Mover.pickAndPlace 와의 통합 (Putaway / Pickup / Cell-to-cell)
17
+ * - "박스 다시 끌고 나옴" 결함 회귀 방지
18
+ */
19
+
20
+ import 'should'
21
+ import { ContainerCapacity, TRANSFER_SLOT_KEY } from '@hatiolab/things-scene'
22
+ import Mover from '../../scene-base/src/mover.js'
23
+
24
+ // ── Fake infra (test-storage-rack-crane.ts 와 동일 baseline + on/off 이벤트) ──
25
+
26
+ class FakeBase {
27
+ _components: any[] = []
28
+ state: Record<string, any>
29
+ parent: any = null
30
+ root: any = null
31
+ app: any = { isViewMode: true }
32
+ _listeners = new Map<string, Array<(...args: any[]) => void>>()
33
+
34
+ constructor(state: Record<string, any> = {}) {
35
+ this.state = state
36
+ }
37
+
38
+ get components() {
39
+ return this._components
40
+ }
41
+
42
+ setState(keyOrObj: string | Record<string, any>, value?: any) {
43
+ if (typeof keyOrObj === 'object') Object.assign(this.state, keyOrObj)
44
+ else this.state[keyOrObj as string] = value
45
+ }
46
+
47
+ getState(key: string) {
48
+ return this.state[key]
49
+ }
50
+
51
+ addComponent(child: any) {
52
+ if (child.parent && child.parent !== this) {
53
+ const idx = child.parent._components?.indexOf(child) ?? -1
54
+ if (idx >= 0) child.parent._components.splice(idx, 1)
55
+ child.parent = null
56
+ }
57
+ if (!this._components.includes(child)) this._components.push(child)
58
+ child.parent = this
59
+ }
60
+
61
+ removeComponent(child: any) {
62
+ const idx = this._components.indexOf(child)
63
+ if (idx >= 0) this._components.splice(idx, 1)
64
+ if (child.parent === this) child.parent = null
65
+ }
66
+
67
+ reparent(child: any, _options?: any) {
68
+ this.addComponent(child)
69
+ }
70
+
71
+ on(name: string, fn: (...args: any[]) => void) {
72
+ let list = this._listeners.get(name)
73
+ if (!list) { list = []; this._listeners.set(name, list) }
74
+ list.push(fn)
75
+ }
76
+ off(name: string, fn: (...args: any[]) => void) {
77
+ const list = this._listeners.get(name)
78
+ if (!list) return
79
+ const i = list.indexOf(fn)
80
+ if (i >= 0) list.splice(i, 1)
81
+ }
82
+ trigger(name: string, payload?: any) {
83
+ const list = this._listeners.get(name)
84
+ if (!list) return
85
+ for (const fn of [...list]) fn(payload)
86
+ }
87
+ }
88
+
89
+ // ── FakeCarrier — operation archetype 모방 ────────────────────────────────────
90
+
91
+ function makeCarrier(state: any = { type: 'parcel' }) {
92
+ return {
93
+ parent: null as any,
94
+ state: { ...state },
95
+ [TRANSFER_SLOT_KEY]: undefined as any,
96
+ __rackRecord: undefined as any,
97
+ constructor: { placement: 'operation' },
98
+ setState(s: any) { Object.assign(this.state, s) }
99
+ }
100
+ }
101
+
102
+ // ── FakeRackCell — RackCell.receive/dispatch/canReceive 모방 + carrier getter ─
103
+
104
+ class FakeRackCell extends FakeBase {
105
+ cellId: string
106
+
107
+ constructor(state: Record<string, any> = {}) {
108
+ super(state)
109
+ this.cellId = (state.cellId as string) || 'cell-0-0-0'
110
+ }
111
+
112
+ get carrier() {
113
+ return this._components.find((c: any) => c.constructor?.placement === 'operation')
114
+ }
115
+
116
+ canReceive(_component?: any): boolean {
117
+ return this._components.length < 1
118
+ }
119
+
120
+ async receive(carrier: any, options: any = {}): Promise<void> {
121
+ if (!this.canReceive(carrier)) {
122
+ this.trigger('transfer-rejected', { component: carrier, container: this })
123
+ return
124
+ }
125
+ carrier[TRANSFER_SLOT_KEY] = this.cellId
126
+ this.reparent(carrier, options)
127
+ this.trigger('transfer-received', { component: carrier, container: this, slotId: this.cellId })
128
+ }
129
+
130
+ async dispatch(carrier: any, target: any, options: any = {}): Promise<void> {
131
+ this.removeComponent(carrier)
132
+ if (typeof target?.receive === 'function') {
133
+ await target.receive(carrier, options)
134
+ } else {
135
+ target?.reparent?.(carrier, options)
136
+ }
137
+ this.trigger('transfer-dispatched', { component: carrier, container: this, target })
138
+ }
139
+ }
140
+
141
+ // ── FakeCrane — Mover + ContainerCapacity ─────────────────────────────────────
142
+
143
+ const FakeCraneBase = Mover(ContainerCapacity(FakeBase as any))
144
+
145
+ class FakeCrane extends (FakeCraneBase as any) {
146
+ constructor(state: Record<string, any> = {}) {
147
+ super({ status: 'idle', carriageHeight: 0, ...state })
148
+ }
149
+
150
+ get slots() { return [{ id: 'forks', maxCount: 1 }] }
151
+
152
+ moveTo() { return Promise.resolve() }
153
+
154
+ async engage(_target: any, kind: 'pick' | 'place') {
155
+ this.state.status = kind === 'pick' ? 'loading' : 'unloading'
156
+ }
157
+ }
158
+
159
+ // ── FakeBatchedRack — 실제 storage-rack.ts contract 와 일치하는 mock ──────────
160
+ // 이 mock 의 동작이 *실제 코드의 의도된 contract*. 둘이 다르면 mock 또는 실제 둘 중
161
+ // 하나가 틀린 것 — review 신호.
162
+
163
+ interface CellDef {
164
+ id: string
165
+ bay: number
166
+ row: number
167
+ level: number
168
+ size: { width: number; height: number; depth: number }
169
+ localPosition: { x: number; y: number; z: number }
170
+ }
171
+
172
+ class FakeBatchedRack extends FakeBase {
173
+ bays: number
174
+ levels: number
175
+ _cellMap: { cells: CellDef[]; findById: (id: string) => CellDef | null }
176
+ _batchedPayload = new Map<string, any>()
177
+ _cellRegistry = new Map<string, FakeRackCell>()
178
+ _applyingData = false
179
+
180
+ constructor(bays: number, levels: number, data: any[] = []) {
181
+ super({ batched: true, data: [...data], bays, levels, width: 1000, height: 600, depth: 3000 })
182
+ this.bays = bays
183
+ this.levels = levels
184
+
185
+ const cells: CellDef[] = []
186
+ const bayWidth = 1000 / bays
187
+ const levelHeight = 3000 / levels
188
+ for (let bay = 0; bay < bays; bay++) {
189
+ for (let level = 0; level < levels; level++) {
190
+ cells.push({
191
+ id: `${bay}-0-${level}`,
192
+ bay: bay + 1, row: 1, level: level + 1,
193
+ size: { width: bayWidth, height: levelHeight, depth: 600 },
194
+ localPosition: { x: bay * bayWidth, y: level * levelHeight, z: 0 }
195
+ })
196
+ }
197
+ }
198
+ this._cellMap = {
199
+ cells,
200
+ findById: (id: string) => cells.find(c => c.id === id) ?? null
201
+ }
202
+
203
+ // initial payload 적재
204
+ for (const r of data) {
205
+ if (r?.cellId) this._batchedPayload.set(r.cellId, { ...r })
206
+ }
207
+ }
208
+
209
+ get cellMap() { return this._cellMap }
210
+
211
+ _resolveCellId(idOrBay: string | number, row: number, level: number): string {
212
+ return typeof idOrBay === 'string'
213
+ ? idOrBay
214
+ : `${(idOrBay as number) - 1}-${row - 1}-${level - 1}`
215
+ }
216
+
217
+ materializeCell(idOrBay: string | number, row = 1, level = 1): FakeRackCell | null {
218
+ const cellId = this._resolveCellId(idOrBay, row, level)
219
+ if (this._cellRegistry.has(cellId)) return this._cellRegistry.get(cellId)!
220
+ const def = this._cellMap.findById(cellId)
221
+ if (!def) return null
222
+
223
+ const cell = new FakeRackCell({ cellId })
224
+ cell.parent = this
225
+ this.addComponent(cell)
226
+ this._cellRegistry.set(cellId, cell)
227
+
228
+ const record = this._batchedPayload.get(cellId)
229
+ if (record) {
230
+ this._batchedPayload.delete(cellId)
231
+ const carrier = makeCarrier({ type: record.type || 'parcel', ...record })
232
+ carrier.__rackRecord = { ...record }
233
+ cell.addComponent(carrier)
234
+ }
235
+
236
+ // auto-cleanup on transfer-dispatched only (transfer-received → 유지)
237
+ const autoCleanup = () => queueMicrotask(() => this.dematerializeCell(cellId))
238
+ cell.on('transfer-dispatched', autoCleanup)
239
+ ;(cell as any).__rackAutoCleanup = autoCleanup
240
+ return cell
241
+ }
242
+
243
+ dematerializeCell(idOrBay: string | number, row = 1, level = 1): void {
244
+ const cellId = this._resolveCellId(idOrBay, row, level)
245
+ const cell = this._cellRegistry.get(cellId)
246
+ if (!cell) return
247
+
248
+ const carrier = cell._components.find((c: any) => c.constructor?.placement === 'operation')
249
+ if (carrier) {
250
+ const record = carrier.__rackRecord ?? { cellId, ...carrier.state }
251
+ this._batchedPayload.set(cellId, record)
252
+ }
253
+ const ac = (cell as any).__rackAutoCleanup
254
+ if (ac) cell.off('transfer-dispatched', ac)
255
+ this.removeComponent(cell)
256
+ this._cellRegistry.delete(cellId)
257
+ }
258
+
259
+ cellComponent(idOrBay: string | number, row = 1, level = 1): FakeRackCell | null {
260
+ const def = this._cellMap.findById(this._resolveCellId(idOrBay, row, level))
261
+ if (!def) return null
262
+ for (const c of this._components) {
263
+ if ((c as any).cellId === def.id) return c as FakeRackCell
264
+ }
265
+ if (this.state.batched) return this.materializeCell(idOrBay, row, level)
266
+ return null
267
+ }
268
+
269
+ carrierAt(idOrBay: string | number, row = 1, level = 1): any {
270
+ const cell = this.cellComponent(idOrBay, row, level)
271
+ return cell?.carrier ?? null
272
+ }
273
+
274
+ patchStock(cellId: string, fields: Record<string, any>) {
275
+ const existing = this._batchedPayload.get(cellId)
276
+ if (!existing) return
277
+ const merged = { ...existing, ...fields }
278
+ this._batchedPayload.set(cellId, merged)
279
+
280
+ // state.data 동기화 (rack 의 실제 로직 모방)
281
+ const data = (this.state.data as any[]) ?? []
282
+ const idx = data.findIndex(r => r?.cellId === cellId)
283
+ if (idx >= 0) data[idx] = merged
284
+ else data.push({ ...merged, cellId })
285
+ }
286
+ }
287
+
288
+ // ── Group 1: Batched payload 초기화 ─────────────────────────────────────────
289
+
290
+ const sampleData = () => [
291
+ { cellId: '0-0-0', type: 'parcel', sku: 'SKU-A', status: 'occupied' },
292
+ { cellId: '1-0-0', type: 'parcel', sku: 'SKU-B', status: 'occupied' },
293
+ { cellId: '2-0-2', type: 'parcel', sku: 'SKU-C', status: 'reserved' }
294
+ ]
295
+
296
+ describe('Rack batched: 초기 payload', () => {
297
+ it('cell 컴포넌트가 만들어지지 않음 (lazy)', () => {
298
+ const rack = new FakeBatchedRack(5, 4, sampleData())
299
+ rack._cellRegistry.size.should.equal(0)
300
+ })
301
+
302
+ it('초기 data 가 _batchedPayload 로 반영됨', () => {
303
+ const rack = new FakeBatchedRack(5, 4, sampleData())
304
+ rack._batchedPayload.size.should.equal(3)
305
+ rack._batchedPayload.has('0-0-0').should.be.true()
306
+ rack._batchedPayload.has('2-0-2').should.be.true()
307
+ })
308
+ })
309
+
310
+ // ── Group 2: materializeCell ────────────────────────────────────────────────
311
+
312
+ describe('Rack batched: materializeCell', () => {
313
+ it('빈 cell materialize → cell.components.length = 0', () => {
314
+ const rack = new FakeBatchedRack(5, 4, sampleData())
315
+ const cell = rack.materializeCell(4, 1, 4)! // 3-0-3 — 빈 셀
316
+ cell.should.not.be.null()
317
+ cell._components.length.should.equal(0)
318
+ })
319
+
320
+ it('점유 cell materialize → carrier 자동 생성, length=1', () => {
321
+ const rack = new FakeBatchedRack(5, 4, sampleData())
322
+ const cell = rack.materializeCell(1, 1, 1)! // 0-0-0 — 점유
323
+ cell._components.length.should.equal(1)
324
+ cell._components[0].state.type.should.equal('parcel')
325
+ cell._components[0].state.sku.should.equal('SKU-A')
326
+ })
327
+
328
+ it('점유 cell materialize 후 canReceive=false', () => {
329
+ const rack = new FakeBatchedRack(5, 4, sampleData())
330
+ const cell = rack.materializeCell(1, 1, 1)!
331
+ cell.canReceive(undefined).should.be.false()
332
+ })
333
+
334
+ it('빈 cell materialize 후 canReceive=true', () => {
335
+ const rack = new FakeBatchedRack(5, 4, sampleData())
336
+ const cell = rack.materializeCell(4, 1, 4)!
337
+ cell.canReceive(undefined).should.be.true()
338
+ })
339
+
340
+ it('materialize idempotent', () => {
341
+ const rack = new FakeBatchedRack(5, 4, sampleData())
342
+ const a = rack.materializeCell(1, 1, 1)
343
+ const b = rack.materializeCell(1, 1, 1)
344
+ a!.should.equal(b!)
345
+ })
346
+
347
+ it('materialize 시 payload 에서 cellId 제거', () => {
348
+ const rack = new FakeBatchedRack(5, 4, sampleData())
349
+ rack._batchedPayload.has('0-0-0').should.be.true()
350
+ rack.materializeCell(1, 1, 1)
351
+ rack._batchedPayload.has('0-0-0').should.be.false()
352
+ })
353
+
354
+ it('cellMap 에 없는 cellId → null', () => {
355
+ const rack = new FakeBatchedRack(5, 4, sampleData())
356
+ ;(rack.materializeCell(99, 99, 99) === null).should.be.true()
357
+ })
358
+ })
359
+
360
+ // ── Group 3: dematerializeCell ──────────────────────────────────────────────
361
+
362
+ describe('Rack batched: dematerializeCell', () => {
363
+ it('점유 cell dematerialize → record 가 payload 로 복귀', () => {
364
+ const rack = new FakeBatchedRack(5, 4, sampleData())
365
+ rack.materializeCell(1, 1, 1)
366
+ rack.dematerializeCell(1, 1, 1)
367
+ rack._batchedPayload.has('0-0-0').should.be.true()
368
+ })
369
+
370
+ it('dematerialize 후 cell 컴포넌트 제거', () => {
371
+ const rack = new FakeBatchedRack(5, 4, sampleData())
372
+ rack.materializeCell(1, 1, 1)
373
+ rack._cellRegistry.has('0-0-0').should.be.true()
374
+ rack.dematerializeCell(1, 1, 1)
375
+ rack._cellRegistry.has('0-0-0').should.be.false()
376
+ })
377
+ })
378
+
379
+ // ── Group 4: cellComponent / carrierAt ─────────────────────────────────────
380
+
381
+ describe('Rack batched: cellComponent / carrierAt', () => {
382
+ it('cellComponent 호출 시 batched 면 자동 materialize', () => {
383
+ const rack = new FakeBatchedRack(5, 4, sampleData())
384
+ rack._cellRegistry.size.should.equal(0)
385
+ rack.cellComponent(1, 1, 1)
386
+ rack._cellRegistry.size.should.equal(1)
387
+ })
388
+
389
+ it('carrierAt 으로 점유 cell 의 carrier 직접 추출', () => {
390
+ const rack = new FakeBatchedRack(5, 4, sampleData())
391
+ const carrier = rack.carrierAt(1, 1, 1)
392
+ carrier.should.not.be.null()
393
+ carrier.state.type.should.equal('parcel')
394
+ })
395
+
396
+ it('carrierAt 빈 cell → null', () => {
397
+ const rack = new FakeBatchedRack(5, 4, sampleData())
398
+ ;(rack.carrierAt(4, 1, 4) === null).should.be.true()
399
+ })
400
+
401
+ it('cell.carrier getter', () => {
402
+ const rack = new FakeBatchedRack(5, 4, sampleData())
403
+ const cell = rack.cellComponent(1, 1, 1)!
404
+ cell.carrier.should.not.be.null()
405
+ cell.carrier.state.type.should.equal('parcel')
406
+ })
407
+ })
408
+
409
+ // ── Group 5: patchStock ────────────────────────────────────────────────────
410
+
411
+ describe('Rack batched: patchStock', () => {
412
+ it('payload 의 record 갱신', () => {
413
+ const rack = new FakeBatchedRack(5, 4, sampleData())
414
+ rack.patchStock('0-0-0', { status: 'damaged', qty: 7 })
415
+ const r = rack._batchedPayload.get('0-0-0')
416
+ r.status.should.equal('damaged')
417
+ r.qty.should.equal(7)
418
+ })
419
+
420
+ it('state.data 도 함께 갱신', () => {
421
+ const rack = new FakeBatchedRack(5, 4, sampleData())
422
+ rack.patchStock('0-0-0', { status: 'reserved' })
423
+ const data = rack.state.data as any[]
424
+ const r = data.find(x => x.cellId === '0-0-0')
425
+ r.status.should.equal('reserved')
426
+ })
427
+
428
+ it('payload 에 없는 cell patch → no-op', () => {
429
+ const rack = new FakeBatchedRack(5, 4, sampleData())
430
+ const before = rack._batchedPayload.size
431
+ rack.patchStock('4-0-4', { status: 'reserved' })
432
+ rack._batchedPayload.size.should.equal(before)
433
+ })
434
+ })
435
+
436
+ // ── Group 6: Mover 통합 — Putaway / Pickup / Move ──────────────────────────
437
+
438
+ describe('Rack batched + Crane: Putaway (외부 → 빈 cell)', () => {
439
+ it('외부 parcel → 빈 cell. 성공 후 cell.components.length=1', async () => {
440
+ const rack = new FakeBatchedRack(5, 4, sampleData())
441
+ const crane = new FakeCrane()
442
+ const parcel = makeCarrier({ type: 'parcel', sku: 'NEW' })
443
+
444
+ const targetCell = rack.cellComponent(4, 1, 4)! // 빈 cell
445
+ await crane.pickAndPlace(parcel, targetCell)
446
+
447
+ targetCell._components.length.should.equal(1)
448
+ targetCell._components[0].should.equal(parcel)
449
+ })
450
+
451
+ it('putaway 후 parcel 이 cell 에서 *제거되지 않음* (회귀: 박스 다시 끌고 나옴)', async () => {
452
+ const rack = new FakeBatchedRack(5, 4, sampleData())
453
+ const crane = new FakeCrane()
454
+ const parcel = makeCarrier({ type: 'parcel', sku: 'NEW' })
455
+
456
+ const targetCell = rack.cellComponent(4, 1, 4)!
457
+ await crane.pickAndPlace(parcel, targetCell)
458
+
459
+ // queueMicrotask 가 flush 되도록 한 tick 대기
460
+ await new Promise(r => setImmediate(r))
461
+
462
+ // 회귀 검증 — auto-cleanup 이 transfer-received 에 걸리면 안 됨
463
+ rack._cellRegistry.has('3-0-3').should.be.true()
464
+ targetCell._components.length.should.equal(1)
465
+ targetCell._components[0].should.equal(parcel)
466
+ })
467
+ })
468
+
469
+ describe('Rack batched: 점유 cell 의 canReceive', () => {
470
+ it('점유 cell.canReceive 는 false (회귀: 이미 점유된 cell 에 putaway 통과)', () => {
471
+ const rack = new FakeBatchedRack(5, 4, sampleData())
472
+ const targetCell = rack.cellComponent(1, 1, 1)!
473
+ targetCell._components.length.should.equal(1)
474
+ targetCell.canReceive(undefined).should.be.false()
475
+ })
476
+
477
+ it('빈 cell.canReceive 는 true', () => {
478
+ const rack = new FakeBatchedRack(5, 4, sampleData())
479
+ const targetCell = rack.cellComponent(4, 1, 4)!
480
+ targetCell.canReceive(undefined).should.be.true()
481
+ })
482
+
483
+ it('점유 cell 에 직접 receive 호출 → reject (transfer-rejected 발생)', async () => {
484
+ const rack = new FakeBatchedRack(5, 4, sampleData())
485
+ const targetCell = rack.cellComponent(1, 1, 1)!
486
+ const intruder = makeCarrier({ type: 'parcel', sku: 'INTRUDER' })
487
+
488
+ let rejected = false
489
+ targetCell.on('transfer-rejected', () => { rejected = true })
490
+ await targetCell.receive(intruder)
491
+
492
+ rejected.should.be.true()
493
+ targetCell._components.length.should.equal(1)
494
+ targetCell._components[0].state.sku.should.equal('SKU-A') // 원래 점유자
495
+ })
496
+ })
497
+
498
+ describe('Rack batched: auto-dematerialize on transfer-dispatched', () => {
499
+ it('transfer-dispatched 발사 → microtask 후 source cell 정리', async () => {
500
+ const rack = new FakeBatchedRack(5, 4, sampleData())
501
+ const sourceCell = rack.cellComponent(1, 1, 1)!
502
+ rack._cellRegistry.has('0-0-0').should.be.true()
503
+
504
+ // dispatch 시뮬레이션 — 실제로는 Mover/Transfer 가 호출
505
+ const carrier = sourceCell.carrier
506
+ sourceCell.removeComponent(carrier)
507
+ sourceCell.trigger('transfer-dispatched', { component: carrier, container: sourceCell })
508
+
509
+ await new Promise(r => setImmediate(r))
510
+ rack._cellRegistry.has('0-0-0').should.be.false('pickup 후 source cell 자동 정리')
511
+ })
512
+
513
+ it('transfer-received 발사 → cell *유지* (회귀: 박스 다시 끌고 나옴 방지)', async () => {
514
+ const rack = new FakeBatchedRack(5, 4, sampleData())
515
+ const targetCell = rack.cellComponent(4, 1, 4)! // 빈 cell
516
+ const incomingParcel = makeCarrier({ type: 'parcel', sku: 'NEW' })
517
+
518
+ await targetCell.receive(incomingParcel)
519
+ await new Promise(r => setImmediate(r))
520
+
521
+ rack._cellRegistry.has('3-0-3').should.be.true('putaway 후 target cell 유지')
522
+ targetCell._components.length.should.equal(1)
523
+ targetCell._components[0].should.equal(incomingParcel)
524
+ })
525
+ })
526
+
527
+ describe('Rack batched + Crane: Pickup (cell → 외부)', () => {
528
+ it('점유 cell 에서 carrier 추출 후 다른 holder 로', async () => {
529
+ const rack = new FakeBatchedRack(5, 4, sampleData())
530
+ const crane = new FakeCrane()
531
+ const externalSpot = new FakeRackCell({ cellId: 'spot-1' })
532
+
533
+ const sourceCell = rack.cellComponent(1, 1, 1)!
534
+ const carrier = sourceCell.carrier
535
+ carrier.should.not.be.null()
536
+
537
+ await crane.pickAndPlace(carrier, externalSpot)
538
+
539
+ externalSpot._components.length.should.equal(1)
540
+ externalSpot._components[0].should.equal(carrier)
541
+ sourceCell._components.length.should.equal(0)
542
+ })
543
+ })
544
+
545
+ describe('Rack batched + Crane: Cell-to-cell move (sub-step)', () => {
546
+ it('source.carrier 추출 → 다른 cell 로 place 성공', async () => {
547
+ const rack = new FakeBatchedRack(5, 4, sampleData())
548
+ const crane = new FakeCrane()
549
+
550
+ const source = rack.cellComponent(1, 1, 1)!
551
+ const target = rack.cellComponent(4, 1, 4)!
552
+ const carrier = source.carrier
553
+
554
+ await crane.pickAndPlace(carrier, target)
555
+
556
+ target._components.length.should.equal(1)
557
+ target._components[0].should.equal(carrier)
558
+ rack._cellRegistry.has('3-0-3').should.be.true('target cell 은 유지')
559
+ })
560
+ })
561
+
562
+ // ── Group 7: 신규 결함 — pick 도중 텔레포트 ────────────────────────────────────
563
+ //
564
+ // 사용자 보고: "크레인이 대상 아이템을 가지러 가는 도중에 텔레포트되어서 크레인
565
+ // 캐리지에 옮겨져있는 경우가 많다. 이 시점에 크레인은 운행을 멈춰버린다."
566
+ //
567
+ // 검증 contract:
568
+ // - pick 의 moveTo 단계 동안 carrier 가 source cell 의 child 여야 함 (이동 X)
569
+ // - moveTo 완료 후에야 reparent 가 일어남
570
+ // - 중간에 reparent 가 발동되면 crane 의 동선이 깨질 위험
571
+
572
+ describe('Rack batched + Crane: pick 도중 텔레포트 회귀 방지', () => {
573
+ it('moveTo 단계 동안 carrier.parent === sourceCell (premature reparent 없음)', async () => {
574
+ const rack = new FakeBatchedRack(5, 4, sampleData())
575
+ const sourceCell = rack.cellComponent(1, 1, 1)!
576
+ const carrier = sourceCell.carrier
577
+
578
+ // 동선 추적용 crane — moveTo 중간에 carrier.parent 가 무엇인지 기록
579
+ class TracingCrane extends FakeCrane {
580
+ midMoveParent: any = null
581
+ async moveTo(_t: any) {
582
+ // 중간 tick 시점에 carrier 의 parent 캡처
583
+ this.midMoveParent = carrier.parent
584
+ await Promise.resolve()
585
+ }
586
+ }
587
+ const crane = new TracingCrane()
588
+ await crane.pick(carrier)
589
+
590
+ // 검증: moveTo 중에 carrier 는 아직 source cell 의 child 였어야 함
591
+ crane.midMoveParent.should.equal(sourceCell, 'moveTo 단계에서 carrier 가 텔레포트되면 안 됨')
592
+ })
593
+
594
+ it('pick 완료 후 carrier.parent === crane (reparent 는 moveTo+engage 뒤)', async () => {
595
+ const rack = new FakeBatchedRack(5, 4, sampleData())
596
+ const crane = new FakeCrane()
597
+
598
+ const sourceCell = rack.cellComponent(1, 1, 1)!
599
+ const carrier = sourceCell.carrier
600
+
601
+ await crane.pick(carrier)
602
+
603
+ carrier.parent.should.equal(crane, 'pick 완료 시 crane 의 child')
604
+ sourceCell._components.length.should.equal(0)
605
+ })
606
+ })