@operato/scene-storage 10.0.0-beta.44 → 10.0.0-beta.46

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 (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/crane-3d.d.ts +10 -0
  3. package/dist/crane-3d.js +34 -5
  4. package/dist/crane-3d.js.map +1 -1
  5. package/dist/crane.d.ts +136 -6
  6. package/dist/crane.js +567 -46
  7. package/dist/crane.js.map +1 -1
  8. package/dist/parcel-3d.d.ts +1 -0
  9. package/dist/parcel-3d.js +18 -1
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.js +26 -8
  12. package/dist/rack-grid-3d.js.map +1 -1
  13. package/dist/rack-grid.d.ts +94 -10
  14. package/dist/rack-grid.js +468 -86
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/storage-rack-3d.js +1 -1
  17. package/dist/storage-rack-3d.js.map +1 -1
  18. package/dist/storage-rack.d.ts +31 -6
  19. package/dist/storage-rack.js +96 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +615 -55
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +488 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +96 -14
  29. package/test/test-coord-alignment.ts +2 -2
  30. package/test/test-crane-bay-match.ts +130 -0
  31. package/test/test-crane-binding-resolve.ts +168 -0
  32. package/test/test-crane-duration.ts +90 -0
  33. package/test/test-crane-rotation-reach.ts +218 -0
  34. package/test/test-rack-grid-3d-alignment.ts +235 -0
  35. package/test/test-rack-grid-3d-attach-real.ts +375 -0
  36. package/test/test-rack-grid-cell.ts +2 -2
  37. package/test/test-rack-grid-location.ts +2 -2
  38. package/test/test-rack-grid-occupied-slots.ts +165 -0
  39. package/test/test-rack-grid-picking-position.ts +154 -0
  40. package/test/test-rack-grid-slot-api.ts +483 -0
  41. package/test/test-slot-ids-enumeration.ts +137 -0
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,375 @@
1
+ /*
2
+ * RackGrid Plan A 의 *진짜 Three.js* attach/detach 검증.
3
+ *
4
+ * 동등 본 test-rack-3d-attach-real.ts 와 *동일한 검증 패턴* — RackGrid 가
5
+ * StorageRack 의 handoff contract 와 *동일한 3D scene graph 흐름* 을 보이는지.
6
+ *
7
+ * 추가 검증 — **handoff follow**:
8
+ * crane 의 fork 가 움직이면 carrier 의 world position 이 자동 따라가야 함.
9
+ * 사용자 보고 *crane 이 carrier 를 못 담음 / fork 빈 포크짓* 의 회귀 방지.
10
+ *
11
+ * 시나리오:
12
+ * 1. obtainCarrier 후 carrier.obj3d.parent === rack-grid-anchor
13
+ * 2. crane.receive 후 carrier.obj3d.parent === crane fork
14
+ * 3. fork.position 변경 시 carrier.world 가 fork.world 따라감
15
+ * 4. receiveAt 후 carrier 완전 detach + record push
16
+ * 5. ghost 검증 — 반복 pickAndPlace 후 anchor / fork 모두 깨끗
17
+ */
18
+
19
+ import 'should'
20
+ import * as THREE from 'three'
21
+
22
+ // ── Carrier 모사 (Parcel3D 식 Group + mesh) ────────────────────────────────
23
+
24
+ class RealCarrier {
25
+ state: any
26
+ parent: any = null
27
+ _realObject: { object3d: THREE.Object3D; placement?: any }
28
+ _disposed = false
29
+
30
+ constructor(state: any) {
31
+ this.state = state
32
+ const group = new THREE.Group()
33
+ group.name = `carrier-${state.cellId ?? '?'}-group`
34
+ const bodyMesh = new THREE.Mesh(
35
+ new THREE.BoxGeometry(50, 50, 50),
36
+ new THREE.MeshBasicMaterial({ color: 0xc8a878 })
37
+ )
38
+ bodyMesh.name = 'carrier-body'
39
+ group.add(bodyMesh)
40
+ const self = this
41
+ this._realObject = {
42
+ object3d: group,
43
+ // setTransientPlacement 모사 — 실제 RealObject 와 동등
44
+ setTransientPlacement(p: any) { self._realObject.placement = p }
45
+ } as any
46
+ }
47
+
48
+ applyHolderAttachPoint(): void {
49
+ const p: any = this.parent
50
+ if (!p?.attachPointFor) return
51
+ const point = p.attachPointFor(this)
52
+ if (!point?.attach) return
53
+ point.attach.attach(this._realObject.object3d)
54
+ if (point.localPosition) {
55
+ this._realObject.object3d.position.set(
56
+ point.localPosition.x, point.localPosition.y, point.localPosition.z
57
+ )
58
+ }
59
+ }
60
+
61
+ dispose(): void {
62
+ this._disposed = true
63
+ for (const child of [...this._realObject.object3d.children]) {
64
+ const mesh = child as THREE.Mesh
65
+ mesh.geometry?.dispose?.()
66
+ const mats = Array.isArray(mesh.material) ? mesh.material : (mesh.material ? [mesh.material] : [])
67
+ for (const m of mats) m?.dispose?.()
68
+ }
69
+ this._realObject.object3d.clear()
70
+ }
71
+ }
72
+
73
+ // ── RackGrid 모사 — *현재 RackGrid.obtainCarrier 의 실제 코드 흐름* 그대로 ──
74
+
75
+ class RackGridMock {
76
+ state: any = { data: [] }
77
+ components: any[] = []
78
+ rootObject3d = new THREE.Group()
79
+ _attachAnchorBySlot = new Map<string, THREE.Object3D>()
80
+
81
+ constructor() { this.rootObject3d.name = 'rack-grid-root' }
82
+
83
+ // RackGrid.getSlotAttachObject3d 본문 모사 (위치 setting 제외 — anchor 자체만)
84
+ getSlotAttachObject3d(cellId: string): THREE.Object3D {
85
+ let obj = this._attachAnchorBySlot.get(cellId)
86
+ if (!obj) {
87
+ obj = new THREE.Object3D()
88
+ obj.name = `rack-grid-anchor:${cellId}`
89
+ this.rootObject3d.add(obj)
90
+ this._attachAnchorBySlot.set(cellId, obj)
91
+ }
92
+ return obj
93
+ }
94
+
95
+ // RackGrid.attachPointFor (mixin override)
96
+ attachPointFor(carrier: any) {
97
+ const cellId = carrier.state?.cellId
98
+ if (cellId) {
99
+ const obj = this.getSlotAttachObject3d(cellId)
100
+ if (obj) return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } }
101
+ }
102
+ return { attach: this.rootObject3d, localPosition: { x: 0, y: 0, z: 0 } }
103
+ }
104
+
105
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
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
+ // RackGrid.obtainCarrier 의 *현 코드* 그대로 모사 (manual setTransientPlacement
113
+ // + manual anchor.add 포함)
114
+ obtainCarrier(cellId: string): RealCarrier | null {
115
+ const i = this.state.data.findIndex((r: any) => r.cellId === cellId)
116
+ if (i === -1) return null
117
+ const record = this.state.data[i]
118
+ const c = new RealCarrier({ ...record, cellId })
119
+ this.addComponent(c)
120
+ c.applyHolderAttachPoint()
121
+
122
+ // 현재 코드의 manual block
123
+ ;(c._realObject as any).setTransientPlacement?.({ policy: 'carried' })
124
+ const anchor = this.getSlotAttachObject3d(cellId)
125
+ const carrierObj3d = c._realObject.object3d
126
+ if (anchor && carrierObj3d) {
127
+ anchor.add(carrierObj3d)
128
+ carrierObj3d.position.set(0, 0, 0)
129
+ carrierObj3d.updateMatrixWorld(true)
130
+ }
131
+
132
+ this.state.data = this.state.data.filter((_: any, j: number) => j !== i)
133
+ return c
134
+ }
135
+
136
+ async receiveAt(cellId: string, carrier: RealCarrier): Promise<void> {
137
+ const p = carrier.parent
138
+ if (p?.removeComponent) p.removeComponent(carrier)
139
+ const obj = carrier._realObject?.object3d
140
+ if (obj?.parent?.remove) obj.parent.remove(obj)
141
+ carrier.dispose()
142
+ const rec: any = { cellId, type: carrier.state.type ?? 'parcel' }
143
+ for (const k of Object.keys(carrier.state)) {
144
+ if (['id', 'left', 'top', 'zPos', 'cellId'].includes(k)) continue
145
+ rec[k] = carrier.state[k]
146
+ }
147
+ this.state.data.push(rec)
148
+ }
149
+ }
150
+
151
+ // ── Crane 모사 — trolley → lift → fork 의 nested group 구조 ────────────────
152
+ // crane-3d.ts 의 getCarriageFrame 의 fallback chain (_forkGroup → _carriageLiftGroup
153
+ // → _trolleyGroup → root) 의 *_forkGroup* 사용 가정.
154
+
155
+ class CraneMock {
156
+ components: any[] = []
157
+ rootObject3d = new THREE.Group()
158
+ trolleyGroup = new THREE.Group()
159
+ carriageLiftGroup = new THREE.Group()
160
+ forkGroup = new THREE.Group()
161
+
162
+ constructor() {
163
+ this.rootObject3d.name = 'crane-root'
164
+ this.trolleyGroup.name = 'trolley'
165
+ this.carriageLiftGroup.name = 'lift'
166
+ this.forkGroup.name = 'fork'
167
+ this.rootObject3d.add(this.trolleyGroup)
168
+ this.trolleyGroup.add(this.carriageLiftGroup)
169
+ this.carriageLiftGroup.add(this.forkGroup)
170
+ }
171
+
172
+ // crane.attachPointFor 본문 모사 — getCarriageFrame 의 _forkGroup
173
+ attachPointFor(_carrier: any) {
174
+ return {
175
+ attach: this.forkGroup,
176
+ localPosition: { x: 0, y: 0, z: 0 },
177
+ carryPolicy: 'follow-holder'
178
+ }
179
+ }
180
+
181
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
182
+ removeComponent(c: any): void {
183
+ const i = this.components.indexOf(c)
184
+ if (i >= 0) this.components.splice(i, 1)
185
+ c.parent = null
186
+ }
187
+
188
+ // ContainerCapacity.receive + CarrierHolder.reparent 시뮬 (단순화)
189
+ receive(carrier: any): void {
190
+ const op = carrier.parent
191
+ if (op?.removeComponent) op.removeComponent(carrier)
192
+ this.addComponent(carrier)
193
+ const point = this.attachPointFor(carrier)
194
+ point.attach.attach(carrier._realObject.object3d)
195
+ carrier._realObject.object3d.position.set(0, 0, 0)
196
+ // CarrierHolder.reparent 의 post-step: setTransientPlacement('carried')
197
+ ;(carrier._realObject as any).setTransientPlacement?.({
198
+ policy: 'carried',
199
+ meta: { carryPolicy: 'follow-holder' }
200
+ })
201
+ }
202
+ }
203
+
204
+ // ──────────────────────────────────────────────────────────────────────────
205
+
206
+ describe('RackGrid 3D scene graph: carrier parent transitions', () => {
207
+ it('obtainCarrier 후 carrier.obj3d.parent.name === rack-grid-anchor:A-0-1', () => {
208
+ const rack = new RackGridMock()
209
+ rack.state.data = [{ cellId: 'A-0-1' }]
210
+ const c = rack.obtainCarrier('A-0-1')!
211
+ c.should.not.be.null()
212
+ c._realObject.object3d.parent!.name.should.equal('rack-grid-anchor:A-0-1')
213
+ })
214
+
215
+ it('obtainCarrier 후 carrier 의 placement 가 carried (manual)', () => {
216
+ const rack = new RackGridMock()
217
+ rack.state.data = [{ cellId: 'A-0-1' }]
218
+ const c = rack.obtainCarrier('A-0-1')!
219
+ ;(c._realObject as any).placement?.policy?.should.equal('carried')
220
+ })
221
+
222
+ it('crane.receive 후 carrier.obj3d.parent === fork', () => {
223
+ const rack = new RackGridMock()
224
+ rack.state.data = [{ cellId: 'A-0-1' }]
225
+ const crane = new CraneMock()
226
+ const c = rack.obtainCarrier('A-0-1')!
227
+ crane.receive(c)
228
+ c._realObject.object3d.parent!.name.should.equal('fork')
229
+ })
230
+
231
+ it('crane.receive 후 carrier.parent (logical) === crane', () => {
232
+ const rack = new RackGridMock()
233
+ rack.state.data = [{ cellId: 'A-0-1' }]
234
+ const crane = new CraneMock()
235
+ const c = rack.obtainCarrier('A-0-1')!
236
+ crane.receive(c)
237
+ c.parent.should.equal(crane)
238
+ })
239
+
240
+ it('receiveAt 후 carrier.obj3d.parent === null', async () => {
241
+ const rack = new RackGridMock()
242
+ rack.state.data = [{ cellId: 'A-0-1' }]
243
+ const crane = new CraneMock()
244
+ const c = rack.obtainCarrier('A-0-1')!
245
+ crane.receive(c)
246
+ await rack.receiveAt('B-0-2', c)
247
+ ;(c._realObject.object3d.parent === null).should.be.true()
248
+ })
249
+ })
250
+
251
+ // **목표 검증** — fork 이동 시 carrier 가 따라가는지. 사용자 보고 *carrier 안
252
+ // 따라옴 / fork 빈 포크짓* 의 회귀 방지 핵심 assertion.
253
+
254
+ describe('RackGrid handoff follow: fork 이동 시 carrier world 따라감', () => {
255
+ it('fork 이동만 → carrier world = fork world', () => {
256
+ const rack = new RackGridMock()
257
+ rack.state.data = [{ cellId: 'A-0-1' }]
258
+ const crane = new CraneMock()
259
+ const c = rack.obtainCarrier('A-0-1')!
260
+ crane.receive(c)
261
+
262
+ crane.forkGroup.position.set(100, 200, 50)
263
+ crane.rootObject3d.updateMatrixWorld(true)
264
+
265
+ const carrierWorld = new THREE.Vector3()
266
+ c._realObject.object3d.getWorldPosition(carrierWorld)
267
+ const forkWorld = new THREE.Vector3()
268
+ crane.forkGroup.getWorldPosition(forkWorld)
269
+
270
+ carrierWorld.x.should.be.approximately(forkWorld.x, 1e-9)
271
+ carrierWorld.y.should.be.approximately(forkWorld.y, 1e-9)
272
+ carrierWorld.z.should.be.approximately(forkWorld.z, 1e-9)
273
+ })
274
+
275
+ it('lift + trolley + fork extension 복합 — carrier 가 모두 따라감', () => {
276
+ const rack = new RackGridMock()
277
+ rack.state.data = [{ cellId: 'A-0-1' }]
278
+ const crane = new CraneMock()
279
+ const c = rack.obtainCarrier('A-0-1')!
280
+ crane.receive(c)
281
+
282
+ // trolley (X 이동) + lift (Y 이동) + fork (Z extension) 시뮬
283
+ crane.trolleyGroup.position.set(500, 0, 0)
284
+ crane.carriageLiftGroup.position.set(0, 800, 0)
285
+ crane.forkGroup.position.set(0, 0, 300)
286
+ crane.rootObject3d.updateMatrixWorld(true)
287
+
288
+ const carrierWorld = new THREE.Vector3()
289
+ c._realObject.object3d.getWorldPosition(carrierWorld)
290
+ const forkWorld = new THREE.Vector3()
291
+ crane.forkGroup.getWorldPosition(forkWorld)
292
+
293
+ carrierWorld.x.should.be.approximately(forkWorld.x, 1e-9, 'X (trolley + fork) 따라감')
294
+ carrierWorld.y.should.be.approximately(forkWorld.y, 1e-9, 'Y (lift) 따라감')
295
+ carrierWorld.z.should.be.approximately(forkWorld.z, 1e-9, 'Z (fork extension) 따라감')
296
+ })
297
+
298
+ it('연속 fork.position 변경 — carrier 가 매 step 따라감', () => {
299
+ const rack = new RackGridMock()
300
+ rack.state.data = [{ cellId: 'A-0-1' }]
301
+ const crane = new CraneMock()
302
+ const c = rack.obtainCarrier('A-0-1')!
303
+ crane.receive(c)
304
+
305
+ const positions = [[0,0,0], [100,0,0], [100,200,0], [100,200,300], [50,150,250]]
306
+ for (const [x, y, z] of positions) {
307
+ crane.forkGroup.position.set(x, y, z)
308
+ crane.rootObject3d.updateMatrixWorld(true)
309
+
310
+ const cw = new THREE.Vector3()
311
+ c._realObject.object3d.getWorldPosition(cw)
312
+ cw.x.should.be.approximately(x, 1e-9, `step (${x},${y},${z}) X`)
313
+ cw.y.should.be.approximately(y, 1e-9, `step (${x},${y},${z}) Y`)
314
+ cw.z.should.be.approximately(z, 1e-9, `step (${x},${y},${z}) Z`)
315
+ }
316
+ })
317
+ })
318
+
319
+ // 회귀 방지 — ghost / parent 누락 검증.
320
+
321
+ describe('RackGrid 회귀 방지: ghost 없음', () => {
322
+ it('receiveAt 후 fork.children 비어있음', async () => {
323
+ const rack = new RackGridMock()
324
+ rack.state.data = [{ cellId: 'A' }]
325
+ const crane = new CraneMock()
326
+ const c = rack.obtainCarrier('A')!
327
+ crane.receive(c)
328
+ crane.forkGroup.children.length.should.equal(1)
329
+ await rack.receiveAt('B', c)
330
+ crane.forkGroup.children.length.should.equal(0)
331
+ })
332
+
333
+ it('pick 후 source anchor.children 비어있음', () => {
334
+ const rack = new RackGridMock()
335
+ rack.state.data = [{ cellId: 'A-0-1' }]
336
+ const crane = new CraneMock()
337
+ const sourceAnchor = rack.getSlotAttachObject3d('A-0-1')
338
+
339
+ const c = rack.obtainCarrier('A-0-1')!
340
+ sourceAnchor.children.length.should.equal(1)
341
+
342
+ crane.receive(c)
343
+ sourceAnchor.children.length.should.equal(0)
344
+ })
345
+
346
+ it('A→B 10번 왕복 — 매 사이클 ghost 0', async () => {
347
+ const rack = new RackGridMock()
348
+ rack.state.data = [{ cellId: 'A', sku: 'X' }]
349
+ const crane = new CraneMock()
350
+ const anchorA = rack.getSlotAttachObject3d('A')
351
+ const anchorB = rack.getSlotAttachObject3d('B')
352
+
353
+ for (let i = 0; i < 10; i++) {
354
+ const cAB = rack.obtainCarrier('A')!
355
+ cAB.should.not.be.null()
356
+ crane.receive(cAB)
357
+ await rack.receiveAt('B', cAB)
358
+ crane.forkGroup.children.length.should.equal(0)
359
+ anchorA.children.length.should.equal(0)
360
+ anchorB.children.length.should.equal(0)
361
+
362
+ const cBA = rack.obtainCarrier('B')!
363
+ cBA.should.not.be.null()
364
+ crane.receive(cBA)
365
+ await rack.receiveAt('A', cBA)
366
+ crane.forkGroup.children.length.should.equal(0)
367
+ anchorA.children.length.should.equal(0)
368
+ anchorB.children.length.should.equal(0)
369
+ }
370
+
371
+ rack.state.data.length.should.equal(1)
372
+ rack.state.data[0].cellId.should.equal('A')
373
+ rack.state.data[0].sku.should.equal('X')
374
+ })
375
+ })
@@ -93,7 +93,7 @@ class MiniGridWithCells {
93
93
  return this.cellAt(parts[0], parts[1])
94
94
  }
95
95
 
96
- parseCellId(cellId: string) {
96
+ parseSlotId(cellId: string) {
97
97
  const parts = cellId.split('-').map(Number)
98
98
  if (parts.length !== 3 || parts.some(n => !Number.isFinite(n))) return null
99
99
  return { col: parts[0], row: parts[1], shelf: parts[2] }
@@ -118,7 +118,7 @@ class MiniGridWithCells {
118
118
  }
119
119
 
120
120
  locationOf(cellId: string): string | null {
121
- const parsed = this.parseCellId(cellId)
121
+ const parsed = this.parseSlotId(cellId)
122
122
  if (!parsed) return null
123
123
  const posKey = `${parsed.col}-${parsed.row}`
124
124
 
@@ -73,7 +73,7 @@ class MiniGrid {
73
73
  return out
74
74
  }
75
75
 
76
- parseCellId(cellId: string): { col: number; row: number; shelf: number } | null {
76
+ parseSlotId(cellId: string): { col: number; row: number; shelf: number } | null {
77
77
  const parts = cellId.split('-')
78
78
  if (parts.length !== 3) return null
79
79
  const [c, r, s] = parts.map(Number)
@@ -90,7 +90,7 @@ class MiniGrid {
90
90
  }
91
91
 
92
92
  locationOf(cellId: string): string | null {
93
- const parsed = this.parseCellId(cellId)
93
+ const parsed = this.parseSlotId(cellId)
94
94
  if (!parsed) return null
95
95
  const posKey = `${parsed.col}-${parsed.row}`
96
96
  const override = this.cellOverrides[posKey]
@@ -0,0 +1,165 @@
1
+ /*
2
+ * RackGrid.occupiedSlotIds — 셀 리스트 정보의 정확성 검증.
3
+ *
4
+ * 버그 시나리오 (이전): RackGridCell (rack-grid-cell type 의 시각 proxy 자식) 의
5
+ * state.cellId 가 bayKey ("col-row") 형식. carrier 의 state.cellId 는 full slot
6
+ * id ("col-row-shelf") 형식. occupiedSlotIds 가 모든 children sweep 시 bayKey
7
+ * 도 set 에 추가 → semantic 불일치 + crane simulate 의 reach filter 통과 안 됨.
8
+ *
9
+ * fix: occupiedSlotIds 가 *carriable child* 만 sweep — RackGridCell 의 bayKey
10
+ * 는 set 에 안 들어감. record + carrier 만.
11
+ */
12
+
13
+ import 'should'
14
+
15
+ // Mock RackGrid 의 occupiedSlotIds 본문 로직만 격리해 검증.
16
+ // (real RackGrid 인스턴스 build 는 things-scene env 의존 — test 환경 부담.)
17
+
18
+ function mockOccupiedSlotIds(opts: {
19
+ records: Array<{ cellId?: string }>
20
+ components: Array<{ isCarriable?: boolean; state?: { cellId?: string } }>
21
+ filter?: (id: string) => boolean
22
+ }): string[] {
23
+ const { records, components, filter } = opts
24
+ const set = new Set<string>()
25
+ for (const r of records) {
26
+ const cid = r?.cellId
27
+ if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
28
+ }
29
+ for (const c of components) {
30
+ if (!c?.isCarriable) continue
31
+ const cid = c?.state?.cellId
32
+ if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
33
+ }
34
+ return Array.from(set)
35
+ }
36
+
37
+ describe('RackGrid.occupiedSlotIds — carriable 만, RackGridCell bayKey 제외', () => {
38
+ it('records 의 cellId 가 모두 포함', () => {
39
+ const ids = mockOccupiedSlotIds({
40
+ records: [
41
+ { cellId: '0-0-0' },
42
+ { cellId: '0-0-1' },
43
+ { cellId: '2-1-3' }
44
+ ],
45
+ components: []
46
+ })
47
+ ids.length.should.equal(3)
48
+ ids.should.containEql('0-0-0')
49
+ ids.should.containEql('0-0-1')
50
+ ids.should.containEql('2-1-3')
51
+ })
52
+
53
+ it('carriable child 의 cellId 가 포함', () => {
54
+ const ids = mockOccupiedSlotIds({
55
+ records: [],
56
+ components: [
57
+ { isCarriable: true, state: { cellId: '1-0-2' } },
58
+ { isCarriable: true, state: { cellId: '3-1-5' } }
59
+ ]
60
+ })
61
+ ids.length.should.equal(2)
62
+ ids.should.containEql('1-0-2')
63
+ ids.should.containEql('3-1-5')
64
+ })
65
+
66
+ it('RackGridCell (= isCarriable: false) 의 bayKey 는 *제외*', () => {
67
+ const ids = mockOccupiedSlotIds({
68
+ records: [{ cellId: '0-0-0' }],
69
+ components: [
70
+ // RackGridCell — 시각 proxy. state.cellId = bayKey "col-row" 형식.
71
+ { isCarriable: false, state: { cellId: '0-0' } },
72
+ { isCarriable: false, state: { cellId: '1-0' } },
73
+ { isCarriable: false, state: { cellId: '2-0' } }
74
+ ]
75
+ })
76
+ ids.length.should.equal(1) // record 1 개만
77
+ ids.should.containEql('0-0-0')
78
+ // bayKey 절대 포함 X
79
+ ids.should.not.containEql('0-0')
80
+ ids.should.not.containEql('1-0')
81
+ ids.should.not.containEql('2-0')
82
+ })
83
+
84
+ it('record + carrier + RackGridCell 혼합 — carrier 의 full slotId 만, bayKey 제외', () => {
85
+ const ids = mockOccupiedSlotIds({
86
+ records: [
87
+ { cellId: '0-0-0' }
88
+ ],
89
+ components: [
90
+ { isCarriable: false, state: { cellId: '1-0' } }, // RackGridCell
91
+ { isCarriable: true, state: { cellId: '1-0-2' } }, // 실 carrier
92
+ { isCarriable: false, state: { cellId: '2-0' } } // RackGridCell
93
+ ]
94
+ })
95
+ ids.length.should.equal(2)
96
+ ids.should.containEql('0-0-0')
97
+ ids.should.containEql('1-0-2')
98
+ ids.should.not.containEql('1-0')
99
+ ids.should.not.containEql('2-0')
100
+ })
101
+
102
+ it('filter predicate — adjSet 매칭 안 의 id 만', () => {
103
+ const adjSet = new Set(['0-0-0', '1-0-0'])
104
+ const ids = mockOccupiedSlotIds({
105
+ records: [
106
+ { cellId: '0-0-0' }, // adjSet 안 — pass
107
+ { cellId: '0-0-1' }, // adjSet 밖 — reject
108
+ { cellId: '1-0-0' } // adjSet 안 — pass
109
+ ],
110
+ components: [],
111
+ filter: id => adjSet.has(id)
112
+ })
113
+ ids.length.should.equal(2)
114
+ ids.should.containEql('0-0-0')
115
+ ids.should.containEql('1-0-0')
116
+ ids.should.not.containEql('0-0-1')
117
+ })
118
+
119
+ it('동일 cellId 가 record + carrier 둘 다 있을 경우 — set dedupe', () => {
120
+ const ids = mockOccupiedSlotIds({
121
+ records: [{ cellId: '0-0-0' }],
122
+ components: [
123
+ { isCarriable: true, state: { cellId: '0-0-0' } }
124
+ ]
125
+ })
126
+ ids.length.should.equal(1)
127
+ ids.should.containEql('0-0-0')
128
+ })
129
+ })
130
+
131
+ describe('emptySlotIds — slotIds - occupiedSlotIds, RackGridCell bayKey 영향 X', () => {
132
+ function mockEmptySlotIds(opts: {
133
+ allSlotIds: string[]
134
+ records: Array<{ cellId?: string }>
135
+ components: Array<{ isCarriable?: boolean; state?: { cellId?: string } }>
136
+ }): string[] {
137
+ const occ = new Set(mockOccupiedSlotIds(opts))
138
+ return opts.allSlotIds.filter(id => !occ.has(id))
139
+ }
140
+
141
+ it('record 점유 외 모두 빈 slot', () => {
142
+ const all = ['0-0-0', '0-0-1', '1-0-0', '1-0-1']
143
+ const empty = mockEmptySlotIds({
144
+ allSlotIds: all,
145
+ records: [{ cellId: '0-0-0' }],
146
+ components: []
147
+ })
148
+ empty.length.should.equal(3)
149
+ empty.should.not.containEql('0-0-0')
150
+ })
151
+
152
+ it('RackGridCell bayKey 가 *_empty 결과에 *_노이즈* 안 함', () => {
153
+ const all = ['0-0-0', '0-0-1', '1-0-0', '1-0-1']
154
+ const empty = mockEmptySlotIds({
155
+ allSlotIds: all,
156
+ records: [],
157
+ components: [
158
+ // bayKey 가 occupiedSlotIds 에 들어가지 않으므로 empty 결과 영향 X
159
+ { isCarriable: false, state: { cellId: '0-0' } },
160
+ { isCarriable: false, state: { cellId: '1-0' } }
161
+ ]
162
+ })
163
+ empty.length.should.equal(4) // 모든 slot 이 empty (carrier 0)
164
+ })
165
+ })