@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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/crane.js +1 -1
- package/dist/crane.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/rack-grid-3d.d.ts +18 -7
- package/dist/rack-grid-3d.js +372 -69
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid-cell.d.ts +21 -72
- package/dist/rack-grid-cell.js +147 -243
- package/dist/rack-grid-cell.js.map +1 -1
- package/dist/rack-grid.d.ts +277 -56
- package/dist/rack-grid.js +1230 -695
- package/dist/rack-grid.js.map +1 -1
- package/dist/rack-materials.d.ts +9 -0
- package/dist/rack-materials.js +55 -0
- package/dist/rack-materials.js.map +1 -0
- package/dist/storage-rack-3d.d.ts +15 -0
- package/dist/storage-rack-3d.js +131 -30
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +242 -45
- package/dist/storage-rack.js +684 -106
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/crane.ts +1 -1
- package/src/index.ts +3 -4
- package/src/rack-grid-3d.ts +383 -80
- package/src/rack-grid-cell.ts +161 -305
- package/src/rack-grid.ts +1263 -762
- package/src/rack-materials.ts +61 -0
- package/src/storage-rack-3d.ts +144 -30
- package/src/storage-rack.ts +763 -111
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-external-to-rack.ts +461 -0
- package/test/test-mover-concurrent-bug.ts +304 -0
- package/test/test-mover-rollback.ts +290 -0
- package/test/test-r19-place-absorb.ts +174 -0
- package/test/test-rack-3d-attach-real.ts +301 -0
- package/test/test-rack-concurrent.ts +254 -0
- package/test/test-rack-edge-cases.ts +323 -0
- package/test/test-rack-grid-cell.ts +318 -0
- package/test/test-rack-grid-location.ts +657 -0
- package/test/test-real-3d-positioning.ts +158 -0
- package/test/test-slot-center-convention.ts +116 -0
- package/test/test-slot-target.ts +189 -0
- package/test/test-storage-rack-batched.ts +606 -0
- package/test/test-storage-rack-click.ts +329 -0
- package/test/test-storage-rack-slot-api.ts +357 -0
- package/test/test-toscene-convention.ts +162 -0
- package/test/test-user-scenario-sequential.ts +334 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/rack-column.d.ts +0 -35
- package/dist/rack-column.js +0 -258
- package/dist/rack-column.js.map +0 -1
- package/dist/rack-grid-helpers.d.ts +0 -28
- package/dist/rack-grid-helpers.js +0 -71
- package/dist/rack-grid-helpers.js.map +0 -1
- package/dist/rack-grid-location.d.ts +0 -37
- package/dist/rack-grid-location.js +0 -227
- package/dist/rack-grid-location.js.map +0 -1
- package/dist/storage-cell-3d.d.ts +0 -25
- package/dist/storage-cell-3d.js +0 -88
- package/dist/storage-cell-3d.js.map +0 -1
- package/dist/storage-cell.d.ts +0 -73
- package/dist/storage-cell.js +0 -215
- package/dist/storage-cell.js.map +0 -1
- package/src/rack-column.ts +0 -340
- package/src/rack-grid-helpers.ts +0 -77
- package/src/rack-grid-location.ts +0 -286
- package/src/storage-cell-3d.ts +0 -101
- package/src/storage-cell.ts +0 -267
- package/test/test-cell-position.ts +0 -105
- 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
|
+
})
|