@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,254 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Plan A 의 *동시성 / 멱등성* 검증.
|
|
3
|
+
*
|
|
4
|
+
* Stuck pickAndPlace, ghost mover 가 시스템 락 만드는 회귀의 근본 원인 = *불변식이
|
|
5
|
+
* 동시 호출 / 재호출 시 깨짐*. 여기선 그 불변식들을 격리 검증.
|
|
6
|
+
*
|
|
7
|
+
* 검증 케이스:
|
|
8
|
+
* - 같은 cellId 로 obtainCarrier 를 연속 호출 — 두 번째는 *기존 carrier 그대로* 반환
|
|
9
|
+
* (새 인스턴스 안 만듦)
|
|
10
|
+
* - 같은 cellId 로 receiveAt 를 동시에 시도 — 첫 성공, 두 번째 reject
|
|
11
|
+
* - obtainCarrier 후 receiveAt 까지의 시퀀스가 *원자성* — 다른 호출이 끼어들어도
|
|
12
|
+
* state.data 의 record + carrier-children 의 *합* 이 보존
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import 'should'
|
|
16
|
+
import * as THREE from 'three'
|
|
17
|
+
|
|
18
|
+
// FakeRack — SlottedHolder 의 *불변식 검증* 용
|
|
19
|
+
class FakeRack {
|
|
20
|
+
state: any = { data: [] }
|
|
21
|
+
components: any[] = []
|
|
22
|
+
rootObject3d = new THREE.Group()
|
|
23
|
+
_slotAnchors = new Map<string, THREE.Object3D>()
|
|
24
|
+
|
|
25
|
+
records(): any[] { return this.state.data }
|
|
26
|
+
|
|
27
|
+
carrierAt(cellId: string): any {
|
|
28
|
+
return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
hasCarrierAt(cellId: string): boolean {
|
|
32
|
+
return !!this.carrierAt(cellId) || this.records().some(r => r.cellId === cellId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
canReceiveAt(cellId: string): boolean {
|
|
36
|
+
return !this.hasCarrierAt(cellId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
addComponent(c: any): void {
|
|
40
|
+
c.parent = this
|
|
41
|
+
this.components.push(c)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
removeComponent(c: any): void {
|
|
45
|
+
const i = this.components.indexOf(c)
|
|
46
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
47
|
+
c.parent = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
obtainCarrier(cellId: string): any {
|
|
51
|
+
const existing = this.carrierAt(cellId)
|
|
52
|
+
if (existing) return existing // idempotent — 이미 있으면 그대로
|
|
53
|
+
const idx = this.records().findIndex(r => r.cellId === cellId)
|
|
54
|
+
if (idx === -1) return null
|
|
55
|
+
const record = this.records()[idx]
|
|
56
|
+
const c: any = {
|
|
57
|
+
placement: 'operation',
|
|
58
|
+
state: { ...record, cellId },
|
|
59
|
+
parent: null,
|
|
60
|
+
dispose() { this._disposed = true }
|
|
61
|
+
}
|
|
62
|
+
this.addComponent(c)
|
|
63
|
+
this.state.data = this.records().filter((_, j) => j !== idx)
|
|
64
|
+
return c
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async receiveAt(cellId: string, carrier: any): Promise<{ ok: boolean; reason?: string }> {
|
|
68
|
+
if (!this.canReceiveAt(cellId)) return { ok: false, reason: 'slot-occupied' }
|
|
69
|
+
const p = carrier.parent
|
|
70
|
+
if (p?.removeComponent) p.removeComponent(carrier)
|
|
71
|
+
carrier.dispose?.()
|
|
72
|
+
const rec = { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }
|
|
73
|
+
this.state.data = [...this.records(), rec]
|
|
74
|
+
return { ok: true }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Group 1: obtainCarrier 의 멱등성 ────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('Concurrent: obtainCarrier 멱등성', () => {
|
|
81
|
+
it('같은 cellId 로 두 번 호출 → 같은 인스턴스 반환', () => {
|
|
82
|
+
const rack = new FakeRack()
|
|
83
|
+
rack.state.data = [{ cellId: 'A', sku: 'X' }]
|
|
84
|
+
|
|
85
|
+
const c1 = rack.obtainCarrier('A')
|
|
86
|
+
const c2 = rack.obtainCarrier('A')
|
|
87
|
+
|
|
88
|
+
c1.should.equal(c2)
|
|
89
|
+
rack.records().length.should.equal(0) // record 는 한 번만 빠짐
|
|
90
|
+
rack.components.length.should.equal(1) // carrier 도 한 개
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('첫 obtain → pick 으로 옮긴 후 → 두 번째 obtain → null (record 도 carrier 도 없음)', () => {
|
|
94
|
+
const rack = new FakeRack()
|
|
95
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
96
|
+
const elsewhere: any = { removeComponent(c: any) { c.parent = null } }
|
|
97
|
+
|
|
98
|
+
const c1 = rack.obtainCarrier('A')!
|
|
99
|
+
// 외부로 픽업 (simulated mover.pick)
|
|
100
|
+
rack.removeComponent(c1)
|
|
101
|
+
c1.parent = elsewhere
|
|
102
|
+
|
|
103
|
+
const c2 = rack.obtainCarrier('A')
|
|
104
|
+
;(c2 === null).should.be.true()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('record 와 carrier-child 가 다른 cellId 면 각각 독립적으로 obtain', () => {
|
|
108
|
+
const rack = new FakeRack()
|
|
109
|
+
rack.state.data = [{ cellId: 'A', sku: 'a' }, { cellId: 'B', sku: 'b' }]
|
|
110
|
+
|
|
111
|
+
const cA = rack.obtainCarrier('A')!
|
|
112
|
+
const cB = rack.obtainCarrier('B')!
|
|
113
|
+
|
|
114
|
+
;(cA !== cB).should.be.true()
|
|
115
|
+
cA.state.sku.should.equal('a')
|
|
116
|
+
cB.state.sku.should.equal('b')
|
|
117
|
+
rack.records().length.should.equal(0)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ── Group 2: receiveAt 의 원자성 ────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('Concurrent: receiveAt 원자성', () => {
|
|
124
|
+
it('같은 cellId 로 동시 receiveAt — 첫 성공, 두 번째 reject', async () => {
|
|
125
|
+
const rack = new FakeRack()
|
|
126
|
+
const mover: any = { removeComponent(c: any) { c.parent = null } }
|
|
127
|
+
|
|
128
|
+
const c1 = { placement: 'operation', state: { type: 'parcel', sku: 'first' }, parent: mover, dispose() {} } as any
|
|
129
|
+
const c2 = { placement: 'operation', state: { type: 'parcel', sku: 'second' }, parent: mover, dispose() {} } as any
|
|
130
|
+
|
|
131
|
+
const [r1, r2] = await Promise.all([
|
|
132
|
+
rack.receiveAt('A', c1),
|
|
133
|
+
rack.receiveAt('A', c2)
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
// 둘 중 하나만 ok
|
|
137
|
+
const okCount = [r1, r2].filter(r => r.ok).length
|
|
138
|
+
okCount.should.equal(1)
|
|
139
|
+
rack.records().length.should.equal(1)
|
|
140
|
+
rack.records()[0].cellId.should.equal('A')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('다른 cellId 로 동시 receiveAt — 둘 다 성공', async () => {
|
|
144
|
+
const rack = new FakeRack()
|
|
145
|
+
const mover: any = { removeComponent(c: any) { c.parent = null } }
|
|
146
|
+
|
|
147
|
+
const cA = { placement: 'operation', state: { type: 'parcel' }, parent: mover, dispose() {} } as any
|
|
148
|
+
const cB = { placement: 'operation', state: { type: 'parcel' }, parent: mover, dispose() {} } as any
|
|
149
|
+
|
|
150
|
+
const [rA, rB] = await Promise.all([
|
|
151
|
+
rack.receiveAt('A', cA),
|
|
152
|
+
rack.receiveAt('B', cB)
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
rA.ok.should.be.true()
|
|
156
|
+
rB.ok.should.be.true()
|
|
157
|
+
rack.records().length.should.equal(2)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ── Group 3: round-trip 의 invariant ────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('Concurrent: 전체 round-trip 후 state 일관성', () => {
|
|
164
|
+
it('A → mover → B 전체 후 — record 가 정확히 1개 (B에)', async () => {
|
|
165
|
+
const rack = new FakeRack()
|
|
166
|
+
rack.state.data = [{ cellId: 'A', sku: 'X' }]
|
|
167
|
+
const mover: any = {
|
|
168
|
+
add(c: any) { c.parent = mover },
|
|
169
|
+
removeComponent(c: any) { c.parent = null }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 1. obtain A
|
|
173
|
+
const c = rack.obtainCarrier('A')!
|
|
174
|
+
rack.records().length.should.equal(0)
|
|
175
|
+
|
|
176
|
+
// 2. mover picks
|
|
177
|
+
rack.removeComponent(c)
|
|
178
|
+
mover.add(c)
|
|
179
|
+
rack.records().length.should.equal(0)
|
|
180
|
+
rack.components.length.should.equal(0)
|
|
181
|
+
|
|
182
|
+
// 3. mover places at B
|
|
183
|
+
await rack.receiveAt('B', c)
|
|
184
|
+
rack.records().length.should.equal(1)
|
|
185
|
+
rack.records()[0].cellId.should.equal('B')
|
|
186
|
+
rack.records()[0].sku.should.equal('X')
|
|
187
|
+
rack.components.length.should.equal(0)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('A → mover → A (원래 자리로 복귀) — record A 가 다시 들어옴', async () => {
|
|
191
|
+
const rack = new FakeRack()
|
|
192
|
+
rack.state.data = [{ cellId: 'A', sku: 'X' }]
|
|
193
|
+
const mover: any = { removeComponent(c: any) { c.parent = null } }
|
|
194
|
+
|
|
195
|
+
const c = rack.obtainCarrier('A')!
|
|
196
|
+
rack.removeComponent(c)
|
|
197
|
+
c.parent = mover
|
|
198
|
+
await rack.receiveAt('A', c)
|
|
199
|
+
|
|
200
|
+
rack.records().length.should.equal(1)
|
|
201
|
+
rack.records()[0].cellId.should.equal('A')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('A → mover → 외부 (다른 holder) — rack.records 비어있고 외부에 carrier 1개', () => {
|
|
205
|
+
const rack = new FakeRack()
|
|
206
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
207
|
+
const externalHolder: any = {
|
|
208
|
+
components: [] as any[],
|
|
209
|
+
addComponent(c: any) { c.parent = this; this.components.push(c) }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const c = rack.obtainCarrier('A')!
|
|
213
|
+
rack.removeComponent(c)
|
|
214
|
+
externalHolder.addComponent(c)
|
|
215
|
+
|
|
216
|
+
rack.records().length.should.equal(0)
|
|
217
|
+
rack.components.length.should.equal(0)
|
|
218
|
+
externalHolder.components.length.should.equal(1)
|
|
219
|
+
c.parent.should.equal(externalHolder)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// ── Group 4: 동시 obtain 의 race 방지 (sync 가정) ────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe('Concurrent: 동시 호출의 race condition 방지', () => {
|
|
226
|
+
it('rapid 연속 obtainCarrier 가 record 를 *2번 소비하지 않음*', () => {
|
|
227
|
+
const rack = new FakeRack()
|
|
228
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
229
|
+
|
|
230
|
+
// 동기 연속 (event loop 진입 안 함)
|
|
231
|
+
const c1 = rack.obtainCarrier('A')
|
|
232
|
+
const c2 = rack.obtainCarrier('A')
|
|
233
|
+
const c3 = rack.obtainCarrier('A')
|
|
234
|
+
|
|
235
|
+
c1!.should.equal(c2)
|
|
236
|
+
c2!.should.equal(c3)
|
|
237
|
+
rack.records().length.should.equal(0)
|
|
238
|
+
rack.components.length.should.equal(1)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('obtain → carrier 가 다른 곳으로 떠난 후 → 또 obtain → null', () => {
|
|
242
|
+
const rack = new FakeRack()
|
|
243
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
244
|
+
const mover: any = { removeComponent(c: any) { c.parent = null } }
|
|
245
|
+
|
|
246
|
+
const c1 = rack.obtainCarrier('A')!
|
|
247
|
+
rack.removeComponent(c1)
|
|
248
|
+
c1.parent = mover
|
|
249
|
+
|
|
250
|
+
// 이제 rack 에는 record 도 carrier-child 도 없음
|
|
251
|
+
const c2 = rack.obtainCarrier('A')
|
|
252
|
+
;(c2 === null).should.be.true()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Edge case + 결함 식별 — Plan A 의 가정이 깨지는 시나리오를 *적극 탐사*.
|
|
3
|
+
*
|
|
4
|
+
* 목표: 정상 path 만 검증하는 게 아니라, application 이 자주 만드는 *비정상 입력*
|
|
5
|
+
* 과 *race* 상황에서 어디가 깨지는지 드러내기.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import 'should'
|
|
9
|
+
import * as THREE from 'three'
|
|
10
|
+
|
|
11
|
+
class FakeRack {
|
|
12
|
+
state: any = { data: [] }
|
|
13
|
+
components: any[] = []
|
|
14
|
+
rootObject3d = new THREE.Group()
|
|
15
|
+
_slotAnchors = new Map<string, THREE.Object3D>()
|
|
16
|
+
|
|
17
|
+
records(): any[] { return this.state.data ?? [] }
|
|
18
|
+
|
|
19
|
+
ensureSlotAnchor(cellId: string): THREE.Object3D {
|
|
20
|
+
let a = this._slotAnchors.get(cellId)
|
|
21
|
+
if (!a) {
|
|
22
|
+
a = new THREE.Object3D()
|
|
23
|
+
a.name = `slot:${cellId}`
|
|
24
|
+
this.rootObject3d.add(a)
|
|
25
|
+
this._slotAnchors.set(cellId, a)
|
|
26
|
+
}
|
|
27
|
+
return a
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
carrierAt(cellId: string): any {
|
|
31
|
+
return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
|
|
32
|
+
}
|
|
33
|
+
hasCarrierAt(cellId: string): boolean {
|
|
34
|
+
return !!this.carrierAt(cellId) || this.records().some(r => r.cellId === cellId)
|
|
35
|
+
}
|
|
36
|
+
canReceiveAt(cellId: string, carrier?: any): boolean {
|
|
37
|
+
if (this.records().some(r => r?.cellId === cellId)) return false
|
|
38
|
+
const existing = this.carrierAt(cellId)
|
|
39
|
+
if (existing && existing !== carrier) return false
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
addComponent(c: any): void { c.parent = this; this.components.push(c) }
|
|
44
|
+
removeComponent(c: any): void {
|
|
45
|
+
const i = this.components.indexOf(c)
|
|
46
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
47
|
+
c.parent = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
attachPointFor(carrier: any) {
|
|
51
|
+
const cellId = carrier?.state?.cellId
|
|
52
|
+
if (cellId) return { attach: this.ensureSlotAnchor(cellId), localPosition: { x: 0, y: 0, z: 0 } }
|
|
53
|
+
return { attach: this.rootObject3d, localPosition: { x: 0, y: 0, z: 0 } }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
obtainCarrier(cellId: string): any {
|
|
57
|
+
const existing = this.carrierAt(cellId)
|
|
58
|
+
if (existing) return existing
|
|
59
|
+
const records = this.records()
|
|
60
|
+
const idx = records.findIndex(r => r?.cellId === cellId)
|
|
61
|
+
if (idx === -1) return null
|
|
62
|
+
const record = records[idx]
|
|
63
|
+
const c: any = {
|
|
64
|
+
placement: 'operation',
|
|
65
|
+
state: { ...record, cellId },
|
|
66
|
+
parent: null,
|
|
67
|
+
_realObject: { object3d: new THREE.Group() },
|
|
68
|
+
dispose() {
|
|
69
|
+
this._disposed = true
|
|
70
|
+
this._realObject.object3d.clear()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.addComponent(c)
|
|
74
|
+
const pt = this.attachPointFor(c)
|
|
75
|
+
pt.attach.attach(c._realObject.object3d)
|
|
76
|
+
c._realObject.object3d.position.set(pt.localPosition.x, pt.localPosition.y, pt.localPosition.z)
|
|
77
|
+
// *모든* 동일 cellId record 정리 (중복 방어)
|
|
78
|
+
this.state.data = records.filter(r => r?.cellId !== cellId)
|
|
79
|
+
return c
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async receiveAt(cellId: string, carrier: any): Promise<void> {
|
|
83
|
+
if (!this.canReceiveAt(cellId, carrier)) {
|
|
84
|
+
const err: any = new Error('slot occupied')
|
|
85
|
+
err.reason = 'slot-occupied'
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
const p = carrier.parent
|
|
89
|
+
if (p?.removeComponent) p.removeComponent(carrier)
|
|
90
|
+
const obj = carrier._realObject?.object3d
|
|
91
|
+
if (obj?.parent?.remove) obj.parent.remove(obj)
|
|
92
|
+
carrier.dispose?.()
|
|
93
|
+
// 중복 방어: 동일 cellId 의 기존 record 가 있으면 (외부 결함) 제거 후 단일 추가
|
|
94
|
+
const remaining = this.records().filter(r => r?.cellId !== cellId)
|
|
95
|
+
this.state.data = [...remaining, { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── E1: state.data 가 corrupted 한 케이스 ─────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe('Edge: state.data 비정상 입력', () => {
|
|
102
|
+
it('R5 FIXED — 중복 cellId record 가 *한 번에 모두 정리됨* (ghost 차단)', () => {
|
|
103
|
+
const rack = new FakeRack()
|
|
104
|
+
rack.state.data = [
|
|
105
|
+
{ cellId: 'A', sku: 'first' },
|
|
106
|
+
{ cellId: 'A', sku: 'duplicate' } // 중복
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const c1 = rack.obtainCarrier('A')!
|
|
110
|
+
c1.state.sku.should.equal('first') // 첫 record 가 materialize
|
|
111
|
+
rack.records().length.should.equal(0) // 중복 record 도 한 번에 제거 (Fix 적용)
|
|
112
|
+
|
|
113
|
+
// idempotent — 두 번째 호출은 기존 c1 반환 (다른 인스턴스 안 만듦)
|
|
114
|
+
const c2 = rack.obtainCarrier('A')!
|
|
115
|
+
c2.should.equal(c1)
|
|
116
|
+
rack.components.length.should.equal(1) // carrier 하나만 존재
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('R6 — state.data 가 undefined 일 때 obtainCarrier 안전한가?', () => {
|
|
120
|
+
const rack = new FakeRack()
|
|
121
|
+
rack.state.data = undefined as any
|
|
122
|
+
const c = rack.obtainCarrier('A')
|
|
123
|
+
;(c === null).should.be.true()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('R7 — state.data 에 cellId 가 null/undefined 인 record', () => {
|
|
127
|
+
const rack = new FakeRack()
|
|
128
|
+
rack.state.data = [{ sku: 'no-cell' }, { cellId: null, sku: 'null-cell' }, { cellId: 'A', sku: 'ok' }]
|
|
129
|
+
// 첫 두 record 는 cellId 가 없음 → InstancedMesh 도 못 그리지만 obtainCarrier 영향?
|
|
130
|
+
const c = rack.obtainCarrier('A')!
|
|
131
|
+
c.state.sku.should.equal('ok')
|
|
132
|
+
// 비정상 record 는 그대로 남음 (silent ignore)
|
|
133
|
+
rack.records().length.should.equal(2)
|
|
134
|
+
console.log(' → R7: cellId 없는 record 가 *그대로 잔존*. 청소되지 않음.')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ── E2: 이미 carrier-child 있는 cellId 로 receiveAt ────────────────────────
|
|
139
|
+
|
|
140
|
+
describe('Edge: receiveAt 충돌', () => {
|
|
141
|
+
it('R8 — carrier-child 있는 cellId 로 receiveAt → slot-occupied 거부', async () => {
|
|
142
|
+
const rack = new FakeRack()
|
|
143
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
144
|
+
const c1 = rack.obtainCarrier('A')!
|
|
145
|
+
rack.removeComponent(c1)
|
|
146
|
+
const mover: any = { removeComponent: (c: any) => { c.parent = null } }
|
|
147
|
+
c1.parent = mover
|
|
148
|
+
|
|
149
|
+
// record 는 없지만 carrier child 가 있다고 가정 (다른 carrier)
|
|
150
|
+
const phantom: any = {
|
|
151
|
+
placement: 'operation',
|
|
152
|
+
state: { cellId: 'A' },
|
|
153
|
+
parent: rack
|
|
154
|
+
}
|
|
155
|
+
rack.components.push(phantom)
|
|
156
|
+
|
|
157
|
+
let thrown: any
|
|
158
|
+
try { await rack.receiveAt('A', c1) } catch (e) { thrown = e }
|
|
159
|
+
thrown.should.not.be.null()
|
|
160
|
+
thrown.reason.should.equal('slot-occupied')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('R9 — receiveAt 가 carrier 의 *현재 parent 가 잘못된 케이스* 처리', async () => {
|
|
164
|
+
const rack = new FakeRack()
|
|
165
|
+
// carrier 가 어떤 parent 에도 없는 *orphan* 인 경우
|
|
166
|
+
const orphan: any = {
|
|
167
|
+
placement: 'operation',
|
|
168
|
+
state: { type: 'parcel' },
|
|
169
|
+
parent: null,
|
|
170
|
+
_realObject: { object3d: new THREE.Group() },
|
|
171
|
+
dispose() { this._disposed = true; this._realObject.object3d.clear() }
|
|
172
|
+
}
|
|
173
|
+
// 여전히 receiveAt 호출되면?
|
|
174
|
+
await rack.receiveAt('A', orphan)
|
|
175
|
+
rack.records().length.should.equal(1)
|
|
176
|
+
orphan._disposed.should.be.true()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('R10 — receiveAt 가 carrier._realObject 없는 케이스 처리', async () => {
|
|
180
|
+
const rack = new FakeRack()
|
|
181
|
+
const broken: any = {
|
|
182
|
+
placement: 'operation',
|
|
183
|
+
state: { type: 'parcel' },
|
|
184
|
+
parent: null,
|
|
185
|
+
_realObject: null, // 비정상
|
|
186
|
+
dispose() { this._disposed = true }
|
|
187
|
+
}
|
|
188
|
+
await rack.receiveAt('A', broken) // throw 안 하고 graceful
|
|
189
|
+
broken._disposed.should.be.true()
|
|
190
|
+
rack.records().length.should.equal(1)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ── E3: applyHolderAttachPoint 의 부정확한 시점 ───────────────────────────
|
|
195
|
+
|
|
196
|
+
describe('Edge: applyHolderAttachPoint 타이밍', () => {
|
|
197
|
+
it('R11 — _realObject 가 *아직 build 안 된 시점* 에 applyHolderAttachPoint 호출', () => {
|
|
198
|
+
const rack = new FakeRack()
|
|
199
|
+
// 일반적으로 obtainCarrier 가 _realObject 를 미리 만들지만, 외부에서 직접 add 시
|
|
200
|
+
// _realObject 가 lazy 라 빌드 안 된 채 호출될 수 있음
|
|
201
|
+
const carrier: any = {
|
|
202
|
+
placement: 'operation',
|
|
203
|
+
state: { cellId: 'A' },
|
|
204
|
+
parent: null,
|
|
205
|
+
_realObject: null, // 아직 빌드 안 됨
|
|
206
|
+
dispose() {}
|
|
207
|
+
}
|
|
208
|
+
rack.addComponent(carrier)
|
|
209
|
+
// applyHolderAttachPoint 가 _realObject null 이라 silent skip 해야 함
|
|
210
|
+
const pt = rack.attachPointFor(carrier)
|
|
211
|
+
pt.attach.name.should.equal('slot:A')
|
|
212
|
+
// 호출 자체는 graceful — application 이 _realObject 빌드 후 다시 호출 책임
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('R12 — cellId 가 없는 carrier 가 add 됐을 때 attachPointFor 가 rack root 로 fallback', () => {
|
|
216
|
+
const rack = new FakeRack()
|
|
217
|
+
const noIdCarrier: any = {
|
|
218
|
+
placement: 'operation',
|
|
219
|
+
state: { type: 'parcel' }, // cellId 없음
|
|
220
|
+
parent: rack
|
|
221
|
+
}
|
|
222
|
+
const pt = rack.attachPointFor(noIdCarrier)
|
|
223
|
+
pt.attach.should.equal(rack.rootObject3d)
|
|
224
|
+
// 결함 R12 — *위치 정보 없는* carrier 가 rack 의 *root* 에 attach 됨 → 시각적으로 rack
|
|
225
|
+
// 의 origin (보통 한 모서리 또는 중심) 에 ghost 처럼 떠있게 됨.
|
|
226
|
+
console.log(' → R12: cellId 없는 carrier 는 rack root 에 attach → 시각 결함 가능.')
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// ── E4: dispose 의 부분적 실패 ─────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
describe('Edge: dispose 부분적 실패', () => {
|
|
233
|
+
it('R13 — dispose 중 예외 발생 시 state.data 는 *덮어쓰기 되지 않아야*', async () => {
|
|
234
|
+
const rack = new FakeRack()
|
|
235
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
236
|
+
const c = rack.obtainCarrier('A')!
|
|
237
|
+
|
|
238
|
+
// dispose 가 throw 하는 carrier 시뮬
|
|
239
|
+
c.dispose = function() {
|
|
240
|
+
this._disposed = true
|
|
241
|
+
throw new Error('dispose failed')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let thrown: any
|
|
245
|
+
try { await rack.receiveAt('B', c) } catch (e) { thrown = e }
|
|
246
|
+
|
|
247
|
+
// 결함 R13 발견 — 현재 코드는 dispose 의 throw 를 catch 안 함 → 전체 receiveAt 가 throw
|
|
248
|
+
// → state.data push 가 실행 안 됨 → carrier 가 *부분 정리된 상태* 로 남음
|
|
249
|
+
if (thrown) {
|
|
250
|
+
console.log(' → R13: dispose throw → receiveAt 가 *불완전* 종료. state.data 에 push 안 됨.')
|
|
251
|
+
console.log(' → carrier.parent 가 null (removeComponent 는 이미 됨) 이지만 _disposed=true 인 zombie 상태.')
|
|
252
|
+
;(c.parent === null).should.be.true()
|
|
253
|
+
c._disposed.should.be.true()
|
|
254
|
+
rack.records().length.should.equal(0) // ← 결함: record 누락
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ── E5: SlotTarget 의 carrier-mismatch ────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('Edge: SlotTarget 의 carrier 일관성', () => {
|
|
262
|
+
it('R14 — SlotTarget.receive 에 *예상치 못한 carrier* 가 들어와도 거부?', async () => {
|
|
263
|
+
const rack = new FakeRack()
|
|
264
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
265
|
+
const externalCarrier: any = {
|
|
266
|
+
placement: 'operation',
|
|
267
|
+
state: { id: 'external', type: 'foreign-type' },
|
|
268
|
+
parent: null,
|
|
269
|
+
dispose() { this._disposed = true }
|
|
270
|
+
}
|
|
271
|
+
// dest A 는 이미 record 있어 occupied. receive 호출 시 reject 되어야 함
|
|
272
|
+
let thrown: any
|
|
273
|
+
try { await rack.receiveAt('A', externalCarrier) } catch (e) { thrown = e }
|
|
274
|
+
thrown.should.not.be.null()
|
|
275
|
+
thrown.reason.should.equal('slot-occupied')
|
|
276
|
+
// 정상 거부 — externalCarrier 는 dispose 안 됐어야 함 (rollback)
|
|
277
|
+
;(externalCarrier._disposed !== true).should.be.true()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('R15 — receiveAt 가 *carrier 의 type 검증* 안 함 — 의도된 limitation?', async () => {
|
|
281
|
+
const rack = new FakeRack()
|
|
282
|
+
const weird: any = {
|
|
283
|
+
placement: 'operation',
|
|
284
|
+
state: { type: 'storage-cell' }, // 셀 타입의 carrier?? 이상한 입력
|
|
285
|
+
parent: null,
|
|
286
|
+
dispose() { this._disposed = true }
|
|
287
|
+
}
|
|
288
|
+
await rack.receiveAt('A', weird)
|
|
289
|
+
rack.records().length.should.equal(1)
|
|
290
|
+
rack.records()[0].type.should.equal('storage-cell')
|
|
291
|
+
console.log(' → R15: receiveAt 가 type 검증 안 함 — *어떤 carrier 도 받음*. 의도된 design.')
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ── E6: rapid 연속 obtainCarrier + receiveAt 의 race ──────────────────────
|
|
296
|
+
|
|
297
|
+
describe('Edge: 빠른 연속 호출의 race', () => {
|
|
298
|
+
it('R16 — obtainCarrier 직후 receiveAt 같은 cellId 로 다시 — 정상 동작?', async () => {
|
|
299
|
+
const rack = new FakeRack()
|
|
300
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
301
|
+
const c = rack.obtainCarrier('A')!
|
|
302
|
+
// 같은 cellId 로 다시 receiveAt — 자기 자신을 자기 자리로 환원 (의미: undo obtain)
|
|
303
|
+
await rack.receiveAt('A', c)
|
|
304
|
+
rack.records().length.should.equal(1)
|
|
305
|
+
rack.records()[0].cellId.should.equal('A')
|
|
306
|
+
rack.components.length.should.equal(0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('R17 — obtain → receive → obtain → receive 사이클 — state 누적 없음', async () => {
|
|
310
|
+
const rack = new FakeRack()
|
|
311
|
+
rack.state.data = [{ cellId: 'A', sku: 'X' }]
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < 50; i++) {
|
|
314
|
+
const c = rack.obtainCarrier('A')!
|
|
315
|
+
await rack.receiveAt('A', c)
|
|
316
|
+
}
|
|
317
|
+
rack.records().length.should.equal(1)
|
|
318
|
+
rack.records()[0].cellId.should.equal('A')
|
|
319
|
+
rack.records()[0].sku.should.equal('X')
|
|
320
|
+
rack.components.length.should.equal(0)
|
|
321
|
+
rack._slotAnchors.size.should.equal(1) // anchor 하나만 (singleton 보장)
|
|
322
|
+
})
|
|
323
|
+
})
|