@operato/scene-storage 10.0.0-beta.44 → 10.0.0-beta.46

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 (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/crane-3d.d.ts +10 -0
  3. package/dist/crane-3d.js +34 -5
  4. package/dist/crane-3d.js.map +1 -1
  5. package/dist/crane.d.ts +136 -6
  6. package/dist/crane.js +567 -46
  7. package/dist/crane.js.map +1 -1
  8. package/dist/parcel-3d.d.ts +1 -0
  9. package/dist/parcel-3d.js +18 -1
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.js +26 -8
  12. package/dist/rack-grid-3d.js.map +1 -1
  13. package/dist/rack-grid.d.ts +94 -10
  14. package/dist/rack-grid.js +468 -86
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/storage-rack-3d.js +1 -1
  17. package/dist/storage-rack-3d.js.map +1 -1
  18. package/dist/storage-rack.d.ts +31 -6
  19. package/dist/storage-rack.js +96 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +615 -55
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +488 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +96 -14
  29. package/test/test-coord-alignment.ts +2 -2
  30. package/test/test-crane-bay-match.ts +130 -0
  31. package/test/test-crane-binding-resolve.ts +168 -0
  32. package/test/test-crane-duration.ts +90 -0
  33. package/test/test-crane-rotation-reach.ts +218 -0
  34. package/test/test-rack-grid-3d-alignment.ts +235 -0
  35. package/test/test-rack-grid-3d-attach-real.ts +375 -0
  36. package/test/test-rack-grid-cell.ts +2 -2
  37. package/test/test-rack-grid-location.ts +2 -2
  38. package/test/test-rack-grid-occupied-slots.ts +165 -0
  39. package/test/test-rack-grid-picking-position.ts +154 -0
  40. package/test/test-rack-grid-slot-api.ts +483 -0
  41. package/test/test-slot-ids-enumeration.ts +137 -0
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,154 @@
1
+ /*
2
+ * RackGrid — picking 위치 정밀 검증.
3
+ *
4
+ * 사용자 보고: obtainCarrier 가 만든 carrier 가 *3D 에서 보이는 stock 자리* 와
5
+ * 다른 위치에 picking 됨.
6
+ *
7
+ * 가설: stock InstancedMesh 의 3D 위치 식 (rack-grid-3d.ts:359-363) 과 내가
8
+ * 새로 작성한 obtainCarrier 의 carrier 좌표 식 (rack-grid.ts) 이 *서로 다른
9
+ * 좌표계 / 다른 분할 방식* 을 사용해 동일 cell 에서 *다른 3D 위치*.
10
+ *
11
+ * stock 식 (center origin, 균등 분할):
12
+ * cx = (col - cols/2 + 0.5) * bayW (bayW = rackWidth / cols)
13
+ * cz = (row - rows/2 + 0.5) * bayD (bayD = rackHeight / rows)
14
+ *
15
+ * carrier 식 (top-left origin, widths/heights *비례* 분할):
16
+ * innerX = (cumW + widths[col]/2) / wSum * rackWidth
17
+ * innerY = (cumH + heights[row]/2) / hSum * rackDepthY
18
+ *
19
+ * parent center-origin 변환 가정:
20
+ * carrierCx = innerX - rackWidth/2
21
+ * carrierCz = innerY - rackHeight/2
22
+ *
23
+ * 이 두 값이 같은 (col,row) 에서 *같은가* — 직접 비교.
24
+ */
25
+
26
+ import 'should'
27
+
28
+ // ── stock 위치 식 (rack-grid-3d.ts 의 matrixFor 발췌) ────────────────────────
29
+ function stockPosition(col: number, row: number, opts: {
30
+ cols: number; rows: number; rackWidth: number; rackHeight: number
31
+ }): { cx: number; cz: number } {
32
+ const { cols, rows, rackWidth, rackHeight } = opts
33
+ const bayW = rackWidth / cols
34
+ const bayD = rackHeight / rows
35
+ return {
36
+ cx: (col - cols / 2 + 0.5) * bayW,
37
+ cz: (row - rows / 2 + 0.5) * bayD
38
+ }
39
+ }
40
+
41
+ // ── carrier 위치 식 (rack-grid.ts 의 obtainCarrier 발췌) ────────────────────
42
+ function carrierInner(col: number, row: number, opts: {
43
+ widths: number[]; heights: number[]; rackWidth: number; rackHeight: number
44
+ }): { x: number; y: number } {
45
+ const { widths, heights, rackWidth, rackHeight } = opts
46
+ const wSum = widths.reduce((s, w) => s + w, 0)
47
+ const hSum = heights.reduce((s, h) => s + h, 0)
48
+ let cumW = 0
49
+ for (let c = 0; c < col; c++) cumW += widths[c]
50
+ let cumH = 0
51
+ for (let r = 0; r < row; r++) cumH += heights[r]
52
+ return {
53
+ x: (cumW + widths[col] / 2) / wSum * rackWidth,
54
+ y: (cumH + heights[row] / 2) / hSum * rackHeight
55
+ }
56
+ }
57
+
58
+ // parent 의 center-origin 변환 가정 — 자식 2D top-left origin → 3D center origin.
59
+ function toCenterOrigin(inner: { x: number; y: number }, rackWidth: number, rackHeight: number) {
60
+ return {
61
+ cx: inner.x - rackWidth / 2,
62
+ cz: inner.y - rackHeight / 2
63
+ }
64
+ }
65
+
66
+ // ── 검증 ────────────────────────────────────────────────────────────────────
67
+
68
+ describe('RackGrid picking 위치 — stock 식 vs carrier 식 직접 비교', () => {
69
+ it('uniform widths/heights — 두 식이 일치해야 한다', () => {
70
+ const cols = 5, rows = 3
71
+ const rackWidth = 400, rackHeight = 200
72
+ const widths = new Array(cols).fill(1)
73
+ const heights = new Array(rows).fill(1)
74
+
75
+ for (let col = 0; col < cols; col++) {
76
+ for (let row = 0; row < rows; row++) {
77
+ const stock = stockPosition(col, row, { cols, rows, rackWidth, rackHeight })
78
+ const inner = carrierInner(col, row, { widths, heights, rackWidth, rackHeight })
79
+ const carrier = toCenterOrigin(inner, rackWidth, rackHeight)
80
+
81
+ carrier.cx.should.be.approximately(stock.cx, 1e-9,
82
+ `mismatch at col=${col} row=${row}: carrier=${carrier.cx} stock=${stock.cx}`)
83
+ carrier.cz.should.be.approximately(stock.cz, 1e-9,
84
+ `mismatch at col=${col} row=${row}: carrier=${carrier.cz} stock=${stock.cz}`)
85
+ }
86
+ }
87
+ })
88
+
89
+ it('non-uniform widths — *bug 노출*: 두 식이 어긋남', () => {
90
+ // widths=[1,3] → 첫 cell 25%, 둘째 75% (carrier 식)
91
+ // stock 식은 widths 무시 — 균등 분할 (50% 50%)
92
+ const cols = 2, rows = 1
93
+ const rackWidth = 400, rackHeight = 100
94
+ const widths = [1, 3]
95
+ const heights = [1]
96
+
97
+ // col=0 비교
98
+ const stock0 = stockPosition(0, 0, { cols, rows, rackWidth, rackHeight })
99
+ const inner0 = carrierInner(0, 0, { widths, heights, rackWidth, rackHeight })
100
+ const carrier0 = toCenterOrigin(inner0, rackWidth, rackHeight)
101
+
102
+ // stock col=0 cx = (0 - 1 + 0.5) * 200 = -100 (균등 — bay 0 center)
103
+ stock0.cx.should.equal(-100)
104
+ // carrier inner col=0 x = (0 + 0.5)/4 * 400 = 50 → cx = 50 - 200 = -150
105
+ carrier0.cx.should.equal(-150)
106
+ // 둘이 다름 — bug 노출
107
+ carrier0.cx.should.not.equal(stock0.cx)
108
+ })
109
+
110
+ it('non-uniform heights — 마찬가지로 두 식이 어긋남', () => {
111
+ const cols = 1, rows = 2
112
+ const rackWidth = 100, rackHeight = 400
113
+ const widths = [1]
114
+ const heights = [1, 3]
115
+
116
+ const stock = stockPosition(0, 1, { cols, rows, rackWidth, rackHeight })
117
+ const inner = carrierInner(0, 1, { widths, heights, rackWidth, rackHeight })
118
+ const carrier = toCenterOrigin(inner, rackWidth, rackHeight)
119
+
120
+ // stock row=1 cz = (1 - 1 + 0.5) * 200 = 100 (균등 분할의 row=1 center)
121
+ stock.cz.should.equal(100)
122
+ // carrier row=1: cumH=1, (1 + 1.5)/4 * 400 = 250 → cz = 250 - 200 = 50
123
+ carrier.cz.should.equal(50)
124
+ carrier.cz.should.not.equal(stock.cz)
125
+ })
126
+
127
+ // ── *fix 후* 예상 동작 — carrier 식을 *stock 식과 동일하게 정렬* 한 경우 ─
128
+ it('fix 가정 — carrier 식이 stock 식과 동일하면 (균등 분할 + center origin) 일치', () => {
129
+ // 가정된 fix: carrier 도 균등 분할 + center origin 직접 사용
130
+ // (widths/heights 비례 무시. RackGrid 의 stock 자체가 균등 분할이므로 동일.)
131
+ function carrierFixed(col: number, row: number, opts: {
132
+ cols: number; rows: number; rackWidth: number; rackHeight: number
133
+ }): { cx: number; cz: number } {
134
+ const { cols, rows, rackWidth, rackHeight } = opts
135
+ const bayW = rackWidth / cols
136
+ const bayD = rackHeight / rows
137
+ return {
138
+ cx: (col - cols / 2 + 0.5) * bayW,
139
+ cz: (row - rows / 2 + 0.5) * bayD
140
+ }
141
+ }
142
+
143
+ const cols = 5, rows = 3
144
+ const rackWidth = 400, rackHeight = 200
145
+ for (let col = 0; col < cols; col++) {
146
+ for (let row = 0; row < rows; row++) {
147
+ const stock = stockPosition(col, row, { cols, rows, rackWidth, rackHeight })
148
+ const carrier = carrierFixed(col, row, { cols, rows, rackWidth, rackHeight })
149
+ carrier.cx.should.equal(stock.cx)
150
+ carrier.cz.should.equal(stock.cz)
151
+ }
152
+ }
153
+ })
154
+ })
@@ -0,0 +1,483 @@
1
+ /*
2
+ * RackGrid — Plan A Slot API unit tests.
3
+ *
4
+ * storage-rack 의 test-storage-rack-slot-api.ts 패턴 그대로. things-scene 의
5
+ * full pipeline 우회하고 *순수 슬롯 의미론 + RackGrid 고유 (cellOverrides
6
+ * isEmpty / widths-heights 비례 좌표 계산)* 만 격리 검증.
7
+ *
8
+ * 검증 대상:
9
+ * - obtainCarrier: RackGrid 자체 records 에서 transient materialize (자식 위임 X)
10
+ * - receiveAt: carrier dispose + records push
11
+ * - canReceiveAt: isEmpty cell 거부 + 점유 거부
12
+ * - hasCarrierAt: record + carrier-child 양쪽 인식
13
+ * - recordFromCarrier: SKIP_KEYS 제외
14
+ * - 좌표 계산: widths/heights 비례 분할의 정확성 (picking 위치 일치)
15
+ */
16
+
17
+ import 'should'
18
+
19
+ // ── Fixture: RackGrid 의 핵심 의미론을 격리한 mini-grid ────────────────────────
20
+
21
+ class MiniRackGrid {
22
+ state: any
23
+ components: any[] = []
24
+ _disposed = new Set<any>()
25
+
26
+ constructor(opts: {
27
+ columns?: number
28
+ rows?: number
29
+ shelves?: number
30
+ width?: number // 3D X (RackGrid 의 state.width)
31
+ height?: number // 3D Z = 2D height (state.height)
32
+ widths?: number[]
33
+ heights?: number[]
34
+ cellOverrides?: { [posKey: string]: { isEmpty?: boolean } }
35
+ initialData?: any[]
36
+ } = {}) {
37
+ this.state = {
38
+ columns: opts.columns ?? 5,
39
+ rows: opts.rows ?? 3,
40
+ shelves: opts.shelves ?? 4,
41
+ width: opts.width ?? 400,
42
+ height: opts.height ?? 200,
43
+ widths: opts.widths,
44
+ heights: opts.heights,
45
+ cellOverrides: opts.cellOverrides ?? {},
46
+ data: opts.initialData ?? []
47
+ }
48
+ }
49
+
50
+ // ── accessors (NaN-safe) ─────────────────────────────────
51
+ get columns(): number {
52
+ const v = this.state.columns
53
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 5))
54
+ }
55
+ get rackRows(): number {
56
+ const v = this.state.rows
57
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 1))
58
+ }
59
+ get shelves(): number {
60
+ const v = this.state.shelves
61
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 4))
62
+ }
63
+
64
+ get widths(): number[] {
65
+ const w = this.state.widths as number[] | undefined
66
+ if (!w) return new Array(this.columns).fill(1)
67
+ if (w.length < this.columns) return w.concat(new Array(this.columns - w.length).fill(1))
68
+ if (w.length > this.columns) return w.slice(0, this.columns)
69
+ return w
70
+ }
71
+ get heights(): number[] {
72
+ const h = this.state.heights as number[] | undefined
73
+ if (!h) return new Array(this.rackRows).fill(1)
74
+ if (h.length < this.rackRows) return h.concat(new Array(this.rackRows - h.length).fill(1))
75
+ if (h.length > this.rackRows) return h.slice(0, this.rackRows)
76
+ return h
77
+ }
78
+ get widths_sum(): number {
79
+ return this.widths.reduce((s, w) => s + w, 0) || this.columns
80
+ }
81
+ get heights_sum(): number {
82
+ return this.heights.reduce((s, h) => s + h, 0) || this.rackRows
83
+ }
84
+
85
+ get records(): any[] {
86
+ return this.state.data ?? []
87
+ }
88
+ get cellOverrides(): { [k: string]: { isEmpty?: boolean } } {
89
+ return this.state.cellOverrides ?? {}
90
+ }
91
+
92
+ // ── helpers ─────────────────────────────────────────────
93
+ parseSlotId(cellId: string): { col: number; row: number; shelf: number } | null {
94
+ const parts = cellId.split('-')
95
+ if (parts.length !== 3) return null
96
+ const [c, r, s] = parts.map(Number)
97
+ if (!Number.isFinite(c) || !Number.isFinite(r) || !Number.isFinite(s)) return null
98
+ return { col: c, row: r, shelf: s }
99
+ }
100
+
101
+ /** RackGrid-inner 의 cell center 좌표 (widths/heights 비례 분할). */
102
+ cellCenterInner(cellId: string): { x: number; y: number } | null {
103
+ const parsed = this.parseSlotId(cellId)
104
+ if (!parsed) return null
105
+ const widthsArr = this.widths
106
+ const heightsArr = this.heights
107
+ const wSum = this.widths_sum
108
+ const hSum = this.heights_sum
109
+ const rackWidth = this.state.width ?? 400
110
+ const rackDepthY = this.state.height ?? 200
111
+ let cumW = 0
112
+ for (let c = 0; c < parsed.col; c++) cumW += widthsArr[c]
113
+ let cumH = 0
114
+ for (let r = 0; r < parsed.row; r++) cumH += heightsArr[r]
115
+ return {
116
+ x: (cumW + widthsArr[parsed.col] / 2) / wSum * rackWidth,
117
+ y: (cumH + heightsArr[parsed.row] / 2) / hSum * rackDepthY
118
+ }
119
+ }
120
+
121
+ // ── operations ──────────────────────────────────────────
122
+ setState(key: string, value: any): void {
123
+ this.state[key] = value
124
+ }
125
+
126
+ addComponent(c: any): void {
127
+ c.parent = this
128
+ this.components.push(c)
129
+ }
130
+
131
+ removeComponent(c: any): void {
132
+ const i = this.components.indexOf(c)
133
+ if (i >= 0) this.components.splice(i, 1)
134
+ c.parent = null
135
+ }
136
+
137
+ _carrierChildAt(cellId: string): any {
138
+ return this.components.find(c => c.state?.cellId === cellId && c.placement === 'operation')
139
+ }
140
+
141
+ hasCarrierAt(cellId: string): boolean {
142
+ if (this._carrierChildAt(cellId)) return true
143
+ return this.records.some(r => r?.cellId === cellId)
144
+ }
145
+
146
+ obtainCarrier(cellId: string): any {
147
+ const existing = this._carrierChildAt(cellId)
148
+ if (existing) return existing
149
+
150
+ const idx = this.records.findIndex(r => r?.cellId === cellId)
151
+ if (idx === -1) return null
152
+ const record = this.records[idx]
153
+
154
+ const center = this.cellCenterInner(cellId)
155
+ if (!center) return null
156
+
157
+ const carrier: any = {
158
+ placement: 'operation',
159
+ state: {
160
+ ...record,
161
+ type: record.type ?? 'parcel',
162
+ cellId,
163
+ // RackGrid-inner 좌표 — center 기준 carrier top-left.
164
+ left: center.x - 10,
165
+ top: center.y - 10,
166
+ width: 20,
167
+ height: 20
168
+ },
169
+ parent: null,
170
+ dispose() { /* test */ }
171
+ }
172
+ this.addComponent(carrier)
173
+ this.setState('data', this.records.filter((_, i) => i !== idx))
174
+ return carrier
175
+ }
176
+
177
+ canReceiveAt(cellId: string, carrier?: any): boolean {
178
+ const parsed = this.parseSlotId(cellId)
179
+ if (!parsed) return false
180
+ const override = this.cellOverrides[`${parsed.col}-${parsed.row}`]
181
+ if (override?.isEmpty) return false
182
+
183
+ if (this.records.some(r => r?.cellId === cellId)) return false
184
+ const existing = this._carrierChildAt(cellId)
185
+ if (existing && existing !== carrier) return false
186
+ return true
187
+ }
188
+
189
+ async receiveAt(cellId: string, carrier: any): Promise<void> {
190
+ if (!this.canReceiveAt(cellId, carrier)) return
191
+
192
+ const record = this.recordFromCarrier(carrier, cellId)
193
+ const p = carrier.parent
194
+ if (p && typeof p.removeComponent === 'function') p.removeComponent(carrier)
195
+ this._disposed.add(carrier)
196
+ carrier.dispose?.()
197
+
198
+ this.setState('data', [...this.records, record])
199
+ }
200
+
201
+ recordFromCarrier(carrier: any, cellId: string): any {
202
+ const state = carrier.state ?? {}
203
+ const SKIP = new Set([
204
+ 'left', 'top', 'zPos',
205
+ 'transform', 'rotation', 'scale',
206
+ '_transferSlotId',
207
+ 'cellId', 'id', 'refid'
208
+ ])
209
+ const record: any = { cellId, type: state.type }
210
+ for (const k of Object.keys(state)) {
211
+ if (SKIP.has(k)) continue
212
+ record[k] = state[k]
213
+ }
214
+ return record
215
+ }
216
+ }
217
+
218
+ // ── Group 1: obtainCarrier ──────────────────────────────────────────────────
219
+
220
+ describe('RackGrid Plan A: obtainCarrier — RackGrid 자체 records 에서 materialize', () => {
221
+ it('record 가 있는 cellId → carrier materialize, record 제거', () => {
222
+ const grid = new MiniRackGrid({
223
+ initialData: [
224
+ { cellId: '0-0-0', sku: 'A', qty: 1 },
225
+ { cellId: '1-1-2', sku: 'B', qty: 2 }
226
+ ]
227
+ })
228
+
229
+ const carrier = grid.obtainCarrier('0-0-0')!
230
+ carrier.should.not.be.null()
231
+ carrier.state.sku.should.equal('A')
232
+ carrier.state.cellId.should.equal('0-0-0')
233
+ carrier.parent.should.equal(grid)
234
+
235
+ grid.records.length.should.equal(1)
236
+ grid.records[0].cellId.should.equal('1-1-2')
237
+ grid.components.length.should.equal(1)
238
+ })
239
+
240
+ it('record 가 없는 cellId → null (실제 버그 재현 — 사용자 보고 동일)', () => {
241
+ const grid = new MiniRackGrid({
242
+ initialData: [{ cellId: '0-0-0', sku: 'X' }]
243
+ })
244
+ // 사용자가 '1-11-2' 호출 → record 가 '0-0-0' 이라 null. 호출 cellId 자체 불일치.
245
+ ;(grid.obtainCarrier('1-11-2') === null).should.be.true()
246
+ grid.records.length.should.equal(1)
247
+ })
248
+
249
+ it('이미 child carrier 가 있는 cellId → 그 child 그대로 반환 (idempotent)', () => {
250
+ const grid = new MiniRackGrid()
251
+ const existing: any = {
252
+ placement: 'operation',
253
+ state: { cellId: '2-1-0', sku: 'X' },
254
+ parent: grid
255
+ }
256
+ grid.components.push(existing)
257
+
258
+ const got = grid.obtainCarrier('2-1-0')
259
+ got!.should.equal(existing)
260
+ grid.records.length.should.equal(0)
261
+ grid.components.length.should.equal(1)
262
+ })
263
+
264
+ it('obtainCarrier 2회 연속 → 두 번째는 같은 carrier', () => {
265
+ const grid = new MiniRackGrid({ initialData: [{ cellId: '0-0-0', sku: 'A' }] })
266
+ const c1 = grid.obtainCarrier('0-0-0')
267
+ const c2 = grid.obtainCarrier('0-0-0')
268
+ c1!.should.equal(c2)
269
+ grid.records.length.should.equal(0)
270
+ })
271
+
272
+ it('잘못된 cellId 형식 → null', () => {
273
+ const grid = new MiniRackGrid({ initialData: [{ cellId: '0-0-0' }] })
274
+ ;(grid.obtainCarrier('0-0') === null).should.be.true() // 2 segments
275
+ ;(grid.obtainCarrier('a-b-c') === null).should.be.true() // non-numeric
276
+ })
277
+ })
278
+
279
+ // ── Group 2: receiveAt ──────────────────────────────────────────────────────
280
+
281
+ describe('RackGrid Plan A: receiveAt — absorb carrier into records', () => {
282
+ it('carrier 받으면 dispose + records 에 push', async () => {
283
+ const grid = new MiniRackGrid()
284
+ const carrier: any = {
285
+ placement: 'operation',
286
+ state: { type: 'parcel', sku: 'X', qty: 5 },
287
+ parent: { removeComponent: () => {} },
288
+ dispose() {}
289
+ }
290
+
291
+ await grid.receiveAt('2-0-3', carrier)
292
+
293
+ grid._disposed.has(carrier).should.be.true()
294
+ grid.records.length.should.equal(1)
295
+ grid.records[0].cellId.should.equal('2-0-3')
296
+ grid.records[0].sku.should.equal('X')
297
+ grid.records[0].type.should.equal('parcel')
298
+ })
299
+
300
+ it('이미 점유된 cellId → reject', async () => {
301
+ const grid = new MiniRackGrid({
302
+ initialData: [{ cellId: '0-0-0', sku: 'occupied' }]
303
+ })
304
+ const carrier: any = {
305
+ placement: 'operation',
306
+ state: { type: 'parcel', sku: 'X' },
307
+ parent: { removeComponent: () => {} },
308
+ dispose() {}
309
+ }
310
+
311
+ await grid.receiveAt('0-0-0', carrier)
312
+
313
+ grid._disposed.has(carrier).should.be.false()
314
+ grid.records.length.should.equal(1)
315
+ grid.records[0].sku.should.equal('occupied')
316
+ })
317
+ })
318
+
319
+ // ── Group 3: canReceiveAt — isEmpty cell 거부 ───────────────────────────────
320
+
321
+ describe('RackGrid Plan A: canReceiveAt — isEmpty / 점유 검사', () => {
322
+ it('cellOverrides.isEmpty=true 위치는 거부', () => {
323
+ const grid = new MiniRackGrid({
324
+ cellOverrides: { '1-1': { isEmpty: true } }
325
+ })
326
+ grid.canReceiveAt('1-1-0').should.be.false()
327
+ grid.canReceiveAt('1-1-2').should.be.false()
328
+ // 다른 col-row 는 가능
329
+ grid.canReceiveAt('0-0-0').should.be.true()
330
+ })
331
+
332
+ it('records 점유 시 거부', () => {
333
+ const grid = new MiniRackGrid({
334
+ initialData: [{ cellId: '0-0-0' }]
335
+ })
336
+ grid.canReceiveAt('0-0-0').should.be.false()
337
+ grid.canReceiveAt('0-0-1').should.be.true()
338
+ })
339
+
340
+ it('자기 자신 carrier 가 child 면 idempotent true (자기 자리 복귀)', () => {
341
+ const grid = new MiniRackGrid()
342
+ const carrier: any = {
343
+ placement: 'operation', state: { cellId: '0-0-0' }, parent: grid
344
+ }
345
+ grid.components.push(carrier)
346
+ grid.canReceiveAt('0-0-0', carrier).should.be.true()
347
+ // 다른 carrier 면 false
348
+ grid.canReceiveAt('0-0-0', {} as any).should.be.false()
349
+ })
350
+
351
+ it('잘못된 cellId 형식 → false', () => {
352
+ const grid = new MiniRackGrid()
353
+ grid.canReceiveAt('xyz').should.be.false()
354
+ grid.canReceiveAt('0-0').should.be.false()
355
+ })
356
+ })
357
+
358
+ // ── Group 4: hasCarrierAt ───────────────────────────────────────────────────
359
+
360
+ describe('RackGrid Plan A: hasCarrierAt — record + child 양쪽 인식', () => {
361
+ it('record 만 → true', () => {
362
+ const grid = new MiniRackGrid({ initialData: [{ cellId: '0-0-0' }] })
363
+ grid.hasCarrierAt('0-0-0').should.be.true()
364
+ grid.hasCarrierAt('1-1-1').should.be.false()
365
+ })
366
+
367
+ it('child carrier 만 → true', () => {
368
+ const grid = new MiniRackGrid()
369
+ grid.components.push({ placement: 'operation', state: { cellId: '1-2-3' } } as any)
370
+ grid.hasCarrierAt('1-2-3').should.be.true()
371
+ })
372
+
373
+ it('record / child 둘 다 없음 → false', () => {
374
+ const grid = new MiniRackGrid()
375
+ grid.hasCarrierAt('0-0-0').should.be.false()
376
+ })
377
+ })
378
+
379
+ // ── Group 5: 좌표 계산 — widths/heights 비례 분할 ───────────────────────────
380
+
381
+ describe('RackGrid: cellCenterInner — picking 위치 좌표 정확성', () => {
382
+ it('uniform widths/heights → 균등 분할', () => {
383
+ // cols=2, rows=2, rackWidth=100, rackHeight=80, widths=[1,1], heights=[1,1]
384
+ const grid = new MiniRackGrid({
385
+ columns: 2, rows: 2, width: 100, height: 80
386
+ })
387
+ grid.cellCenterInner('0-0-0')!.should.deepEqual({ x: 25, y: 20 })
388
+ grid.cellCenterInner('1-0-0')!.should.deepEqual({ x: 75, y: 20 })
389
+ grid.cellCenterInner('0-1-0')!.should.deepEqual({ x: 25, y: 60 })
390
+ grid.cellCenterInner('1-1-0')!.should.deepEqual({ x: 75, y: 60 })
391
+ })
392
+
393
+ it('non-uniform widths → 비례 분할', () => {
394
+ // widths=[1,3], 첫 cell 25%, 둘째 75%. rackWidth=400.
395
+ const grid = new MiniRackGrid({
396
+ columns: 2, rows: 1, width: 400, height: 100,
397
+ widths: [1, 3]
398
+ })
399
+ // wSum=4, col=0: (0 + 0.5)/4 * 400 = 50
400
+ grid.cellCenterInner('0-0-0')!.x.should.equal(50)
401
+ // col=1: (1 + 1.5)/4 * 400 = 250
402
+ grid.cellCenterInner('1-0-0')!.x.should.equal(250)
403
+ })
404
+
405
+ it('shelf 인덱스는 inner 2D 좌표에 영향 X (3D Y 축 별도)', () => {
406
+ const grid = new MiniRackGrid({
407
+ columns: 3, rows: 3, shelves: 5, width: 300, height: 300
408
+ })
409
+ const c0 = grid.cellCenterInner('1-1-0')!
410
+ const c4 = grid.cellCenterInner('1-1-4')!
411
+ c0.should.deepEqual(c4)
412
+ })
413
+
414
+ it('잘못된 cellId 형식 → null', () => {
415
+ const grid = new MiniRackGrid()
416
+ ;(grid.cellCenterInner('xyz') === null).should.be.true()
417
+ ;(grid.cellCenterInner('0-0') === null).should.be.true()
418
+ })
419
+ })
420
+
421
+ // ── Group 6: 불변식 — record ↔ child 상호 배타 ─────────────────────────────
422
+
423
+ describe('RackGrid Plan A: 불변식 — record ↔ child 양쪽 동시 X', () => {
424
+ it('obtainCarrier 가 record 제거 → child 와 record 동일 cellId 양립 안 함', () => {
425
+ const grid = new MiniRackGrid({ initialData: [{ cellId: 'A-B-C' }] })
426
+ // cellId 가 숫자가 아니라 parseSlotId 가 null — obtain 도 null. 적절한 cellId 로 재시도.
427
+ const grid2 = new MiniRackGrid({ initialData: [{ cellId: '0-0-0' }] })
428
+ const c = grid2.obtainCarrier('0-0-0')!
429
+ grid2._carrierChildAt('0-0-0').should.equal(c)
430
+ grid2.records.some((r: any) => r.cellId === '0-0-0').should.be.false()
431
+ })
432
+
433
+ it('Full cycle — record → carrier → 다른 cell 로 receive', async () => {
434
+ const grid = new MiniRackGrid({ initialData: [{ cellId: '0-0-0', sku: 'X' }] })
435
+
436
+ // 1. record 만 존재
437
+ grid.hasCarrierAt('0-0-0').should.be.true()
438
+ grid.hasCarrierAt('1-1-1').should.be.false()
439
+
440
+ // 2. obtain → child 로
441
+ const carrier = grid.obtainCarrier('0-0-0')!
442
+ grid.hasCarrierAt('0-0-0').should.be.true()
443
+ grid.records.some((r: any) => r.cellId === '0-0-0').should.be.false()
444
+
445
+ // 3. 외부로 picked
446
+ grid.removeComponent(carrier)
447
+ carrier.parent = { removeComponent: () => {} }
448
+ grid.hasCarrierAt('0-0-0').should.be.false()
449
+
450
+ // 4. 새 cellId 로 receiveAt
451
+ await grid.receiveAt('1-1-1', carrier)
452
+ grid.hasCarrierAt('0-0-0').should.be.false()
453
+ grid.hasCarrierAt('1-1-1').should.be.true()
454
+ grid._disposed.has(carrier).should.be.true()
455
+ })
456
+ })
457
+
458
+ // ── Group 7: recordFromCarrier ──────────────────────────────────────────────
459
+
460
+ describe('RackGrid Plan A: recordFromCarrier — transform 제외, 의미 보존', () => {
461
+ it('transform / id / refid 류는 record 에서 제외', () => {
462
+ const grid = new MiniRackGrid()
463
+ const carrier: any = {
464
+ state: {
465
+ type: 'parcel', sku: 'A', qty: 3,
466
+ left: 100, top: 200, zPos: 50,
467
+ transform: 'rotate(45deg)', rotation: 90, scale: 2,
468
+ _transferSlotId: 'forks',
469
+ cellId: 'old-cell', id: 'comp-id', refid: 999
470
+ }
471
+ }
472
+ const r = grid.recordFromCarrier(carrier, 'new-cell')
473
+ r.cellId.should.equal('new-cell')
474
+ r.type.should.equal('parcel')
475
+ r.sku.should.equal('A')
476
+ r.qty.should.equal(3)
477
+ ;('left' in r).should.be.false()
478
+ ;('transform' in r).should.be.false()
479
+ ;('id' in r).should.be.false()
480
+ ;('refid' in r).should.be.false()
481
+ ;('_transferSlotId' in r).should.be.false()
482
+ })
483
+ })