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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/MIGRATION-plan-a-slot-api.md +266 -0
  3. package/PLAN-A-rack-as-slot-holder.md +164 -0
  4. package/dist/crane.js +1 -1
  5. package/dist/crane.js.map +1 -1
  6. package/dist/index.d.ts +3 -4
  7. package/dist/index.js +1 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/parcel-3d.js +42 -9
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.d.ts +18 -7
  12. package/dist/rack-grid-3d.js +372 -69
  13. package/dist/rack-grid-3d.js.map +1 -1
  14. package/dist/rack-grid-cell.d.ts +21 -72
  15. package/dist/rack-grid-cell.js +147 -243
  16. package/dist/rack-grid-cell.js.map +1 -1
  17. package/dist/rack-grid.d.ts +277 -56
  18. package/dist/rack-grid.js +1230 -695
  19. package/dist/rack-grid.js.map +1 -1
  20. package/dist/rack-materials.d.ts +9 -0
  21. package/dist/rack-materials.js +55 -0
  22. package/dist/rack-materials.js.map +1 -0
  23. package/dist/storage-rack-3d.d.ts +15 -0
  24. package/dist/storage-rack-3d.js +131 -30
  25. package/dist/storage-rack-3d.js.map +1 -1
  26. package/dist/storage-rack.d.ts +242 -45
  27. package/dist/storage-rack.js +684 -106
  28. package/dist/storage-rack.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/crane.ts +1 -1
  31. package/src/index.ts +3 -4
  32. package/src/parcel-3d.ts +41 -9
  33. package/src/rack-grid-3d.ts +383 -80
  34. package/src/rack-grid-cell.ts +161 -305
  35. package/src/rack-grid.ts +1263 -762
  36. package/src/rack-materials.ts +61 -0
  37. package/src/storage-rack-3d.ts +144 -30
  38. package/src/storage-rack.ts +763 -111
  39. package/test/test-carrier-lifecycle.ts +361 -0
  40. package/test/test-coord-alignment.ts +201 -0
  41. package/test/test-external-to-rack.ts +461 -0
  42. package/test/test-mover-concurrent-bug.ts +304 -0
  43. package/test/test-mover-rollback.ts +290 -0
  44. package/test/test-r19-place-absorb.ts +174 -0
  45. package/test/test-rack-3d-attach-real.ts +301 -0
  46. package/test/test-rack-concurrent.ts +254 -0
  47. package/test/test-rack-edge-cases.ts +323 -0
  48. package/test/test-rack-grid-cell.ts +318 -0
  49. package/test/test-rack-grid-location.ts +657 -0
  50. package/test/test-real-3d-positioning.ts +158 -0
  51. package/test/test-slot-center-convention.ts +116 -0
  52. package/test/test-slot-target.ts +189 -0
  53. package/test/test-storage-rack-batched.ts +606 -0
  54. package/test/test-storage-rack-click.ts +329 -0
  55. package/test/test-storage-rack-slot-api.ts +357 -0
  56. package/test/test-toscene-convention.ts +162 -0
  57. package/test/test-user-scenario-sequential.ts +334 -0
  58. package/translations/en.json +2 -0
  59. package/translations/ja.json +2 -0
  60. package/translations/ko.json +2 -0
  61. package/translations/ms.json +2 -0
  62. package/translations/zh.json +2 -0
  63. package/tsconfig.tsbuildinfo +1 -1
  64. package/dist/rack-column.d.ts +0 -35
  65. package/dist/rack-column.js +0 -258
  66. package/dist/rack-column.js.map +0 -1
  67. package/dist/rack-grid-helpers.d.ts +0 -28
  68. package/dist/rack-grid-helpers.js +0 -71
  69. package/dist/rack-grid-helpers.js.map +0 -1
  70. package/dist/rack-grid-location.d.ts +0 -37
  71. package/dist/rack-grid-location.js +0 -227
  72. package/dist/rack-grid-location.js.map +0 -1
  73. package/dist/storage-cell-3d.d.ts +0 -25
  74. package/dist/storage-cell-3d.js +0 -88
  75. package/dist/storage-cell-3d.js.map +0 -1
  76. package/dist/storage-cell.d.ts +0 -73
  77. package/dist/storage-cell.js +0 -215
  78. package/dist/storage-cell.js.map +0 -1
  79. package/src/rack-column.ts +0 -340
  80. package/src/rack-grid-helpers.ts +0 -77
  81. package/src/rack-grid-location.ts +0 -286
  82. package/src/storage-cell-3d.ts +0 -101
  83. package/src/storage-cell.ts +0 -267
  84. package/test/test-cell-position.ts +0 -105
  85. package/test/test-rack-grid.ts +0 -77
@@ -0,0 +1,329 @@
1
+ /*
2
+ * Click routing — InstancedMesh-based + empty-cell fallback.
3
+ *
4
+ * 검증 대상:
5
+ * - _raycastRackHit: closest hit 이 *우리 rack* descendant 인지 walk-up 으로 판별
6
+ * - stockMesh hit → records[instanceId] 로 record 추출
7
+ * - shelf/frame hit → world point 로 cellId 역산
8
+ * - 다른 rack 또는 무관 mesh 가 더 가까우면 무시
9
+ *
10
+ * 실제 Three.js raycaster 와 InstancedMesh 를 *진짜* 만들어서 검증한다.
11
+ */
12
+
13
+ import 'should'
14
+ import * as THREE from 'three'
15
+
16
+ // ── 클래스 contract 검증 fixture ────────────────────────────────────────────────
17
+ //
18
+ // 실제 Rack 클래스는 things-scene Component / RealObject / 3D 파이프라인을 요구해 단위
19
+ // 테스트에서 인스턴스화 비현실적. 이 테스트는 *click routing* 의 정확한 로직 (closest hit
20
+ // 판단 + records 매핑) 을 isolate 해 검증.
21
+
22
+ function buildStockInstancedMesh(records: any[]): THREE.InstancedMesh {
23
+ const geo = new THREE.BoxGeometry(10, 10, 10)
24
+ const mat = new THREE.MeshBasicMaterial()
25
+ const inst = new THREE.InstancedMesh(geo, mat, records.length)
26
+ inst.userData.context = { tag: 'storage-rack-3d' }
27
+ inst.userData._records = records
28
+
29
+ const m = new THREE.Matrix4()
30
+ for (let i = 0; i < records.length; i++) {
31
+ // 각 instance 를 (i * 20, 0, 0) 에 배치 (간격 20)
32
+ m.makeTranslation(i * 20, 0, 0)
33
+ inst.setMatrixAt(i, m)
34
+ }
35
+ inst.instanceMatrix.needsUpdate = true
36
+ inst.computeBoundingSphere()
37
+ inst.computeBoundingBox()
38
+ return inst
39
+ }
40
+
41
+ /**
42
+ * Rack._raycastRackHit 의 *순수 로직* 부분만 추출해 검증한다.
43
+ * (실 구현은 `tc._threeCapability.getObjectsByRaycast()` 호출 + walk-up check + return)
44
+ */
45
+ function raycastRackHit(
46
+ intersects: THREE.Intersection[] | undefined,
47
+ ourRealObject: any
48
+ ): THREE.Intersection | undefined {
49
+ if (!intersects || intersects.length === 0) return undefined
50
+ const closest = intersects[0]
51
+ let obj: THREE.Object3D | null = closest.object
52
+ while (obj) {
53
+ if (obj.userData?.context === ourRealObject) return closest
54
+ obj = obj.parent
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ /** Rack 의 stockMesh hit 인지 (= isStock 분기) */
60
+ function isStockHit(
61
+ hit: THREE.Intersection | undefined,
62
+ stockMesh: THREE.InstancedMesh | undefined
63
+ ): boolean {
64
+ return !!hit && !!stockMesh && hit.object === stockMesh
65
+ }
66
+
67
+ /** world point → cellId 역산 (rack matrixWorld = identity 가정) */
68
+ function cellIdFromWorldPoint(
69
+ worldPoint: { x: number; y: number },
70
+ state: { bays: number; levels: number; width: number; depth: number; shelfBaseHeight?: number }
71
+ ): string | null {
72
+ const bays = state.bays
73
+ const levels = state.levels
74
+ const width = state.width
75
+ const depth = state.depth
76
+ const shelfBase = state.shelfBaseHeight || 0
77
+ const shelfZone = depth - shelfBase
78
+ const bayWidth = width / bays
79
+ const levelHeight = shelfZone / levels
80
+ const bayIdx = Math.floor((worldPoint.x + width / 2) / bayWidth)
81
+ const yFromBottom = worldPoint.y + depth / 2 - shelfBase
82
+ const levelIdx = Math.floor(yFromBottom / levelHeight)
83
+ if (bayIdx < 0 || bayIdx >= bays || levelIdx < 0 || levelIdx >= levels) {
84
+ return `out-of-bounds(bay=${bayIdx}, level=${levelIdx})`
85
+ }
86
+ return `${bayIdx}-0-${levelIdx}`
87
+ }
88
+
89
+ function resolveRecord(hit: THREE.Intersection | undefined): any | undefined {
90
+ if (!hit) return undefined
91
+ const inst: any = hit.object
92
+ const records: any[] | undefined = inst?.userData?._records
93
+ return records?.[hit.instanceId ?? -1]
94
+ }
95
+
96
+ // ── Group 1: 실 Three.js raycaster 로 InstancedMesh hit-test 검증 ────────────
97
+
98
+ describe('Click: real Three.js InstancedMesh raycast', () => {
99
+ const records = [
100
+ { cellId: '0-0-0', sku: 'A', qty: 1 },
101
+ { cellId: '1-0-0', sku: 'B', qty: 2 },
102
+ { cellId: '2-0-0', sku: 'C', qty: 3 }
103
+ ]
104
+ const stockMesh = buildStockInstancedMesh(records)
105
+
106
+ it('instanceId 와 record 의 순서가 동일하다', () => {
107
+ ;(stockMesh.userData._records as any[]).length.should.equal(3)
108
+ ;(stockMesh.userData._records as any[])[1].cellId.should.equal('1-0-0')
109
+ })
110
+
111
+ it('instance 0 을 직접 겨냥한 ray → instanceId 0 hit', () => {
112
+ const raycaster = new THREE.Raycaster()
113
+ // instance 0 은 (0,0,0). z=+50 에서 -Z 방향으로 쏘기.
114
+ raycaster.set(new THREE.Vector3(0, 0, 50), new THREE.Vector3(0, 0, -1))
115
+ const hits: THREE.Intersection[] = []
116
+ stockMesh.raycast(raycaster, hits)
117
+ hits.length.should.be.greaterThanOrEqual(1)
118
+ hits[0].instanceId!.should.equal(0)
119
+ })
120
+
121
+ it('instance 2 (x=40) 을 겨냥한 ray → instanceId 2 hit', () => {
122
+ const raycaster = new THREE.Raycaster()
123
+ raycaster.set(new THREE.Vector3(40, 0, 50), new THREE.Vector3(0, 0, -1))
124
+ const hits: THREE.Intersection[] = []
125
+ stockMesh.raycast(raycaster, hits)
126
+ hits.length.should.be.greaterThanOrEqual(1)
127
+ hits[0].instanceId!.should.equal(2)
128
+ })
129
+
130
+ it('hit 으로부터 record 역참조', () => {
131
+ const raycaster = new THREE.Raycaster()
132
+ raycaster.set(new THREE.Vector3(20, 0, 50), new THREE.Vector3(0, 0, -1))
133
+ const hits: THREE.Intersection[] = []
134
+ stockMesh.raycast(raycaster, hits)
135
+ const rec = resolveRecord(hits[0])
136
+ rec.should.not.be.null()
137
+ rec.cellId.should.equal('1-0-0')
138
+ rec.sku.should.equal('B')
139
+ })
140
+
141
+ it('raycast 가 빗나가면 hits 비어있음', () => {
142
+ const raycaster = new THREE.Raycaster()
143
+ // 멀리 y=+1000 에서 -Y 로 쏘면 stockMesh (y=0 plane) 와 평행하지 않으나, x=1000 빗나감.
144
+ raycaster.set(new THREE.Vector3(1000, 0, 50), new THREE.Vector3(0, 0, -1))
145
+ const hits: THREE.Intersection[] = []
146
+ stockMesh.raycast(raycaster, hits)
147
+ hits.length.should.equal(0)
148
+ })
149
+ })
150
+
151
+ // ── Group 2: rack walk-up — 우리 rack 인지 판별 ──────────────────────────────
152
+
153
+ describe('Click: rack walk-up identifies "our rack"', () => {
154
+ const ourRealObject = { tag: 'our-rack' }
155
+ const otherRealObject = { tag: 'other-rack' }
156
+
157
+ // Set up: rack 의 root 와 자식 mesh들 — userData.context 가 우리 realObject
158
+ const ourRoot = new THREE.Group()
159
+ ourRoot.userData.context = ourRealObject
160
+ const ourFrameMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial())
161
+ ourRoot.add(ourFrameMesh) // frame 은 자체 context 없음 → walk-up 으로 찾음
162
+
163
+ const ourStockMesh = buildStockInstancedMesh([{ cellId: 'A', sku: 'a' }])
164
+ ourStockMesh.userData.context = ourRealObject // override fixture default
165
+ ourRoot.add(ourStockMesh)
166
+
167
+ // Another rack — sibling
168
+ const otherRoot = new THREE.Group()
169
+ otherRoot.userData.context = otherRealObject
170
+ const otherMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial())
171
+ otherRoot.add(otherMesh)
172
+
173
+ it('우리 rack 의 stock mesh hit → 반환', () => {
174
+ const intersects: THREE.Intersection[] = [
175
+ { object: ourStockMesh, instanceId: 0, distance: 30, point: new THREE.Vector3() } as any
176
+ ]
177
+ const hit = raycastRackHit(intersects, ourRealObject)
178
+ ;(hit === undefined).should.be.false()
179
+ hit!.object.should.equal(ourStockMesh)
180
+ })
181
+
182
+ it('우리 rack 의 frame mesh hit (자체 context 없음, walk-up) → 반환', () => {
183
+ const intersects: THREE.Intersection[] = [
184
+ { object: ourFrameMesh, distance: 10, point: new THREE.Vector3() } as any
185
+ ]
186
+ const hit = raycastRackHit(intersects, ourRealObject)
187
+ ;(hit === undefined).should.be.false()
188
+ hit!.object.should.equal(ourFrameMesh)
189
+ })
190
+
191
+ it('다른 rack 이 더 가까우면 — undefined (우리 rack 아님)', () => {
192
+ const intersects: THREE.Intersection[] = [
193
+ { object: otherMesh, distance: 10, point: new THREE.Vector3() } as any,
194
+ { object: ourStockMesh, instanceId: 0, distance: 30, point: new THREE.Vector3() } as any
195
+ ]
196
+ const hit = raycastRackHit(intersects, ourRealObject)
197
+ ;(hit === undefined).should.be.true()
198
+ })
199
+
200
+ it('intersects 비어있으면 undefined', () => {
201
+ ;(raycastRackHit([], ourRealObject) === undefined).should.be.true()
202
+ })
203
+
204
+ it('intersects undefined 이면 undefined', () => {
205
+ ;(raycastRackHit(undefined, ourRealObject) === undefined).should.be.true()
206
+ })
207
+
208
+ it('isStockHit — stockMesh hit 만 true', () => {
209
+ const stockHit: any = { object: ourStockMesh, instanceId: 0 }
210
+ const frameHit: any = { object: ourFrameMesh }
211
+ isStockHit(stockHit, ourStockMesh).should.be.true()
212
+ isStockHit(frameHit, ourStockMesh).should.be.false()
213
+ isStockHit(undefined, ourStockMesh).should.be.false()
214
+ isStockHit(stockHit, undefined).should.be.false()
215
+ })
216
+ })
217
+
218
+ // ── Group 3: records lookup contract ────────────────────────────────────────
219
+
220
+ describe('Click: records lookup', () => {
221
+ it('hit.instanceId 가 records 범위 안 → 정상 record', () => {
222
+ const stockMesh = buildStockInstancedMesh([
223
+ { cellId: 'A', sku: 'a' },
224
+ { cellId: 'B', sku: 'b' }
225
+ ])
226
+ const hit: any = { object: stockMesh, instanceId: 1 }
227
+ const rec = resolveRecord(hit)
228
+ rec.cellId.should.equal('B')
229
+ })
230
+
231
+ it('records 가 비어있으면 undefined', () => {
232
+ const stockMesh = new THREE.InstancedMesh(
233
+ new THREE.BoxGeometry(1, 1, 1),
234
+ new THREE.MeshBasicMaterial(),
235
+ 0
236
+ )
237
+ stockMesh.userData._records = []
238
+ const hit: any = { object: stockMesh, instanceId: 0 }
239
+ const rec = resolveRecord(hit)
240
+ ;(rec === undefined).should.be.true()
241
+ })
242
+
243
+ it('instanceId out-of-bounds → undefined', () => {
244
+ const stockMesh = buildStockInstancedMesh([{ cellId: 'X' }])
245
+ const hit: any = { object: stockMesh, instanceId: 5 }
246
+ const rec = resolveRecord(hit)
247
+ ;(rec === undefined).should.be.true()
248
+ })
249
+
250
+ it('hit 자체가 undefined → undefined', () => {
251
+ resolveRecord(undefined)?.should.equal('never')
252
+ // 정확히 undefined 인지 직접 확인
253
+ ;(resolveRecord(undefined) === undefined).should.be.true()
254
+ })
255
+ })
256
+
257
+ // ── Group 4: empty cell fallback — cellIdFromWorldPoint ─────────────────────
258
+
259
+ describe('Click: empty cell — world point → cellId 역산', () => {
260
+ const state = { bays: 20, levels: 20, width: 1000, depth: 3000, shelfBaseHeight: 0 }
261
+ // bayWidth=50, levelHeight=150
262
+
263
+ it('rack 중심 (0, 0) → bay 10, level 10', () => {
264
+ cellIdFromWorldPoint({ x: 0, y: 0 }, state)!.should.equal('10-0-10')
265
+ })
266
+
267
+ it('첫 cell (0-0-0) — rack 좌하단 근처', () => {
268
+ cellIdFromWorldPoint({ x: -475, y: -1425 }, state)!.should.equal('0-0-0')
269
+ })
270
+
271
+ it('마지막 cell (19-0-19) — rack 우상단 근처', () => {
272
+ cellIdFromWorldPoint({ x: 475, y: 1425 }, state)!.should.equal('19-0-19')
273
+ })
274
+
275
+ it('X 좌표 rack 밖 → out-of-bounds', () => {
276
+ cellIdFromWorldPoint({ x: 600, y: 0 }, state)!.should.match(/out-of-bounds/)
277
+ })
278
+
279
+ it('Y 좌표 rack 밖 → out-of-bounds', () => {
280
+ cellIdFromWorldPoint({ x: 0, y: -2000 }, state)!.should.match(/out-of-bounds/)
281
+ })
282
+
283
+ it('shelfBaseHeight 적용 → 첫 level (level 0) 의 중심 y', () => {
284
+ const s = { bays: 10, levels: 10, width: 1000, depth: 3000, shelfBaseHeight: 200 }
285
+ // shelfZone = 2800, levelHeight = 280
286
+ // level 0 시작 y = -1500 + 200 = -1300, center y = -1300 + 140 = -1160
287
+ cellIdFromWorldPoint({ x: 0, y: -1160 }, s)!.should.equal('5-0-0')
288
+ })
289
+ })
290
+
291
+ // ── Group 5: storage-rack-3d 의 _records 저장 계약 ───────────────────────────
292
+
293
+ describe('Click: storage-rack-3d._records contract', () => {
294
+ it('valid record 만 instance 가 됨 — _records 길이가 valid.length 와 일치', () => {
295
+ // 시뮬레이션: rebuildStockMesh 가 cellId 없는 record 를 거른다.
296
+ const data = [
297
+ { cellId: '0-0-0', sku: 'A' }, // valid
298
+ { sku: 'no-cell' }, // invalid — cellId 없음
299
+ { cellId: '1-0-0', sku: 'B' } // valid
300
+ ]
301
+ const valid = data.filter(r => r.cellId)
302
+ valid.length.should.equal(2)
303
+
304
+ const inst = new THREE.InstancedMesh(
305
+ new THREE.BoxGeometry(1, 1, 1),
306
+ new THREE.MeshBasicMaterial(),
307
+ valid.length
308
+ )
309
+ inst.userData._records = valid
310
+
311
+ ;(inst.userData._records as any[])[0].cellId.should.equal('0-0-0')
312
+ ;(inst.userData._records as any[])[1].cellId.should.equal('1-0-0')
313
+ ;(inst.userData._records as any[]).length.should.equal(2)
314
+ })
315
+
316
+ it('userData.context 도 함께 set 되어 framework 가 walk-up 으로 찾음', () => {
317
+ const inst = new THREE.InstancedMesh(
318
+ new THREE.BoxGeometry(1, 1, 1),
319
+ new THREE.MeshBasicMaterial(),
320
+ 1
321
+ )
322
+ const realObject = { tag: 'fake-storage-rack-3d' }
323
+ inst.userData.context = realObject
324
+ inst.userData._records = [{ cellId: 'X' }]
325
+
326
+ inst.userData.context.should.equal(realObject)
327
+ ;(inst.userData._records as any[])[0].cellId.should.equal('X')
328
+ })
329
+ })
@@ -0,0 +1,357 @@
1
+ /*
2
+ * Plan A — Slot API unit tests.
3
+ *
4
+ * 검증 대상:
5
+ * - obtainCarrier → state.data 에서 record 제거 + rack 자식으로 carrier 추가
6
+ * - receiveAt → carrier dispose + state.data 에 record push
7
+ * - 동일 cellId 가 record + carrier-child 양쪽 동시 존재하지 않는 불변식
8
+ * - recordFromCarrier 가 의미있는 필드만 추출 (transform 등 skip)
9
+ * - canReceiveAt / hasCarrierAt 가 두 source 모두 인식
10
+ *
11
+ * 실제 Rack 클래스를 things-scene 의 full pipeline 으로 인스턴스화하기 비현실적이라
12
+ * *순수 슬롯 의미론* 만 격리해 검증. SlottedHolder 컨트랙의 *불변식* 자체에 집중.
13
+ */
14
+
15
+ import 'should'
16
+
17
+ // ── Fixture: 컨트랙의 핵심 의미론을 격리한 mini-rack ─────────────────────────
18
+
19
+ class MiniRack {
20
+ state: any
21
+ components: any[] = []
22
+ _disposed = new Set<any>()
23
+
24
+ constructor(initialData: any[] = []) {
25
+ this.state = { data: initialData }
26
+ }
27
+
28
+ get records(): any[] {
29
+ return this.state.data ?? []
30
+ }
31
+
32
+ setState(key: string, value: any): void {
33
+ this.state[key] = value
34
+ }
35
+
36
+ addComponent(c: any): void {
37
+ c.parent = this
38
+ this.components.push(c)
39
+ }
40
+
41
+ removeComponent(c: any): void {
42
+ const i = this.components.indexOf(c)
43
+ if (i >= 0) this.components.splice(i, 1)
44
+ c.parent = null
45
+ }
46
+
47
+ _carrierChildAt(cellId: string): any {
48
+ return this.components.find(c => c.state?.cellId === cellId && c.placement === 'operation')
49
+ }
50
+
51
+ hasCarrierAt(cellId: string): boolean {
52
+ if (this._carrierChildAt(cellId)) return true
53
+ return this.records.some(r => r?.cellId === cellId)
54
+ }
55
+
56
+ obtainCarrier(cellId: string): any {
57
+ const existing = this._carrierChildAt(cellId)
58
+ if (existing) return existing
59
+
60
+ const idx = this.records.findIndex(r => r?.cellId === cellId)
61
+ if (idx === -1) return null
62
+ const record = this.records[idx]
63
+
64
+ // 가짜 carrier — placement 'operation' + state.cellId
65
+ const carrier: any = {
66
+ placement: 'operation',
67
+ state: { ...record, cellId, type: record.type ?? 'parcel' },
68
+ parent: null,
69
+ dispose() { /* test 가 추적 */ }
70
+ }
71
+ this.addComponent(carrier)
72
+
73
+ // atomic: child 추가 직후 record 제거
74
+ this.setState('data', this.records.filter((_, i) => i !== idx))
75
+
76
+ return carrier
77
+ }
78
+
79
+ canReceiveAt(cellId: string, _carrier?: any): boolean {
80
+ return !this.hasCarrierAt(cellId)
81
+ }
82
+
83
+ async receiveAt(cellId: string, carrier: any, _options?: any): Promise<void> {
84
+ if (!this.canReceiveAt(cellId, carrier)) return
85
+
86
+ const record = this.recordFromCarrier(carrier, cellId)
87
+
88
+ const p = carrier.parent
89
+ if (p && typeof p.removeComponent === 'function') p.removeComponent(carrier)
90
+ this._disposed.add(carrier)
91
+ carrier.dispose?.()
92
+
93
+ this.setState('data', [...this.records, record])
94
+ }
95
+
96
+ recordFromCarrier(carrier: any, cellId: string): any {
97
+ const state = carrier.state ?? {}
98
+ const SKIP = new Set(['left', 'top', 'zPos', 'transform', 'rotation', 'scale', '_transferSlotId', 'cellId'])
99
+ const record: any = { cellId, type: state.type }
100
+ for (const k of Object.keys(state)) {
101
+ if (SKIP.has(k)) continue
102
+ record[k] = state[k]
103
+ }
104
+ return record
105
+ }
106
+ }
107
+
108
+ // ── Group 1: obtainCarrier ──────────────────────────────────────────────────
109
+
110
+ describe('Plan A: obtainCarrier — materialize from state.data', () => {
111
+ it('record 가 있는 cellId → carrier materialize, record 제거', () => {
112
+ const rack = new MiniRack([
113
+ { cellId: '0-0-0', sku: 'A', qty: 1 },
114
+ { cellId: '1-0-0', sku: 'B', qty: 2 }
115
+ ])
116
+
117
+ const carrier = rack.obtainCarrier('0-0-0')!
118
+ carrier.should.not.be.null()
119
+ carrier.state.sku.should.equal('A')
120
+ carrier.state.cellId.should.equal('0-0-0')
121
+ carrier.parent.should.equal(rack)
122
+
123
+ // state.data 에서 빠짐
124
+ rack.records.length.should.equal(1)
125
+ rack.records[0].cellId.should.equal('1-0-0')
126
+
127
+ // rack 의 자식
128
+ rack.components.length.should.equal(1)
129
+ rack.components[0].should.equal(carrier)
130
+ })
131
+
132
+ it('record 가 없는 cellId → null', () => {
133
+ const rack = new MiniRack([{ cellId: '0-0-0' }])
134
+ ;(rack.obtainCarrier('5-0-5') === null).should.be.true()
135
+ // state.data 는 변동 없음
136
+ rack.records.length.should.equal(1)
137
+ })
138
+
139
+ it('이미 child carrier 가 있는 cellId → 그 child 그대로 반환 (idempotent)', () => {
140
+ const rack = new MiniRack([])
141
+ const existing: any = {
142
+ placement: 'operation',
143
+ state: { cellId: '0-0-0', sku: 'X' },
144
+ parent: rack
145
+ }
146
+ rack.components.push(existing)
147
+
148
+ const got = rack.obtainCarrier('0-0-0')
149
+ got!.should.equal(existing)
150
+ // record 도 child 도 추가 안 됨
151
+ rack.records.length.should.equal(0)
152
+ rack.components.length.should.equal(1)
153
+ })
154
+
155
+ it('child + record 동시 존재 → child 우선 (record 손대지 않음)', () => {
156
+ // 불변식: 동시 존재하면 안 되지만 정합성 보호 — child 가 truth
157
+ const rack = new MiniRack([{ cellId: '0-0-0', sku: 'phantom' }])
158
+ const existing: any = {
159
+ placement: 'operation',
160
+ state: { cellId: '0-0-0', sku: 'real' },
161
+ parent: rack
162
+ }
163
+ rack.components.push(existing)
164
+
165
+ const got = rack.obtainCarrier('0-0-0')
166
+ got!.state.sku.should.equal('real')
167
+ rack.records.length.should.equal(1) // phantom 그대로 (정리는 별도 책임)
168
+ })
169
+
170
+ it('obtainCarrier 2회 연속 호출 → 두 번째는 같은 carrier (record 다시 소비 안 함)', () => {
171
+ const rack = new MiniRack([{ cellId: '0-0-0', sku: 'A' }])
172
+ const c1 = rack.obtainCarrier('0-0-0')
173
+ const c2 = rack.obtainCarrier('0-0-0')
174
+ c1!.should.equal(c2)
175
+ rack.records.length.should.equal(0)
176
+ })
177
+ })
178
+
179
+ // ── Group 2: receiveAt ──────────────────────────────────────────────────────
180
+
181
+ describe('Plan A: receiveAt — absorb carrier into state.data', () => {
182
+ it('carrier 받으면 dispose + state.data 에 record push', async () => {
183
+ const rack = new MiniRack([])
184
+ const carrier: any = {
185
+ placement: 'operation',
186
+ state: { type: 'parcel', sku: 'X', qty: 5 },
187
+ parent: { removeComponent: () => {} },
188
+ dispose() {}
189
+ }
190
+
191
+ await rack.receiveAt('2-0-3', carrier)
192
+
193
+ rack._disposed.has(carrier).should.be.true()
194
+ rack.records.length.should.equal(1)
195
+ rack.records[0].cellId.should.equal('2-0-3')
196
+ rack.records[0].sku.should.equal('X')
197
+ rack.records[0].qty.should.equal(5)
198
+ rack.records[0].type.should.equal('parcel')
199
+ })
200
+
201
+ it('이미 점유된 cellId → reject (state.data 변동 없음)', async () => {
202
+ const rack = new MiniRack([{ cellId: '0-0-0', sku: 'occupied' }])
203
+ const carrier: any = {
204
+ placement: 'operation',
205
+ state: { type: 'parcel', sku: 'X' },
206
+ parent: { removeComponent: () => {} },
207
+ dispose() {}
208
+ }
209
+
210
+ await rack.receiveAt('0-0-0', carrier)
211
+
212
+ rack._disposed.has(carrier).should.be.false()
213
+ rack.records.length.should.equal(1)
214
+ rack.records[0].sku.should.equal('occupied')
215
+ })
216
+
217
+ it('parent 에서 떼낸 후 dispose', async () => {
218
+ const rack = new MiniRack([])
219
+ const removed: any[] = []
220
+ const parent = {
221
+ removeComponent(c: any) { removed.push(c) }
222
+ }
223
+ const carrier: any = {
224
+ placement: 'operation',
225
+ state: { type: 'parcel' },
226
+ parent,
227
+ dispose() {}
228
+ }
229
+
230
+ await rack.receiveAt('0-0-0', carrier)
231
+
232
+ removed.length.should.equal(1)
233
+ removed[0].should.equal(carrier)
234
+ rack._disposed.has(carrier).should.be.true()
235
+ })
236
+ })
237
+
238
+ // ── Group 3: 불변식 — 동일 cellId 가 양쪽 동시 존재 안 함 ─────────────────────
239
+
240
+ describe('Plan A: 불변식 — record ↔ child 상호 배타', () => {
241
+ it('obtainCarrier 가 record 제거 → child 와 record 가 동일 cellId 로 양립 안 함', () => {
242
+ const rack = new MiniRack([{ cellId: 'A', sku: 'A' }])
243
+ const c = rack.obtainCarrier('A')
244
+ rack._carrierChildAt('A').should.equal(c)
245
+ rack.records.some((r: any) => r.cellId === 'A').should.be.false()
246
+ })
247
+
248
+ it('receiveAt 후 record 존재 → child 사라짐', async () => {
249
+ const rack = new MiniRack([])
250
+ const carrier: any = {
251
+ placement: 'operation', state: { type: 'parcel' },
252
+ parent: { removeComponent: () => {} }, dispose() {}
253
+ }
254
+ await rack.receiveAt('B', carrier)
255
+ rack.records.some((r: any) => r.cellId === 'B').should.be.true()
256
+ ;(rack._carrierChildAt('B') === undefined).should.be.true()
257
+ })
258
+
259
+ it('Full cycle — record → carrier → record (다른 cellId 로 이동)', async () => {
260
+ const rack = new MiniRack([{ cellId: 'A', sku: 'X' }])
261
+
262
+ // 1. record 만 존재
263
+ rack.hasCarrierAt('A').should.be.true()
264
+ rack.hasCarrierAt('B').should.be.false()
265
+
266
+ // 2. obtain → child 로
267
+ const carrier = rack.obtainCarrier('A')!
268
+ rack.hasCarrierAt('A').should.be.true() // child 로 존재
269
+ ;(rack._carrierChildAt('A') !== undefined).should.be.true()
270
+ rack.records.some((r: any) => r.cellId === 'A').should.be.false() // record 빠짐
271
+
272
+ // 3. 외부로 picked (parent 변경 시뮬)
273
+ rack.removeComponent(carrier)
274
+ carrier.parent = { removeComponent: (c: any) => {} } // mover
275
+ rack.hasCarrierAt('A').should.be.false()
276
+
277
+ // 4. 새 cellId 로 receiveAt
278
+ await rack.receiveAt('B', carrier)
279
+ rack.hasCarrierAt('A').should.be.false()
280
+ rack.hasCarrierAt('B').should.be.true()
281
+ rack.records.some((r: any) => r.cellId === 'B').should.be.true()
282
+ rack._disposed.has(carrier).should.be.true()
283
+ })
284
+ })
285
+
286
+ // ── Group 4: recordFromCarrier — 의미있는 필드 추출 ──────────────────────────
287
+
288
+ describe('Plan A: recordFromCarrier — transform 제외, 의미 보존', () => {
289
+ it('transform 관련 필드는 record 에 포함되지 않음', () => {
290
+ const rack = new MiniRack()
291
+ const carrier: any = {
292
+ state: {
293
+ type: 'parcel',
294
+ sku: 'A',
295
+ qty: 3,
296
+ left: 100, top: 200, zPos: 50,
297
+ transform: 'rotate(45deg)',
298
+ rotation: 90,
299
+ scale: 2,
300
+ _transferSlotId: 'forks',
301
+ cellId: 'old-cell'
302
+ }
303
+ }
304
+ const r = rack.recordFromCarrier(carrier, 'new-cell')
305
+ r.cellId.should.equal('new-cell') // 새 cellId 가 override
306
+ r.type.should.equal('parcel')
307
+ r.sku.should.equal('A')
308
+ r.qty.should.equal(3)
309
+ ;('left' in r).should.be.false()
310
+ ;('top' in r).should.be.false()
311
+ ;('zPos' in r).should.be.false()
312
+ ;('transform' in r).should.be.false()
313
+ ;('rotation' in r).should.be.false()
314
+ ;('scale' in r).should.be.false()
315
+ ;('_transferSlotId' in r).should.be.false()
316
+ })
317
+
318
+ it('cellId 가 새 값으로 갱신 — old → new 이동 의미', () => {
319
+ const rack = new MiniRack()
320
+ const carrier: any = { state: { type: 'parcel', cellId: '0-0-0' } }
321
+ const r = rack.recordFromCarrier(carrier, '5-5-5')
322
+ r.cellId.should.equal('5-5-5')
323
+ })
324
+
325
+ it('type 누락 시 record.type 도 undefined (caller 책임)', () => {
326
+ const rack = new MiniRack()
327
+ const carrier: any = { state: { sku: 'X' } }
328
+ const r = rack.recordFromCarrier(carrier, '0-0-0')
329
+ ;(r.type === undefined).should.be.true()
330
+ r.sku.should.equal('X')
331
+ })
332
+ })
333
+
334
+ // ── Group 5: canReceiveAt / hasCarrierAt — 두 source 모두 인식 ───────────────
335
+
336
+ describe('Plan A: hasCarrierAt / canReceiveAt 가 record 와 child 모두 인식', () => {
337
+ it('record 만 있어도 hasCarrierAt true, canReceiveAt false', () => {
338
+ const rack = new MiniRack([{ cellId: 'A' }])
339
+ rack.hasCarrierAt('A').should.be.true()
340
+ rack.canReceiveAt('A').should.be.false()
341
+ })
342
+
343
+ it('child 만 있어도 hasCarrierAt true, canReceiveAt false', () => {
344
+ const rack = new MiniRack([])
345
+ rack.components.push({
346
+ placement: 'operation', state: { cellId: 'B' }, parent: rack
347
+ } as any)
348
+ rack.hasCarrierAt('B').should.be.true()
349
+ rack.canReceiveAt('B').should.be.false()
350
+ })
351
+
352
+ it('record / child 모두 없으면 hasCarrierAt false, canReceiveAt true', () => {
353
+ const rack = new MiniRack([])
354
+ rack.hasCarrierAt('C').should.be.false()
355
+ rack.canReceiveAt('C').should.be.true()
356
+ })
357
+ })