@operato/scene-storage 10.0.0-beta.41 → 10.0.0-beta.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/crane.js +1 -1
- package/dist/crane.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/rack-grid-3d.d.ts +18 -7
- package/dist/rack-grid-3d.js +372 -69
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid-cell.d.ts +21 -72
- package/dist/rack-grid-cell.js +147 -243
- package/dist/rack-grid-cell.js.map +1 -1
- package/dist/rack-grid.d.ts +277 -56
- package/dist/rack-grid.js +1230 -695
- package/dist/rack-grid.js.map +1 -1
- package/dist/rack-materials.d.ts +9 -0
- package/dist/rack-materials.js +55 -0
- package/dist/rack-materials.js.map +1 -0
- package/dist/storage-rack-3d.d.ts +15 -0
- package/dist/storage-rack-3d.js +131 -30
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +242 -45
- package/dist/storage-rack.js +684 -106
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/crane.ts +1 -1
- package/src/index.ts +3 -4
- package/src/rack-grid-3d.ts +383 -80
- package/src/rack-grid-cell.ts +161 -305
- package/src/rack-grid.ts +1263 -762
- package/src/rack-materials.ts +61 -0
- package/src/storage-rack-3d.ts +144 -30
- package/src/storage-rack.ts +763 -111
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-external-to-rack.ts +461 -0
- package/test/test-mover-concurrent-bug.ts +304 -0
- package/test/test-mover-rollback.ts +290 -0
- package/test/test-r19-place-absorb.ts +174 -0
- package/test/test-rack-3d-attach-real.ts +301 -0
- package/test/test-rack-concurrent.ts +254 -0
- package/test/test-rack-edge-cases.ts +323 -0
- package/test/test-rack-grid-cell.ts +318 -0
- package/test/test-rack-grid-location.ts +657 -0
- package/test/test-real-3d-positioning.ts +158 -0
- package/test/test-slot-center-convention.ts +116 -0
- package/test/test-slot-target.ts +189 -0
- package/test/test-storage-rack-batched.ts +606 -0
- package/test/test-storage-rack-click.ts +329 -0
- package/test/test-storage-rack-slot-api.ts +357 -0
- package/test/test-toscene-convention.ts +162 -0
- package/test/test-user-scenario-sequential.ts +334 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/rack-column.d.ts +0 -35
- package/dist/rack-column.js +0 -258
- package/dist/rack-column.js.map +0 -1
- package/dist/rack-grid-helpers.d.ts +0 -28
- package/dist/rack-grid-helpers.js +0 -71
- package/dist/rack-grid-helpers.js.map +0 -1
- package/dist/rack-grid-location.d.ts +0 -37
- package/dist/rack-grid-location.js +0 -227
- package/dist/rack-grid-location.js.map +0 -1
- package/dist/storage-cell-3d.d.ts +0 -25
- package/dist/storage-cell-3d.js +0 -88
- package/dist/storage-cell-3d.js.map +0 -1
- package/dist/storage-cell.d.ts +0 -73
- package/dist/storage-cell.js +0 -215
- package/dist/storage-cell.js.map +0 -1
- package/src/rack-column.ts +0 -340
- package/src/rack-grid-helpers.ts +0 -77
- package/src/rack-grid-location.ts +0 -286
- package/src/storage-cell-3d.ts +0 -101
- package/src/storage-cell.ts +0 -267
- package/test/test-cell-position.ts +0 -105
- package/test/test-rack-grid.ts +0 -77
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* R19 — Mover.place 의 *Plan A absorb 미인식* 회귀.
|
|
3
|
+
*
|
|
4
|
+
* 결함 시퀀스:
|
|
5
|
+
* 1. crane.pickAndPlace(externalParcel, rack.slotTargetAt(cellId))
|
|
6
|
+
* 2. Mover.pick — 외부 parcel 을 crane forks 로
|
|
7
|
+
* 3. Mover.place:
|
|
8
|
+
* a. engage('place') 의 mid-Transfer → SlotTarget.receive → rack.receiveAt
|
|
9
|
+
* → parcel.dispose() + state.data.push(record). 여기서 carrier.parent = null.
|
|
10
|
+
* b. 후속 검사: `if (carrier.parent === holder) return`
|
|
11
|
+
* — holder 는 SlotTarget (wrapper). carrier.parent 는 null. 검사 false → 통과.
|
|
12
|
+
* c. canReceive polling 시작: rack.canReceiveAt(cellId) 가 *방금 push 된* record
|
|
13
|
+
* 때문에 false 영원 반환.
|
|
14
|
+
* d. 30s timeout → throw "Mover.place: holder did not become available within 30000ms"
|
|
15
|
+
* 4. 사용자 script 의 unhandled promise rejection.
|
|
16
|
+
*
|
|
17
|
+
* Fix: `Mover.place` 가 *carrier._disposed === true* 검사 추가. absorb 완료 시 polling
|
|
18
|
+
* 진입 전에 return.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import 'should'
|
|
22
|
+
|
|
23
|
+
describe('R19: Mover.place — Plan A absorb 후 30s timeout 결함', () => {
|
|
24
|
+
it('REPRODUCE — engage 가 carrier 를 *absorb* (dispose) 한 후 polling 에서 멈춤', async () => {
|
|
25
|
+
// 결함 시뮬: engage 가 carrier 를 dispose (state.data 에 record push). 그러면
|
|
26
|
+
// canReceive 가 false 영원 → place 가 polling 에서 stuck.
|
|
27
|
+
|
|
28
|
+
const carrier: any = {
|
|
29
|
+
state: { type: 'parcel' },
|
|
30
|
+
parent: 'mover',
|
|
31
|
+
_disposed: false
|
|
32
|
+
}
|
|
33
|
+
let stateData: any[] = []
|
|
34
|
+
|
|
35
|
+
// SlotTarget mock
|
|
36
|
+
const slotTarget: any = {
|
|
37
|
+
slotId: 'A',
|
|
38
|
+
canReceive: (c: any) => !stateData.some(r => r.cellId === 'A'),
|
|
39
|
+
async receive(c: any) {
|
|
40
|
+
// absorb: dispose + push record
|
|
41
|
+
c._disposed = true
|
|
42
|
+
c.parent = null
|
|
43
|
+
stateData.push({ cellId: 'A' })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// engage's mid-Transfer 시뮬 — sync executeSync 인 척
|
|
48
|
+
await slotTarget.receive(carrier)
|
|
49
|
+
carrier._disposed.should.be.true()
|
|
50
|
+
stateData.length.should.equal(1)
|
|
51
|
+
|
|
52
|
+
// 결함 검증: 후속 *parent === holder* 검사 가 false → polling 진입
|
|
53
|
+
;(carrier.parent !== slotTarget).should.be.true()
|
|
54
|
+
|
|
55
|
+
// polling: canReceive 영원 false
|
|
56
|
+
let pollCount = 0
|
|
57
|
+
const start = Date.now()
|
|
58
|
+
while (!slotTarget.canReceive(carrier)) {
|
|
59
|
+
pollCount++
|
|
60
|
+
if (pollCount > 5) break // 시뮬용 짧게
|
|
61
|
+
if (Date.now() - start > 100) break
|
|
62
|
+
await new Promise(r => setTimeout(r, 10))
|
|
63
|
+
}
|
|
64
|
+
pollCount.should.be.greaterThan(1) // ← 실 환경에선 30s 동안 수백 회
|
|
65
|
+
console.log(` → polling 횟수=${pollCount}, canReceive 영원 false (R19 결함 확인)`)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('FIX VERIFY — `_disposed` 검사 가 polling 진입 차단', async () => {
|
|
69
|
+
const carrier: any = {
|
|
70
|
+
state: { type: 'parcel' },
|
|
71
|
+
parent: 'mover',
|
|
72
|
+
_disposed: false
|
|
73
|
+
}
|
|
74
|
+
let stateData: any[] = []
|
|
75
|
+
const slotTarget: any = {
|
|
76
|
+
canReceive: () => !stateData.some(r => r.cellId === 'A'),
|
|
77
|
+
async receive(c: any) {
|
|
78
|
+
c._disposed = true
|
|
79
|
+
c.parent = null
|
|
80
|
+
stateData.push({ cellId: 'A' })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Simulated Mover.place after-engage
|
|
85
|
+
await slotTarget.receive(carrier) // engage's mid-Transfer absorb
|
|
86
|
+
|
|
87
|
+
// R19 fix — _disposed 검사:
|
|
88
|
+
function placeAfterEngage(c: any, holder: any): boolean {
|
|
89
|
+
if (c.parent === holder) return true // skip polling
|
|
90
|
+
if (c._disposed) return true // R19 fix — absorb 완료
|
|
91
|
+
return false // proceed to polling
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const shouldSkipPolling = placeAfterEngage(carrier, slotTarget)
|
|
95
|
+
shouldSkipPolling.should.be.true() // FIX: polling 진입 X
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('FIX VERIFY — disposed 검사 *없으면* polling 영원 (timeout)', () => {
|
|
99
|
+
const carrier: any = {
|
|
100
|
+
state: { type: 'parcel' },
|
|
101
|
+
parent: null, // absorb 후 null
|
|
102
|
+
_disposed: true // absorb 후 disposed
|
|
103
|
+
}
|
|
104
|
+
const slotTarget: any = { /* 점유 — canReceive false */ }
|
|
105
|
+
|
|
106
|
+
function placeAfterEngage_OLD(c: any, holder: any): boolean {
|
|
107
|
+
if (c.parent === holder) return true
|
|
108
|
+
// R19 fix 없음 — polling 진입
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const willPoll = !placeAfterEngage_OLD(carrier, slotTarget)
|
|
113
|
+
willPoll.should.be.true() // ← 결함: polling 진입 → 30s timeout 운명
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('R19: 사용자 시나리오 — 1차 성공, 2차 명확 에러', () => {
|
|
118
|
+
it('1차 호출 — 30s timeout 없이 즉시 완료', async () => {
|
|
119
|
+
const carrier: any = {
|
|
120
|
+
state: { type: 'parcel', id: 'parcel' },
|
|
121
|
+
parent: 'mover',
|
|
122
|
+
_disposed: false
|
|
123
|
+
}
|
|
124
|
+
let stateData: any[] = []
|
|
125
|
+
const slot: any = {
|
|
126
|
+
canReceive: () => !stateData.some(r => r.cellId === 'A'),
|
|
127
|
+
async receive(c: any) {
|
|
128
|
+
c._disposed = true
|
|
129
|
+
c.parent = null
|
|
130
|
+
stateData.push({ cellId: 'A' })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Mover.place (R19 fix 포함)
|
|
135
|
+
async function moverPlace(c: any, holder: any): Promise<void> {
|
|
136
|
+
await holder.receive(c)
|
|
137
|
+
if (c.parent === holder) return
|
|
138
|
+
if (c._disposed) return // R19 fix
|
|
139
|
+
// polling (도달하지 않아야 함)
|
|
140
|
+
throw new Error('polling reached — R19 fix not applied')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const started = Date.now()
|
|
144
|
+
await moverPlace(carrier, slot)
|
|
145
|
+
const elapsed = Date.now() - started
|
|
146
|
+
|
|
147
|
+
elapsed.should.be.lessThan(100) // 즉시 완료 (no 30s)
|
|
148
|
+
stateData.length.should.equal(1)
|
|
149
|
+
carrier._disposed.should.be.true()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('2차 호출 — disposed carrier 로 R18 가 *즉시 throw*', async () => {
|
|
153
|
+
const disposedCarrier: any = {
|
|
154
|
+
state: { type: 'parcel', id: 'parcel' },
|
|
155
|
+
parent: null,
|
|
156
|
+
_disposed: true // 이미 1차에서 dispose 됨
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Mover.pickAndPlace 의 R18 guard
|
|
160
|
+
function pickAndPlaceR18Check(c: any): void {
|
|
161
|
+
if (c._disposed) {
|
|
162
|
+
throw new Error('Mover.pickAndPlace: carrier is already disposed.')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let thrown: any
|
|
167
|
+
try {
|
|
168
|
+
pickAndPlaceR18Check(disposedCarrier)
|
|
169
|
+
} catch (e) { thrown = e }
|
|
170
|
+
|
|
171
|
+
thrown.should.not.be.null()
|
|
172
|
+
thrown.message.should.match(/disposed/)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Plan A 의 *진짜 Three.js* attach/detach 검증.
|
|
3
|
+
*
|
|
4
|
+
* 가짜 Object3D 대신 *실제 THREE.Object3D + Mesh + Group* 으로 ghost 와 텔레포트 의
|
|
5
|
+
* 원인 (Three.js scene graph 의 parent 추적 누락) 을 직접 확인.
|
|
6
|
+
*
|
|
7
|
+
* 시나리오:
|
|
8
|
+
* 1. carrier 의 Three.js parent transition: 없음 → slot anchor → crane fork → 없음
|
|
9
|
+
* 2. receiveAt 이 *실제로* object3d 를 scene graph 에서 분리하는지
|
|
10
|
+
* 3. 새 carrier 가 같은 cellId 로 obtain 됐을 때 *이전 instance 의 mesh 가 잔존하지
|
|
11
|
+
* 않는지* (ghost regression)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import 'should'
|
|
15
|
+
import * as THREE from 'three'
|
|
16
|
+
|
|
17
|
+
// ── 실제 things-scene 의 Carriable / RealObject 정신을 가능한 한 가깝게 모사 ─────
|
|
18
|
+
|
|
19
|
+
class RealCarrier {
|
|
20
|
+
state: any
|
|
21
|
+
parent: any = null
|
|
22
|
+
_realObject: { object3d: THREE.Object3D }
|
|
23
|
+
_disposed = false
|
|
24
|
+
|
|
25
|
+
constructor(state: any) {
|
|
26
|
+
this.state = state
|
|
27
|
+
// Parcel3D 처럼 *child mesh 가 있는 Group* 으로 — real 환경 모방
|
|
28
|
+
const group = new THREE.Group()
|
|
29
|
+
group.name = `parcel-${state.id ?? state.cellId ?? '?'}-group`
|
|
30
|
+
const bodyMesh = new THREE.Mesh(
|
|
31
|
+
new THREE.BoxGeometry(50, 50, 50),
|
|
32
|
+
new THREE.MeshBasicMaterial({ color: 0xc8a878 })
|
|
33
|
+
)
|
|
34
|
+
bodyMesh.name = 'parcel-body'
|
|
35
|
+
group.add(bodyMesh)
|
|
36
|
+
this._realObject = { object3d: group }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
applyHolderAttachPoint(): void {
|
|
40
|
+
const p: any = this.parent
|
|
41
|
+
if (!p?.attachPointFor) return
|
|
42
|
+
const point = p.attachPointFor(this)
|
|
43
|
+
if (!point?.attach) return
|
|
44
|
+
point.attach.attach(this._realObject.object3d)
|
|
45
|
+
if (point.localPosition) {
|
|
46
|
+
this._realObject.object3d.position.set(
|
|
47
|
+
point.localPosition.x, point.localPosition.y, point.localPosition.z
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
dispose(): void {
|
|
53
|
+
this._disposed = true
|
|
54
|
+
// RealObject.dispose 의 clear() 모방 — children 정리, object3d 자체는 안 떼는 결함
|
|
55
|
+
for (const child of [...this._realObject.object3d.children]) {
|
|
56
|
+
const mesh = child as THREE.Mesh
|
|
57
|
+
mesh.geometry?.dispose?.()
|
|
58
|
+
const mats = Array.isArray(mesh.material) ? mesh.material : (mesh.material ? [mesh.material] : [])
|
|
59
|
+
for (const m of mats) m?.dispose?.()
|
|
60
|
+
}
|
|
61
|
+
this._realObject.object3d.clear()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class RealRack {
|
|
66
|
+
state: any = { data: [] }
|
|
67
|
+
components: any[] = []
|
|
68
|
+
rootObject3d = new THREE.Group()
|
|
69
|
+
_slotAnchors = new Map<string, THREE.Object3D>()
|
|
70
|
+
|
|
71
|
+
constructor() { this.rootObject3d.name = 'rack-root' }
|
|
72
|
+
|
|
73
|
+
ensureSlotAnchor(cellId: string): THREE.Object3D {
|
|
74
|
+
let a = this._slotAnchors.get(cellId)
|
|
75
|
+
if (!a) {
|
|
76
|
+
a = new THREE.Object3D()
|
|
77
|
+
a.name = `slot:${cellId}`
|
|
78
|
+
this.rootObject3d.add(a)
|
|
79
|
+
this._slotAnchors.set(cellId, a)
|
|
80
|
+
}
|
|
81
|
+
return a
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
addComponent(c: any): void { c.parent = this; this.components.push(c) }
|
|
85
|
+
removeComponent(c: any): void {
|
|
86
|
+
const i = this.components.indexOf(c)
|
|
87
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
88
|
+
c.parent = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
attachPointFor(carrier: any) {
|
|
92
|
+
const cellId = carrier.state.cellId
|
|
93
|
+
return { attach: this.ensureSlotAnchor(cellId), localPosition: { x: 0, y: 0, z: 0 } }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
obtainCarrier(cellId: string): RealCarrier | null {
|
|
97
|
+
const i = this.state.data.findIndex((r: any) => r.cellId === cellId)
|
|
98
|
+
if (i === -1) return null
|
|
99
|
+
const record = this.state.data[i]
|
|
100
|
+
const c = new RealCarrier({ ...record, cellId })
|
|
101
|
+
this.addComponent(c)
|
|
102
|
+
c.applyHolderAttachPoint()
|
|
103
|
+
this.state.data = this.state.data.filter((_: any, j: number) => j !== i)
|
|
104
|
+
return c
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async receiveAt(cellId: string, carrier: RealCarrier): Promise<void> {
|
|
108
|
+
// Plan A flow:
|
|
109
|
+
// 1. removeComponent
|
|
110
|
+
const p = carrier.parent
|
|
111
|
+
if (p?.removeComponent) p.removeComponent(carrier)
|
|
112
|
+
|
|
113
|
+
// 2. 명시적 Three.js detach (RealObject.dispose 만으로 안 떼는 part 보완)
|
|
114
|
+
const obj = carrier._realObject?.object3d
|
|
115
|
+
if (obj?.parent?.remove) obj.parent.remove(obj)
|
|
116
|
+
|
|
117
|
+
// 3. dispose
|
|
118
|
+
carrier.dispose()
|
|
119
|
+
|
|
120
|
+
// 4. record push
|
|
121
|
+
const rec: any = { cellId, type: carrier.state.type ?? 'parcel' }
|
|
122
|
+
for (const k of Object.keys(carrier.state)) {
|
|
123
|
+
if (['id', 'left', 'top', 'zPos', 'cellId'].includes(k)) continue
|
|
124
|
+
rec[k] = carrier.state[k]
|
|
125
|
+
}
|
|
126
|
+
this.state.data.push(rec)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class RealCrane {
|
|
131
|
+
components: any[] = []
|
|
132
|
+
forkObject3d = new THREE.Object3D()
|
|
133
|
+
constructor() { this.forkObject3d.name = 'crane-fork' }
|
|
134
|
+
addComponent(c: any): void { c.parent = this; this.components.push(c) }
|
|
135
|
+
removeComponent(c: any): void {
|
|
136
|
+
const i = this.components.indexOf(c)
|
|
137
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
138
|
+
c.parent = null
|
|
139
|
+
}
|
|
140
|
+
simulatePick(c: any): void {
|
|
141
|
+
const op = c.parent
|
|
142
|
+
if (op?.removeComponent) op.removeComponent(c)
|
|
143
|
+
this.addComponent(c)
|
|
144
|
+
this.forkObject3d.attach(c._realObject.object3d)
|
|
145
|
+
c._realObject.object3d.position.set(0, 0, 0)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Group 1: Three.js scene graph 의 parent transition ────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('Real Three.js: carrier scene-graph parent transitions', () => {
|
|
152
|
+
it('갓 생성 — object3d.parent === null', () => {
|
|
153
|
+
const c = new RealCarrier({ cellId: 'X' })
|
|
154
|
+
;(c._realObject.object3d.parent === null).should.be.true()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('obtainCarrier 후 — object3d.parent.name === slot:A-0-1', () => {
|
|
158
|
+
const rack = new RealRack()
|
|
159
|
+
rack.state.data = [{ cellId: 'A-0-1' }]
|
|
160
|
+
const c = rack.obtainCarrier('A-0-1')!
|
|
161
|
+
c._realObject.object3d.parent!.name.should.equal('slot:A-0-1')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('crane.simulatePick 후 — object3d.parent.name === crane-fork', () => {
|
|
165
|
+
const rack = new RealRack()
|
|
166
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
167
|
+
const crane = new RealCrane()
|
|
168
|
+
const c = rack.obtainCarrier('A')!
|
|
169
|
+
crane.simulatePick(c)
|
|
170
|
+
c._realObject.object3d.parent!.name.should.equal('crane-fork')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('receiveAt 후 — object3d.parent === null (완전 detach)', async () => {
|
|
174
|
+
const rack = new RealRack()
|
|
175
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
176
|
+
const crane = new RealCrane()
|
|
177
|
+
const c = rack.obtainCarrier('A')!
|
|
178
|
+
crane.simulatePick(c)
|
|
179
|
+
await rack.receiveAt('B', c)
|
|
180
|
+
;(c._realObject.object3d.parent === null).should.be.true()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ── Group 2: ghost 검증 — receiveAt 후 visible mesh 잔존 X ───────────────────
|
|
185
|
+
|
|
186
|
+
describe('Real Three.js: ghost 없음', () => {
|
|
187
|
+
it('receiveAt 후 — crane fork 의 자식에 아무도 없음 (mover idle)', async () => {
|
|
188
|
+
const rack = new RealRack()
|
|
189
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
190
|
+
const crane = new RealCrane()
|
|
191
|
+
const c = rack.obtainCarrier('A')!
|
|
192
|
+
crane.simulatePick(c)
|
|
193
|
+
|
|
194
|
+
crane.forkObject3d.children.length.should.equal(1) // 들고 있는 동안
|
|
195
|
+
|
|
196
|
+
await rack.receiveAt('B', c)
|
|
197
|
+
crane.forkObject3d.children.length.should.equal(0) // 내려놓은 후 비어있음
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('receiveAt 후 — slot anchor (source) 의 자식 비어있음', async () => {
|
|
201
|
+
const rack = new RealRack()
|
|
202
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
203
|
+
const crane = new RealCrane()
|
|
204
|
+
const sourceAnchor = rack.ensureSlotAnchor('A')
|
|
205
|
+
|
|
206
|
+
const c = rack.obtainCarrier('A')!
|
|
207
|
+
sourceAnchor.children.length.should.equal(1) // obtain 직후 attached
|
|
208
|
+
|
|
209
|
+
crane.simulatePick(c)
|
|
210
|
+
sourceAnchor.children.length.should.equal(0) // pick 후 source 비어있음
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('receiveAt 후 — dest slot anchor 에도 아무것도 없음 (record 만 있을 뿐)', async () => {
|
|
214
|
+
const rack = new RealRack()
|
|
215
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
216
|
+
const crane = new RealCrane()
|
|
217
|
+
const c = rack.obtainCarrier('A')!
|
|
218
|
+
crane.simulatePick(c)
|
|
219
|
+
await rack.receiveAt('B', c)
|
|
220
|
+
|
|
221
|
+
// B 의 anchor 는 미리 생성되어 있을 수도 / 없을 수도. 어쨌든 carrier 의 object3d 는 거기 없음
|
|
222
|
+
const bAnchor = rack._slotAnchors.get('B')
|
|
223
|
+
if (bAnchor) bAnchor.children.length.should.equal(0)
|
|
224
|
+
// record 는 state.data 에 있어야 함
|
|
225
|
+
rack.state.data.length.should.equal(1)
|
|
226
|
+
rack.state.data[0].cellId.should.equal('B')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('carrier 의 *mesh children* 도 정리됨 (geometry/material disposed)', async () => {
|
|
230
|
+
const rack = new RealRack()
|
|
231
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
232
|
+
const crane = new RealCrane()
|
|
233
|
+
const c = rack.obtainCarrier('A')!
|
|
234
|
+
crane.simulatePick(c)
|
|
235
|
+
|
|
236
|
+
const bodyMesh = c._realObject.object3d.children[0] as THREE.Mesh
|
|
237
|
+
// dispose 전엔 살아있음
|
|
238
|
+
bodyMesh.should.not.be.undefined()
|
|
239
|
+
|
|
240
|
+
await rack.receiveAt('B', c)
|
|
241
|
+
|
|
242
|
+
// carrier object3d 자체는 empty Group
|
|
243
|
+
c._realObject.object3d.children.length.should.equal(0)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// ── Group 3: 반복 pickAndPlace — ghost 누적 없음 ─────────────────────────────
|
|
248
|
+
|
|
249
|
+
describe('Real Three.js: 반복 pickAndPlace 회귀 차단', () => {
|
|
250
|
+
it('A→B 10번 왕복 — crane fork 와 두 slot anchor 가 매 사이클마다 깨끗', async () => {
|
|
251
|
+
const rack = new RealRack()
|
|
252
|
+
rack.state.data = [{ cellId: 'A', sku: 'X' }]
|
|
253
|
+
const crane = new RealCrane()
|
|
254
|
+
const anchorA = rack.ensureSlotAnchor('A')
|
|
255
|
+
const anchorB = rack.ensureSlotAnchor('B')
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < 10; i++) {
|
|
258
|
+
// A → B
|
|
259
|
+
const cAB = rack.obtainCarrier('A')!
|
|
260
|
+
cAB.should.not.be.null()
|
|
261
|
+
crane.simulatePick(cAB)
|
|
262
|
+
await rack.receiveAt('B', cAB)
|
|
263
|
+
crane.forkObject3d.children.length.should.equal(0)
|
|
264
|
+
anchorA.children.length.should.equal(0)
|
|
265
|
+
anchorB.children.length.should.equal(0)
|
|
266
|
+
|
|
267
|
+
// B → A
|
|
268
|
+
const cBA = rack.obtainCarrier('B')!
|
|
269
|
+
cBA.should.not.be.null()
|
|
270
|
+
crane.simulatePick(cBA)
|
|
271
|
+
await rack.receiveAt('A', cBA)
|
|
272
|
+
crane.forkObject3d.children.length.should.equal(0)
|
|
273
|
+
anchorA.children.length.should.equal(0)
|
|
274
|
+
anchorB.children.length.should.equal(0)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 끝에 정확히 1 record at A
|
|
278
|
+
rack.state.data.length.should.equal(1)
|
|
279
|
+
rack.state.data[0].cellId.should.equal('A')
|
|
280
|
+
rack.state.data[0].sku.should.equal('X')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('대량 데이터 (100 records) — 모두 다른 dest 로 이동, ghost X', async () => {
|
|
284
|
+
const rack = new RealRack()
|
|
285
|
+
rack.state.data = Array.from({ length: 100 }, (_, i) => ({ cellId: `src-${i}`, sku: `S${i}` }))
|
|
286
|
+
const crane = new RealCrane()
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < 100; i++) {
|
|
289
|
+
const c = rack.obtainCarrier(`src-${i}`)!
|
|
290
|
+
crane.simulatePick(c)
|
|
291
|
+
await rack.receiveAt(`dst-${i}`, c)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
rack.state.data.length.should.equal(100)
|
|
295
|
+
rack.state.data.every((r: any) => r.cellId.startsWith('dst-')).should.be.true()
|
|
296
|
+
crane.components.length.should.equal(0)
|
|
297
|
+
crane.forkObject3d.children.length.should.equal(0)
|
|
298
|
+
// 모든 src anchor 와 dst anchor 의 자식 0
|
|
299
|
+
for (const [, a] of rack._slotAnchors) a.children.length.should.equal(0)
|
|
300
|
+
})
|
|
301
|
+
})
|