@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.
- package/CHANGELOG.md +20 -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/parcel-3d.js +42 -9
- package/dist/parcel-3d.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/parcel-3d.ts +41 -9
- 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,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
|
+
})
|