@operato/scene-storage 10.0.0-beta.43 → 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.
- package/CHANGELOG.md +37 -0
- package/dist/box-3d.d.ts +2 -0
- package/dist/box-3d.js +103 -64
- package/dist/box-3d.js.map +1 -1
- package/dist/crane-3d.d.ts +10 -0
- package/dist/crane-3d.js +34 -5
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +136 -6
- package/dist/crane.js +567 -46
- package/dist/crane.js.map +1 -1
- package/dist/pallet-3d.d.ts +2 -0
- package/dist/pallet-3d.js +103 -53
- package/dist/pallet-3d.js.map +1 -1
- package/dist/parcel-3d.d.ts +1 -0
- package/dist/parcel-3d.js +18 -1
- package/dist/parcel-3d.js.map +1 -1
- package/dist/rack-grid-3d.js +26 -8
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid.d.ts +94 -10
- package/dist/rack-grid.js +468 -86
- package/dist/rack-grid.js.map +1 -1
- package/dist/storage-rack-3d.js +1 -1
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +31 -6
- package/dist/storage-rack.js +96 -14
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box-3d.ts +121 -68
- package/src/crane-3d.ts +34 -4
- package/src/crane.ts +615 -55
- package/src/pallet-3d.ts +122 -55
- package/src/parcel-3d.ts +19 -1
- package/src/rack-grid-3d.ts +31 -8
- package/src/rack-grid.ts +488 -82
- package/src/storage-rack-3d.ts +1 -1
- package/src/storage-rack.ts +96 -14
- package/test/test-coord-alignment.ts +2 -2
- package/test/test-crane-bay-match.ts +130 -0
- package/test/test-crane-binding-resolve.ts +168 -0
- package/test/test-crane-duration.ts +90 -0
- package/test/test-crane-rotation-reach.ts +218 -0
- package/test/test-rack-grid-3d-alignment.ts +235 -0
- package/test/test-rack-grid-3d-attach-real.ts +375 -0
- package/test/test-rack-grid-cell.ts +2 -2
- package/test/test-rack-grid-location.ts +2 -2
- package/test/test-rack-grid-occupied-slots.ts +165 -0
- package/test/test-rack-grid-picking-position.ts +154 -0
- package/test/test-rack-grid-slot-api.ts +483 -0
- package/test/test-slot-ids-enumeration.ts +137 -0
- 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
|
+
})
|