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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/MIGRATION-plan-a-slot-api.md +266 -0
  3. package/PLAN-A-rack-as-slot-holder.md +164 -0
  4. package/dist/crane.js +1 -1
  5. package/dist/crane.js.map +1 -1
  6. package/dist/index.d.ts +3 -4
  7. package/dist/index.js +1 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/parcel-3d.js +42 -9
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.d.ts +18 -7
  12. package/dist/rack-grid-3d.js +372 -69
  13. package/dist/rack-grid-3d.js.map +1 -1
  14. package/dist/rack-grid-cell.d.ts +21 -72
  15. package/dist/rack-grid-cell.js +147 -243
  16. package/dist/rack-grid-cell.js.map +1 -1
  17. package/dist/rack-grid.d.ts +277 -56
  18. package/dist/rack-grid.js +1230 -695
  19. package/dist/rack-grid.js.map +1 -1
  20. package/dist/rack-materials.d.ts +9 -0
  21. package/dist/rack-materials.js +55 -0
  22. package/dist/rack-materials.js.map +1 -0
  23. package/dist/storage-rack-3d.d.ts +15 -0
  24. package/dist/storage-rack-3d.js +131 -30
  25. package/dist/storage-rack-3d.js.map +1 -1
  26. package/dist/storage-rack.d.ts +242 -45
  27. package/dist/storage-rack.js +684 -106
  28. package/dist/storage-rack.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/crane.ts +1 -1
  31. package/src/index.ts +3 -4
  32. package/src/parcel-3d.ts +41 -9
  33. package/src/rack-grid-3d.ts +383 -80
  34. package/src/rack-grid-cell.ts +161 -305
  35. package/src/rack-grid.ts +1263 -762
  36. package/src/rack-materials.ts +61 -0
  37. package/src/storage-rack-3d.ts +144 -30
  38. package/src/storage-rack.ts +763 -111
  39. package/test/test-carrier-lifecycle.ts +361 -0
  40. package/test/test-coord-alignment.ts +201 -0
  41. package/test/test-external-to-rack.ts +461 -0
  42. package/test/test-mover-concurrent-bug.ts +304 -0
  43. package/test/test-mover-rollback.ts +290 -0
  44. package/test/test-r19-place-absorb.ts +174 -0
  45. package/test/test-rack-3d-attach-real.ts +301 -0
  46. package/test/test-rack-concurrent.ts +254 -0
  47. package/test/test-rack-edge-cases.ts +323 -0
  48. package/test/test-rack-grid-cell.ts +318 -0
  49. package/test/test-rack-grid-location.ts +657 -0
  50. package/test/test-real-3d-positioning.ts +158 -0
  51. package/test/test-slot-center-convention.ts +116 -0
  52. package/test/test-slot-target.ts +189 -0
  53. package/test/test-storage-rack-batched.ts +606 -0
  54. package/test/test-storage-rack-click.ts +329 -0
  55. package/test/test-storage-rack-slot-api.ts +357 -0
  56. package/test/test-toscene-convention.ts +162 -0
  57. package/test/test-user-scenario-sequential.ts +334 -0
  58. package/translations/en.json +2 -0
  59. package/translations/ja.json +2 -0
  60. package/translations/ko.json +2 -0
  61. package/translations/ms.json +2 -0
  62. package/translations/zh.json +2 -0
  63. package/tsconfig.tsbuildinfo +1 -1
  64. package/dist/rack-column.d.ts +0 -35
  65. package/dist/rack-column.js +0 -258
  66. package/dist/rack-column.js.map +0 -1
  67. package/dist/rack-grid-helpers.d.ts +0 -28
  68. package/dist/rack-grid-helpers.js +0 -71
  69. package/dist/rack-grid-helpers.js.map +0 -1
  70. package/dist/rack-grid-location.d.ts +0 -37
  71. package/dist/rack-grid-location.js +0 -227
  72. package/dist/rack-grid-location.js.map +0 -1
  73. package/dist/storage-cell-3d.d.ts +0 -25
  74. package/dist/storage-cell-3d.js +0 -88
  75. package/dist/storage-cell-3d.js.map +0 -1
  76. package/dist/storage-cell.d.ts +0 -73
  77. package/dist/storage-cell.js +0 -215
  78. package/dist/storage-cell.js.map +0 -1
  79. package/src/rack-column.ts +0 -340
  80. package/src/rack-grid-helpers.ts +0 -77
  81. package/src/rack-grid-location.ts +0 -286
  82. package/src/storage-cell-3d.ts +0 -101
  83. package/src/storage-cell.ts +0 -267
  84. package/test/test-cell-position.ts +0 -105
  85. package/test/test-rack-grid.ts +0 -77
@@ -0,0 +1,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
+ })