@operato/scene-storage 10.0.0-beta.40 → 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 +29 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/box.js +18 -0
- package/dist/box.js.map +1 -1
- package/dist/crane-3d.d.ts +47 -2
- package/dist/crane-3d.js +246 -89
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +96 -12
- package/dist/crane.js +395 -100
- 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/pallet.d.ts +15 -0
- package/dist/pallet.js +38 -2
- package/dist/pallet.js.map +1 -1
- package/dist/parcel-3d.js +22 -18
- package/dist/parcel-3d.js.map +1 -1
- package/dist/parcel.d.ts +4 -3
- package/dist/parcel.js +24 -5
- package/dist/parcel.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 +165 -29
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +253 -32
- package/dist/storage-rack.js +726 -66
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box.ts +18 -0
- package/src/crane-3d.ts +258 -93
- package/src/crane.ts +445 -110
- package/src/index.ts +3 -4
- package/src/pallet.ts +50 -1
- package/src/parcel-3d.ts +23 -18
- package/src/parcel.ts +24 -5
- 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 +182 -29
- package/src/storage-rack.ts +819 -67
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-crane-geometry.ts +167 -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-phase-h-carrier-pickable.ts +4 -3
- 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 +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
- 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 -70
- package/dist/storage-cell.js +0 -197
- 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 -247
- package/test/test-rack-grid.ts +0 -77
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 실 production 결함 재현 — *동시 pickAndPlace* 의 mover 충돌.
|
|
3
|
+
*
|
|
4
|
+
* 사용자가 보고한 회귀 패턴 (transfer panel):
|
|
5
|
+
* - Phase 10 ✓ rack → crane (첫 pick 성공)
|
|
6
|
+
* - Phase 3 ✗ crane → crane all-slots-full (자기 자신에게 transfer 시도)
|
|
7
|
+
* - Phase 3 ✗ [model-layer] → crane all-slots-full (다른 carrier 가 또 시도)
|
|
8
|
+
* - Phase 1 ✗ crane → slot-target cannot-receive (dest 가 점유)
|
|
9
|
+
* - 크레인이 멈춰버림
|
|
10
|
+
*
|
|
11
|
+
* 가설: 두 개의 pickAndPlace 가 *동시 진행* 중. 한쪽은 사용자 script, 다른 한쪽은
|
|
12
|
+
* 정체불명. 두 카리어가 같은 crane forks slot 을 두고 경쟁 → 충돌.
|
|
13
|
+
*
|
|
14
|
+
* 이 테스트는 *그 시나리오를 재현* 해 *실제로 어떻게 깨지는지* 드러내는 게 목표.
|
|
15
|
+
* 통과하면 multi-mover 가 안전, 실패하면 framework / Mover 의 race condition 노출.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import 'should'
|
|
19
|
+
|
|
20
|
+
// ── 최소 Mover-like (Plan A 의 슬롯 컨트랙 + capacity 추적) ────────────────────
|
|
21
|
+
|
|
22
|
+
interface SlotDef { id: string; maxCount: number }
|
|
23
|
+
|
|
24
|
+
class FakeCrane {
|
|
25
|
+
slots: SlotDef[] = [{ id: 'forks', maxCount: 1 }]
|
|
26
|
+
components: any[] = []
|
|
27
|
+
_events: any[] = []
|
|
28
|
+
|
|
29
|
+
canReceive(_carrier: any): boolean {
|
|
30
|
+
const fork = this.slots[0]
|
|
31
|
+
const occupied = this.components.filter(c => c._transferSlotId === fork.id).length
|
|
32
|
+
return occupied < fork.maxCount
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async receive(carrier: any): Promise<void> {
|
|
36
|
+
if (!this.canReceive(carrier)) {
|
|
37
|
+
this._events.push({ type: 'rejected', reason: 'all-slots-full', carrier })
|
|
38
|
+
const err: any = new Error('crane forks full')
|
|
39
|
+
err.reason = 'all-slots-full'
|
|
40
|
+
throw err
|
|
41
|
+
}
|
|
42
|
+
// reparent
|
|
43
|
+
const op = carrier.parent
|
|
44
|
+
if (op?.removeComponent) op.removeComponent(carrier)
|
|
45
|
+
carrier.parent = this
|
|
46
|
+
this.components.push(carrier)
|
|
47
|
+
carrier._transferSlotId = 'forks'
|
|
48
|
+
this._events.push({ type: 'received', carrier })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async dispatch(carrier: any, target: any): Promise<void> {
|
|
52
|
+
if (target.canReceive && !target.canReceive(carrier)) {
|
|
53
|
+
this._events.push({ type: 'rejected', reason: 'cannot-receive', target })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
delete carrier._transferSlotId
|
|
57
|
+
if (typeof target.receive === 'function') await target.receive(carrier)
|
|
58
|
+
this._events.push({ type: 'dispatched', target })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 단순화: pick = source.removeComponent + crane.receive
|
|
62
|
+
async pick(carrier: any): Promise<void> {
|
|
63
|
+
if (carrier.parent === this) return // idempotent
|
|
64
|
+
await this.receive(carrier)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// place = await canReceive polling + dispatch
|
|
68
|
+
async place(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
|
|
69
|
+
const timeoutMs = options.timeoutMs ?? 1000 // 짧게 (테스트용)
|
|
70
|
+
const started = Date.now()
|
|
71
|
+
while (target.canReceive && !target.canReceive(carrier)) {
|
|
72
|
+
if (Date.now() - started > timeoutMs) {
|
|
73
|
+
throw new Error(`place timeout after ${timeoutMs}ms`)
|
|
74
|
+
}
|
|
75
|
+
await new Promise(r => setTimeout(r, 10))
|
|
76
|
+
}
|
|
77
|
+
await this.dispatch(carrier, target)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
|
|
81
|
+
await this.pick(carrier)
|
|
82
|
+
await this.place(carrier, target, options)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class FakeRack {
|
|
87
|
+
state: any = { data: [] }
|
|
88
|
+
components: any[] = []
|
|
89
|
+
|
|
90
|
+
records(): any[] { return this.state.data }
|
|
91
|
+
carrierAt(cellId: string): any {
|
|
92
|
+
return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
|
|
93
|
+
}
|
|
94
|
+
hasCarrierAt(cellId: string): boolean {
|
|
95
|
+
return !!this.carrierAt(cellId) || this.records().some((r: any) => r.cellId === cellId)
|
|
96
|
+
}
|
|
97
|
+
canReceiveAt(cellId: string): boolean { return !this.hasCarrierAt(cellId) }
|
|
98
|
+
canReceive(carrier: any): boolean { return this.canReceiveAt(carrier?.state?.cellId ?? '?') }
|
|
99
|
+
|
|
100
|
+
addComponent(c: any): void { c.parent = this; this.components.push(c) }
|
|
101
|
+
removeComponent(c: any): void {
|
|
102
|
+
const i = this.components.indexOf(c)
|
|
103
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
104
|
+
c.parent = null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
obtainCarrier(cellId: string): any {
|
|
108
|
+
const existing = this.carrierAt(cellId)
|
|
109
|
+
if (existing) return existing
|
|
110
|
+
const idx = this.records().findIndex((r: any) => r.cellId === cellId)
|
|
111
|
+
if (idx === -1) return null
|
|
112
|
+
const record = this.records()[idx]
|
|
113
|
+
const c: any = {
|
|
114
|
+
placement: 'operation',
|
|
115
|
+
state: { ...record, cellId },
|
|
116
|
+
parent: null,
|
|
117
|
+
dispose() { this._disposed = true }
|
|
118
|
+
}
|
|
119
|
+
this.addComponent(c)
|
|
120
|
+
this.state.data = this.records().filter((_: any, j: number) => j !== idx)
|
|
121
|
+
return c
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async receiveAt(cellId: string, carrier: any): Promise<void> {
|
|
125
|
+
if (!this.canReceiveAt(cellId)) {
|
|
126
|
+
const err: any = new Error('slot occupied')
|
|
127
|
+
err.reason = 'slot-occupied'
|
|
128
|
+
throw err
|
|
129
|
+
}
|
|
130
|
+
const p = carrier.parent
|
|
131
|
+
if (p?.removeComponent) p.removeComponent(carrier)
|
|
132
|
+
carrier.dispose?.()
|
|
133
|
+
const rec: any = { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }
|
|
134
|
+
this.state.data = [...this.records(), rec]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
slotTargetAt(cellId: string): any {
|
|
138
|
+
const rack = this
|
|
139
|
+
return {
|
|
140
|
+
canReceive: (c: any) => rack.canReceiveAt(cellId),
|
|
141
|
+
receive: (c: any) => rack.receiveAt(cellId, c)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Group 1: 동시 pickAndPlace 시나리오 ─────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe('Bug-finder: 동시 pickAndPlace', () => {
|
|
149
|
+
|
|
150
|
+
it('REPRODUCE — 두 carrier 가 *동시에* 같은 crane 으로 pick → 두 번째 reject', async () => {
|
|
151
|
+
const rack = new FakeRack()
|
|
152
|
+
rack.state.data = [{ cellId: 'A', sku: 'a' }, { cellId: 'B', sku: 'b' }]
|
|
153
|
+
const crane = new FakeCrane()
|
|
154
|
+
|
|
155
|
+
const cA = rack.obtainCarrier('A')!
|
|
156
|
+
const cB = rack.obtainCarrier('B')!
|
|
157
|
+
|
|
158
|
+
// 동시 pick
|
|
159
|
+
const results = await Promise.allSettled([
|
|
160
|
+
crane.pickAndPlace(cA, rack.slotTargetAt('X'), { timeoutMs: 100 }),
|
|
161
|
+
crane.pickAndPlace(cB, rack.slotTargetAt('Y'), { timeoutMs: 100 })
|
|
162
|
+
])
|
|
163
|
+
|
|
164
|
+
// 진단:
|
|
165
|
+
// - 첫 pick 성공 → crane 에 cA 또는 cB 중 하나
|
|
166
|
+
// - 두 번째 pick 은 crane full → reject ('all-slots-full')
|
|
167
|
+
// - 이게 사용자가 보는 Phase 3 의 정체
|
|
168
|
+
const rejected = results.filter(r => r.status === 'rejected')
|
|
169
|
+
rejected.length.should.be.greaterThanOrEqual(1)
|
|
170
|
+
// 거부 이유에 'all-slots-full' 또는 timeout 이 보임
|
|
171
|
+
const reasons = rejected.map(r => (r as PromiseRejectedResult).reason?.reason ?? (r as PromiseRejectedResult).reason?.message)
|
|
172
|
+
console.log(' → 두 번째 pick 의 reject 사유:', reasons.join(', '))
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('REPRODUCE — 첫 pickAndPlace 가 *stuck* (dest 점유) 이면 두 번째도 영향', async () => {
|
|
176
|
+
const rack = new FakeRack()
|
|
177
|
+
rack.state.data = [
|
|
178
|
+
{ cellId: 'A', sku: 'a' },
|
|
179
|
+
{ cellId: 'B', sku: 'b' },
|
|
180
|
+
{ cellId: 'X', sku: 'occupier' } // ← X 점유 (첫 pickAndPlace 의 dest)
|
|
181
|
+
]
|
|
182
|
+
const crane = new FakeCrane()
|
|
183
|
+
|
|
184
|
+
const cA = rack.obtainCarrier('A')!
|
|
185
|
+
const cB = rack.obtainCarrier('B')!
|
|
186
|
+
|
|
187
|
+
// 1st: A → X (X 점유라 영원히 stuck → timeout)
|
|
188
|
+
// 2nd: B → Y (X 와 무관하지만 crane 이 cA 보유 중이라 cB pick fail)
|
|
189
|
+
const results = await Promise.allSettled([
|
|
190
|
+
crane.pickAndPlace(cA, rack.slotTargetAt('X'), { timeoutMs: 50 }),
|
|
191
|
+
crane.pickAndPlace(cB, rack.slotTargetAt('Y'), { timeoutMs: 50 })
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
const failures = results.filter(r => r.status === 'rejected')
|
|
195
|
+
failures.length.should.be.greaterThanOrEqual(1)
|
|
196
|
+
console.log(' → 사용자 보고 사례 재현:', failures.map(f => (f as PromiseRejectedResult).reason?.reason ?? (f as PromiseRejectedResult).reason?.message))
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ── Group 2: 사용자 시나리오 정확 재현 ─────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe('Bug-finder: 사용자 보고 시나리오', () => {
|
|
203
|
+
|
|
204
|
+
it('REPRODUCE — original parcel (model-layer) + transient (obtainCarrier) 동시 pickAndPlace', async () => {
|
|
205
|
+
const rack = new FakeRack()
|
|
206
|
+
rack.state.data = [
|
|
207
|
+
{ cellId: '0-0-5' }, // 사용자 transient 의 source
|
|
208
|
+
{ cellId: '2-0-3', sku: 'occupier' } // dest — *이미 점유*
|
|
209
|
+
]
|
|
210
|
+
const modelLayer: any = {
|
|
211
|
+
components: [] as any[],
|
|
212
|
+
removeComponent(c: any) {
|
|
213
|
+
const i = this.components.indexOf(c)
|
|
214
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
215
|
+
c.parent = null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const originalParcel: any = {
|
|
219
|
+
placement: 'operation',
|
|
220
|
+
state: { id: 'parcel', type: 'parcel' },
|
|
221
|
+
parent: modelLayer,
|
|
222
|
+
dispose() { this._disposed = true }
|
|
223
|
+
}
|
|
224
|
+
modelLayer.components.push(originalParcel)
|
|
225
|
+
|
|
226
|
+
const crane = new FakeCrane()
|
|
227
|
+
const transient = rack.obtainCarrier('0-0-5')!
|
|
228
|
+
|
|
229
|
+
// 두 pickAndPlace 동시 실행 — 사용자 환경에서 무엇 fire 되는지 모르지만 *둘 다 fire 됐다고
|
|
230
|
+
// 가정* 했을 때 어떤 일이 벌어지는지 재현.
|
|
231
|
+
const r1 = crane.pickAndPlace(transient, rack.slotTargetAt('2-0-3'), { timeoutMs: 50 })
|
|
232
|
+
const r2 = crane.pickAndPlace(originalParcel, rack.slotTargetAt('0-0-6'), { timeoutMs: 50 })
|
|
233
|
+
|
|
234
|
+
const results = await Promise.allSettled([r1, r2])
|
|
235
|
+
|
|
236
|
+
// 진단:
|
|
237
|
+
// - 첫 pick (transient 또는 originalParcel) 성공 → crane 에 들어감
|
|
238
|
+
// - 두 번째 pick 시도 → crane full → reject (all-slots-full)
|
|
239
|
+
// - 첫 place 시도 → dest '2-0-3' 점유 → timeout (cannot-receive 영구)
|
|
240
|
+
// - 결과: crane 에 carrier 그대로 남음, 두 pickAndPlace 다 fail
|
|
241
|
+
|
|
242
|
+
const failed = results.filter(r => r.status === 'rejected')
|
|
243
|
+
console.log(' → 결과:', results.map(r => r.status === 'rejected'
|
|
244
|
+
? `REJECTED(${(r as PromiseRejectedResult).reason?.reason ?? (r as PromiseRejectedResult).reason?.message})`
|
|
245
|
+
: 'FULFILLED'
|
|
246
|
+
).join(', '))
|
|
247
|
+
|
|
248
|
+
failed.length.should.be.greaterThanOrEqual(1)
|
|
249
|
+
crane.components.length.should.equal(1) // ← *한 carrier 가 crane 에 stuck*
|
|
250
|
+
// ↑ 이게 사용자 환경의 "크레인이 멈춤" 의 직접 원인
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ── Group 3: timeout 으로 stuck 자동 복구 검증 ───────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe('Bug-finder: place timeout 동작', () => {
|
|
257
|
+
|
|
258
|
+
it('FIX VERIFY — dest 가 점유여도 timeout 후 throw, mover 가 풀림', async () => {
|
|
259
|
+
const rack = new FakeRack()
|
|
260
|
+
rack.state.data = [
|
|
261
|
+
{ cellId: 'A', sku: 'a' },
|
|
262
|
+
{ cellId: 'X', sku: 'occupied-forever' }
|
|
263
|
+
]
|
|
264
|
+
const crane = new FakeCrane()
|
|
265
|
+
const c = rack.obtainCarrier('A')!
|
|
266
|
+
|
|
267
|
+
const started = Date.now()
|
|
268
|
+
let thrown: any = null
|
|
269
|
+
try {
|
|
270
|
+
await crane.pickAndPlace(c, rack.slotTargetAt('X'), { timeoutMs: 100 })
|
|
271
|
+
} catch (e) {
|
|
272
|
+
thrown = e
|
|
273
|
+
}
|
|
274
|
+
const elapsed = Date.now() - started
|
|
275
|
+
|
|
276
|
+
thrown.should.not.be.null()
|
|
277
|
+
elapsed.should.be.greaterThanOrEqual(100)
|
|
278
|
+
elapsed.should.be.lessThan(500) // timeout 정확히 fire
|
|
279
|
+
// 단 — carrier 는 crane 에 그대로 남음 (timeout 후 정리 안 함)
|
|
280
|
+
crane.components.length.should.equal(1)
|
|
281
|
+
console.log(' → timeout 후 carrier 가 crane 에 잔존:', crane.components.length, '개')
|
|
282
|
+
// ↑ 결함 발견: timeout 으로 throw 해도 carrier 가 crane fork 위에 영구. 추가 fix 필요.
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('FIX REQUIRED — timeout 후 carrier 가 *어디로 가야* 하는가? (현재: 안 가서 mover lock 잔존)', async () => {
|
|
286
|
+
const rack = new FakeRack()
|
|
287
|
+
rack.state.data = [{ cellId: 'A' }, { cellId: 'X', sku: 'blocker' }]
|
|
288
|
+
const crane = new FakeCrane()
|
|
289
|
+
const c = rack.obtainCarrier('A')!
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await crane.pickAndPlace(c, rack.slotTargetAt('X'), { timeoutMs: 50 })
|
|
293
|
+
} catch {}
|
|
294
|
+
|
|
295
|
+
// 다음 pickAndPlace 시도 — crane 이 풀려 있어야 새 작업 받을 수 있는데...
|
|
296
|
+
const rack2Records = rack.state.data.length
|
|
297
|
+
// 사실: crane.components.length === 1 → 다음 pick 은 'all-slots-full' 로 실패
|
|
298
|
+
crane.components.length.should.equal(1)
|
|
299
|
+
crane.canReceive({}).should.be.false() // ← *진정한 결함*: timeout 후에도 풀이라고 인식
|
|
300
|
+
|
|
301
|
+
console.log(' → 후속 작업 차단됨. crane.canReceive = false (carrier 가 forks 에 stuck)')
|
|
302
|
+
console.log(' → 해결책: timeout 시 carrier 를 *원래 source 로 환원* 하거나 *명시 dispose*.')
|
|
303
|
+
})
|
|
304
|
+
})
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* R3 결함 — Mover.place 가 timeout 으로 fail 한 후 *carrier 가 mover 에 stuck* 되어
|
|
3
|
+
* mover 가 영구 잠기는 회귀. 해결책은 *rollback*: 실패 시 carrier 를 source 로 복귀,
|
|
4
|
+
* 또는 mover 에서 떼어 orphan 으로 풀어줌.
|
|
5
|
+
*
|
|
6
|
+
* 이 테스트들은 *원하는 동작* 을 명세함 — 현재 (rollback 미구현) 은 fail 해야 함.
|
|
7
|
+
* 구현 후엔 PASS 하면 fix 검증.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import 'should'
|
|
11
|
+
import * as THREE from 'three'
|
|
12
|
+
|
|
13
|
+
class FakeRack {
|
|
14
|
+
state: any = { data: [] }
|
|
15
|
+
components: any[] = []
|
|
16
|
+
records(): any[] { return this.state.data }
|
|
17
|
+
carrierAt(cellId: string): any {
|
|
18
|
+
return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
|
|
19
|
+
}
|
|
20
|
+
canReceiveAt(cellId: string, carrier?: any): boolean {
|
|
21
|
+
if (this.records().some(r => r?.cellId === cellId)) return false
|
|
22
|
+
const existing = this.carrierAt(cellId)
|
|
23
|
+
if (existing && existing !== carrier) return false
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
canReceive(carrier: any): boolean {
|
|
27
|
+
return this.canReceiveAt(carrier?.state?.cellId ?? '?', carrier)
|
|
28
|
+
}
|
|
29
|
+
addComponent(c: any): void { c.parent = this; this.components.push(c) }
|
|
30
|
+
removeComponent(c: any): void {
|
|
31
|
+
const i = this.components.indexOf(c)
|
|
32
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
33
|
+
c.parent = null
|
|
34
|
+
}
|
|
35
|
+
obtainCarrier(cellId: string): any {
|
|
36
|
+
const existing = this.carrierAt(cellId)
|
|
37
|
+
if (existing) return existing
|
|
38
|
+
const idx = this.records().findIndex(r => r?.cellId === cellId)
|
|
39
|
+
if (idx === -1) return null
|
|
40
|
+
const record = this.records()[idx]
|
|
41
|
+
const c: any = {
|
|
42
|
+
placement: 'operation',
|
|
43
|
+
state: { ...record, cellId },
|
|
44
|
+
parent: null,
|
|
45
|
+
_realObject: { object3d: new THREE.Group() },
|
|
46
|
+
dispose() { this._disposed = true; this._realObject.object3d.clear() }
|
|
47
|
+
}
|
|
48
|
+
this.addComponent(c)
|
|
49
|
+
this.state.data = this.records().filter(r => r?.cellId !== cellId)
|
|
50
|
+
return c
|
|
51
|
+
}
|
|
52
|
+
async receiveAt(cellId: string, carrier: any): Promise<void> {
|
|
53
|
+
if (!this.canReceiveAt(cellId, carrier)) {
|
|
54
|
+
const err: any = new Error('slot occupied')
|
|
55
|
+
err.reason = 'slot-occupied'
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
const p = carrier.parent
|
|
59
|
+
if (p?.removeComponent) p.removeComponent(carrier)
|
|
60
|
+
const obj = carrier._realObject?.object3d
|
|
61
|
+
if (obj?.parent?.remove) obj.parent.remove(obj)
|
|
62
|
+
carrier.dispose?.()
|
|
63
|
+
const remaining = this.records().filter(r => r?.cellId !== cellId)
|
|
64
|
+
this.state.data = [...remaining, { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }]
|
|
65
|
+
}
|
|
66
|
+
slotTargetAt(cellId: string): any {
|
|
67
|
+
const rack = this
|
|
68
|
+
return {
|
|
69
|
+
slotId: cellId,
|
|
70
|
+
holder: rack,
|
|
71
|
+
canReceive: (c: any) => rack.canReceiveAt(cellId, c),
|
|
72
|
+
receive: (c: any) => rack.receiveAt(cellId, c)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Optional Transferable.receive — 사용 안 함 (slot 기반 holder 의 source rollback 은
|
|
76
|
+
// slotTargetAt 로 시도)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Mover with rollback — *제안된* 동작
|
|
80
|
+
class FakeCrane {
|
|
81
|
+
components: any[] = []
|
|
82
|
+
forkObject3d = new THREE.Object3D()
|
|
83
|
+
constructor() { this.forkObject3d.name = 'crane-fork' }
|
|
84
|
+
|
|
85
|
+
canReceive(_carrier: any): boolean {
|
|
86
|
+
return this.components.filter(c => c._transferSlotId === 'forks').length < 1
|
|
87
|
+
}
|
|
88
|
+
async receive(carrier: any): Promise<void> {
|
|
89
|
+
if (!this.canReceive(carrier)) {
|
|
90
|
+
const err: any = new Error('crane full')
|
|
91
|
+
err.reason = 'all-slots-full'
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
const op = carrier.parent
|
|
95
|
+
if (op?.removeComponent) op.removeComponent(carrier)
|
|
96
|
+
carrier.parent = this
|
|
97
|
+
this.components.push(carrier)
|
|
98
|
+
carrier._transferSlotId = 'forks'
|
|
99
|
+
const obj = carrier._realObject?.object3d
|
|
100
|
+
if (obj) {
|
|
101
|
+
if (obj.parent?.remove) obj.parent.remove(obj)
|
|
102
|
+
this.forkObject3d.attach(obj)
|
|
103
|
+
obj.position.set(0, 0, 0)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
removeComponent(c: any): void {
|
|
107
|
+
const i = this.components.indexOf(c)
|
|
108
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
109
|
+
c.parent = null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async dispatch(carrier: any, target: any): Promise<void> {
|
|
113
|
+
if (target.canReceive && !target.canReceive(carrier)) {
|
|
114
|
+
const err: any = new Error('target rejected')
|
|
115
|
+
err.reason = 'cannot-receive'
|
|
116
|
+
throw err
|
|
117
|
+
}
|
|
118
|
+
delete carrier._transferSlotId
|
|
119
|
+
if (typeof target.receive === 'function') await target.receive(carrier)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async pick(carrier: any): Promise<void> {
|
|
123
|
+
if (carrier.parent === this) return
|
|
124
|
+
await this.receive(carrier)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async place(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
|
|
128
|
+
const timeoutMs = options.timeoutMs ?? 1000
|
|
129
|
+
const started = Date.now()
|
|
130
|
+
while (target.canReceive && !target.canReceive(carrier)) {
|
|
131
|
+
if (Date.now() - started > timeoutMs) {
|
|
132
|
+
throw new Error(`place timeout after ${timeoutMs}ms`)
|
|
133
|
+
}
|
|
134
|
+
await new Promise(r => setTimeout(r, 5))
|
|
135
|
+
}
|
|
136
|
+
await this.dispatch(carrier, target)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
|
|
140
|
+
// *Rollback* — pick 직전 source 캡쳐. place 실패 시 source 로 복귀 시도.
|
|
141
|
+
const sourceParent: any = carrier.parent
|
|
142
|
+
const sourceCellId: string | undefined = carrier.state?.cellId
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await this.pick(carrier)
|
|
146
|
+
await this.place(carrier, target, options)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// 실패 시 rollback — carrier 가 *지금 mover 의 child* 면 source 로 돌리거나 풀어줌.
|
|
149
|
+
if (this.components.includes(carrier)) {
|
|
150
|
+
const slotTarget = sourceParent?.slotTargetAt?.(sourceCellId)
|
|
151
|
+
if (slotTarget?.canReceive?.(carrier)) {
|
|
152
|
+
try {
|
|
153
|
+
await this.dispatch(carrier, slotTarget)
|
|
154
|
+
} catch {
|
|
155
|
+
// 마지막 수단 — mover 에서 detach (orphan)
|
|
156
|
+
this.removeComponent(carrier)
|
|
157
|
+
const obj = carrier._realObject?.object3d
|
|
158
|
+
if (obj?.parent?.remove) obj.parent.remove(obj)
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// source 도 못 받으면 orphan
|
|
162
|
+
this.removeComponent(carrier)
|
|
163
|
+
const obj = carrier._realObject?.object3d
|
|
164
|
+
if (obj?.parent?.remove) obj.parent.remove(obj)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw err
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Group 1: place timeout 시 carrier 가 source 로 복귀 ─────────────────────
|
|
173
|
+
|
|
174
|
+
describe('R3 fix: Mover rollback on place timeout', () => {
|
|
175
|
+
it('dest 점유로 timeout — carrier 가 *source rack 의 record* 로 복귀', async () => {
|
|
176
|
+
const rack = new FakeRack()
|
|
177
|
+
rack.state.data = [
|
|
178
|
+
{ cellId: 'A', sku: 'X' },
|
|
179
|
+
{ cellId: 'B', sku: 'occupied' }
|
|
180
|
+
]
|
|
181
|
+
const crane = new FakeCrane()
|
|
182
|
+
|
|
183
|
+
const c = rack.obtainCarrier('A')!
|
|
184
|
+
rack.records().length.should.equal(1) // A 빠지고 B 만
|
|
185
|
+
|
|
186
|
+
let thrown: any
|
|
187
|
+
try {
|
|
188
|
+
await crane.pickAndPlace(c, rack.slotTargetAt('B'), { timeoutMs: 100 })
|
|
189
|
+
} catch (e) {
|
|
190
|
+
thrown = e
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
thrown.should.not.be.null()
|
|
194
|
+
// *Rollback 검증* — carrier 가 다시 source A 의 record 로 복귀
|
|
195
|
+
rack.records().some((r: any) => r.cellId === 'A').should.be.true()
|
|
196
|
+
rack.records().some((r: any) => r.cellId === 'B').should.be.true() // B 도 그대로
|
|
197
|
+
rack.records().length.should.equal(2)
|
|
198
|
+
|
|
199
|
+
// crane 은 풀려있어야 함 — 후속 pickAndPlace 받을 수 있게
|
|
200
|
+
crane.components.length.should.equal(0)
|
|
201
|
+
crane.canReceive({}).should.be.true()
|
|
202
|
+
crane.forkObject3d.children.length.should.equal(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('source 도 점유 (record 가 다시 들어옴) → orphan 으로 풀어줌', async () => {
|
|
206
|
+
const rack = new FakeRack()
|
|
207
|
+
rack.state.data = [
|
|
208
|
+
{ cellId: 'A', sku: 'X' },
|
|
209
|
+
{ cellId: 'B', sku: 'blocker' }
|
|
210
|
+
]
|
|
211
|
+
const crane = new FakeCrane()
|
|
212
|
+
|
|
213
|
+
const c = rack.obtainCarrier('A')!
|
|
214
|
+
// 외부에서 A 자리에 다른 record push (경쟁 시나리오)
|
|
215
|
+
rack.state.data = [...rack.state.data, { cellId: 'A', sku: 'sneak' }]
|
|
216
|
+
|
|
217
|
+
let thrown: any
|
|
218
|
+
try {
|
|
219
|
+
await crane.pickAndPlace(c, rack.slotTargetAt('B'), { timeoutMs: 50 })
|
|
220
|
+
} catch (e) {
|
|
221
|
+
thrown = e
|
|
222
|
+
}
|
|
223
|
+
thrown.should.not.be.null()
|
|
224
|
+
|
|
225
|
+
// source A 가 다시 점유 → carrier 가 그쪽으로 못 가 → orphan 으로 풀어줌
|
|
226
|
+
crane.components.length.should.equal(0) // mover 풀림
|
|
227
|
+
crane.canReceive({}).should.be.true()
|
|
228
|
+
;(c.parent === null).should.be.true() // orphan
|
|
229
|
+
;(c._realObject.object3d.parent === null).should.be.true()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('두 번째 pickAndPlace 가 정상 진행 — rollback 후 mover 사용 가능', async () => {
|
|
233
|
+
const rack = new FakeRack()
|
|
234
|
+
rack.state.data = [
|
|
235
|
+
{ cellId: 'A' },
|
|
236
|
+
{ cellId: 'B', sku: 'blocker' },
|
|
237
|
+
{ cellId: 'C', sku: 'next' }
|
|
238
|
+
]
|
|
239
|
+
const crane = new FakeCrane()
|
|
240
|
+
|
|
241
|
+
// 1st — A → B (실패 예정)
|
|
242
|
+
const cA = rack.obtainCarrier('A')!
|
|
243
|
+
try {
|
|
244
|
+
await crane.pickAndPlace(cA, rack.slotTargetAt('B'), { timeoutMs: 50 })
|
|
245
|
+
} catch {}
|
|
246
|
+
|
|
247
|
+
crane.components.length.should.equal(0) // rollback OK
|
|
248
|
+
|
|
249
|
+
// 2nd — C → 빈 자리 (성공해야 함)
|
|
250
|
+
const cC = rack.obtainCarrier('C')!
|
|
251
|
+
await crane.pickAndPlace(cC, rack.slotTargetAt('D'), { timeoutMs: 100 })
|
|
252
|
+
|
|
253
|
+
rack.records().some((r: any) => r.cellId === 'D').should.be.true()
|
|
254
|
+
rack.records().some((r: any) => r.cellId === 'C').should.be.false()
|
|
255
|
+
crane.components.length.should.equal(0)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ── Group 2: pick 자체가 실패한 케이스 ──────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('R3 fix: pick 실패 시 carrier 그대로 source 에', async () => {
|
|
262
|
+
it('crane 이미 점유 → pick 실패 — carrier 가 source rack 에 그대로', async () => {
|
|
263
|
+
const rack = new FakeRack()
|
|
264
|
+
rack.state.data = [{ cellId: 'A' }]
|
|
265
|
+
const crane = new FakeCrane()
|
|
266
|
+
// crane 이 이미 다른 carrier 보유
|
|
267
|
+
const existing: any = {
|
|
268
|
+
placement: 'operation',
|
|
269
|
+
state: { type: 'parcel' },
|
|
270
|
+
parent: crane,
|
|
271
|
+
_transferSlotId: 'forks',
|
|
272
|
+
_realObject: { object3d: new THREE.Object3D() }
|
|
273
|
+
}
|
|
274
|
+
crane.components.push(existing)
|
|
275
|
+
|
|
276
|
+
const c = rack.obtainCarrier('A')!
|
|
277
|
+
|
|
278
|
+
let thrown: any
|
|
279
|
+
try {
|
|
280
|
+
await crane.pickAndPlace(c, rack.slotTargetAt('B'), { timeoutMs: 100 })
|
|
281
|
+
} catch (e) {
|
|
282
|
+
thrown = e
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
thrown.should.not.be.null()
|
|
286
|
+
// carrier 가 rack 의 child 그대로 (pick 자체가 실패라 mover 에 안 들어옴)
|
|
287
|
+
rack.components.includes(c).should.be.true()
|
|
288
|
+
crane.components.length.should.equal(1) // 기존 점유 그대로
|
|
289
|
+
})
|
|
290
|
+
})
|
|
@@ -93,13 +93,14 @@ describe('Phase H — Parcel pickupFrames', () => {
|
|
|
93
93
|
})
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
-
describe('Phase H invariant — carrier 별
|
|
97
|
-
it('Pallet 은 forklift-fork
|
|
96
|
+
describe('Phase H invariant — carrier 별 primary tool', () => {
|
|
97
|
+
it('Pallet 의 primary tool 은 forklift-fork (gripper 없음), Box 는 gripper 포함', () => {
|
|
98
98
|
const palletSrc = readSrc('../src/pallet.ts')
|
|
99
99
|
const boxSrc = readSrc('../src/box.ts')
|
|
100
|
+
// Pallet 은 forklift-fork primary, gripper 직접 안 잡음 (Pallet 위 Box 를 잡음)
|
|
100
101
|
palletSrc.should.match(/forklift-fork/)
|
|
101
102
|
palletSrc.should.not.match(/toolType:\s*['"]gripper['"]/)
|
|
103
|
+
// Box 는 gripper 포함 (primary). 호환성 위해 agv-deck / forklift-fork 도 추가 가능.
|
|
102
104
|
boxSrc.should.match(/gripper/)
|
|
103
|
-
boxSrc.should.not.match(/forklift-fork/)
|
|
104
105
|
})
|
|
105
106
|
})
|