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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +12 -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/rack-grid-3d.d.ts +18 -7
  10. package/dist/rack-grid-3d.js +372 -69
  11. package/dist/rack-grid-3d.js.map +1 -1
  12. package/dist/rack-grid-cell.d.ts +21 -72
  13. package/dist/rack-grid-cell.js +147 -243
  14. package/dist/rack-grid-cell.js.map +1 -1
  15. package/dist/rack-grid.d.ts +277 -56
  16. package/dist/rack-grid.js +1230 -695
  17. package/dist/rack-grid.js.map +1 -1
  18. package/dist/rack-materials.d.ts +9 -0
  19. package/dist/rack-materials.js +55 -0
  20. package/dist/rack-materials.js.map +1 -0
  21. package/dist/storage-rack-3d.d.ts +15 -0
  22. package/dist/storage-rack-3d.js +131 -30
  23. package/dist/storage-rack-3d.js.map +1 -1
  24. package/dist/storage-rack.d.ts +242 -45
  25. package/dist/storage-rack.js +684 -106
  26. package/dist/storage-rack.js.map +1 -1
  27. package/package.json +3 -3
  28. package/src/crane.ts +1 -1
  29. package/src/index.ts +3 -4
  30. package/src/rack-grid-3d.ts +383 -80
  31. package/src/rack-grid-cell.ts +161 -305
  32. package/src/rack-grid.ts +1263 -762
  33. package/src/rack-materials.ts +61 -0
  34. package/src/storage-rack-3d.ts +144 -30
  35. package/src/storage-rack.ts +763 -111
  36. package/test/test-carrier-lifecycle.ts +361 -0
  37. package/test/test-coord-alignment.ts +201 -0
  38. package/test/test-external-to-rack.ts +461 -0
  39. package/test/test-mover-concurrent-bug.ts +304 -0
  40. package/test/test-mover-rollback.ts +290 -0
  41. package/test/test-r19-place-absorb.ts +174 -0
  42. package/test/test-rack-3d-attach-real.ts +301 -0
  43. package/test/test-rack-concurrent.ts +254 -0
  44. package/test/test-rack-edge-cases.ts +323 -0
  45. package/test/test-rack-grid-cell.ts +318 -0
  46. package/test/test-rack-grid-location.ts +657 -0
  47. package/test/test-real-3d-positioning.ts +158 -0
  48. package/test/test-slot-center-convention.ts +116 -0
  49. package/test/test-slot-target.ts +189 -0
  50. package/test/test-storage-rack-batched.ts +606 -0
  51. package/test/test-storage-rack-click.ts +329 -0
  52. package/test/test-storage-rack-slot-api.ts +357 -0
  53. package/test/test-toscene-convention.ts +162 -0
  54. package/test/test-user-scenario-sequential.ts +334 -0
  55. package/translations/en.json +2 -0
  56. package/translations/ja.json +2 -0
  57. package/translations/ko.json +2 -0
  58. package/translations/ms.json +2 -0
  59. package/translations/zh.json +2 -0
  60. package/tsconfig.tsbuildinfo +1 -1
  61. package/dist/rack-column.d.ts +0 -35
  62. package/dist/rack-column.js +0 -258
  63. package/dist/rack-column.js.map +0 -1
  64. package/dist/rack-grid-helpers.d.ts +0 -28
  65. package/dist/rack-grid-helpers.js +0 -71
  66. package/dist/rack-grid-helpers.js.map +0 -1
  67. package/dist/rack-grid-location.d.ts +0 -37
  68. package/dist/rack-grid-location.js +0 -227
  69. package/dist/rack-grid-location.js.map +0 -1
  70. package/dist/storage-cell-3d.d.ts +0 -25
  71. package/dist/storage-cell-3d.js +0 -88
  72. package/dist/storage-cell-3d.js.map +0 -1
  73. package/dist/storage-cell.d.ts +0 -73
  74. package/dist/storage-cell.js +0 -215
  75. package/dist/storage-cell.js.map +0 -1
  76. package/src/rack-column.ts +0 -340
  77. package/src/rack-grid-helpers.ts +0 -77
  78. package/src/rack-grid-location.ts +0 -286
  79. package/src/storage-cell-3d.ts +0 -101
  80. package/src/storage-cell.ts +0 -267
  81. package/test/test-cell-position.ts +0 -105
  82. package/test/test-rack-grid.ts +0 -77
@@ -0,0 +1,158 @@
1
+ /*
2
+ * 실제 THREE.js scene graph 에서 anchor 와 stock 의 *world position 일치* 검증.
3
+ *
4
+ * 단순 공식 비교 (test-coord-alignment.ts) 가 통과해도, 실제 Three.js 환경에선 rack 의
5
+ * matrixWorld / 부모 chain 변환에 따라 어긋날 수 있음. 이 테스트는:
6
+ * 1. 실 THREE.Group (rack.object3d) 만들기
7
+ * 2. anchor 와 stock 을 각각 그 자식으로 추가
8
+ * 3. 위치 set 후 *matrixWorld 비교*
9
+ * 둘이 동일 world 위치라면 framework 통과 시도 정확. 어긋나면 framework 측 추가 변환 의심.
10
+ */
11
+
12
+ import 'should'
13
+ import * as THREE from 'three'
14
+
15
+ function computeAnchorPos(
16
+ cell: { localPosition: { x: number; y: number; z: number } },
17
+ rp: { width: number; depth: number; height: number; bays: number; levels: number; shelfBase: number }
18
+ ) {
19
+ const sz = rp.depth - rp.shelfBase
20
+ const bw = rp.width / rp.bays
21
+ const lh = sz / rp.levels
22
+ const sd = lh * 0.7
23
+ return {
24
+ x: cell.localPosition.x + bw / 2 - rp.width / 2,
25
+ y: cell.localPosition.y - rp.depth / 2 + sd / 2,
26
+ z: cell.localPosition.z + rp.height / 2 - rp.height / 2
27
+ }
28
+ }
29
+
30
+ function computeStockPos(
31
+ cell: { localPosition: { x: number; y: number; z: number } },
32
+ rp: { width: number; depth: number; height: number; bays: number; levels: number; shelfBase: number }
33
+ ) {
34
+ const sz = rp.depth - rp.shelfBase
35
+ const bw = rp.width / rp.bays
36
+ const lh = sz / rp.levels
37
+ const sd = lh * 0.7
38
+ const cellCenterY = cell.localPosition.y + lh / 2 - rp.depth / 2
39
+ return {
40
+ x: cell.localPosition.x + bw / 2 - rp.width / 2,
41
+ y: cellCenterY - lh / 2 + sd / 2,
42
+ z: cell.localPosition.z + rp.height / 2 - rp.height / 2
43
+ }
44
+ }
45
+
46
+ function makeCell(bay: number, row: number, level: number, p: any) {
47
+ return {
48
+ localPosition: {
49
+ x: (bay - 1) * p.bayWidth,
50
+ y: p.shelfBase + (level - 1) * p.levelHeight,
51
+ z: (row - 1) * p.rowDepth
52
+ }
53
+ }
54
+ }
55
+
56
+ describe('Real Three.js: anchor / stock world position 일치', () => {
57
+ const rp = { width: 1000, depth: 3000, height: 600, bays: 5, levels: 4, shelfBase: 0 }
58
+ const bw = rp.width / rp.bays
59
+ const lh = (rp.depth - rp.shelfBase) / rp.levels
60
+ const rd = rp.height
61
+
62
+ it('rack 가 origin 에 있을 때 — anchor.matrixWorld === stock.matrixWorld', () => {
63
+ const rackObj = new THREE.Group()
64
+ rackObj.position.set(0, 0, 0)
65
+ rackObj.updateMatrixWorld(true)
66
+
67
+ const cell = makeCell(2, 1, 2, { bayWidth: bw, rowDepth: rd, levelHeight: lh, shelfBase: 0 })
68
+ const anchorPos = computeAnchorPos(cell, rp)
69
+ const stockPos = computeStockPos(cell, rp)
70
+
71
+ const anchor = new THREE.Object3D()
72
+ anchor.position.set(anchorPos.x, anchorPos.y, anchorPos.z)
73
+ rackObj.add(anchor)
74
+ anchor.updateMatrixWorld(true)
75
+
76
+ const stock = new THREE.Object3D()
77
+ stock.position.set(stockPos.x, stockPos.y, stockPos.z)
78
+ rackObj.add(stock)
79
+ stock.updateMatrixWorld(true)
80
+
81
+ const anchorWorld = new THREE.Vector3()
82
+ anchor.getWorldPosition(anchorWorld)
83
+ const stockWorld = new THREE.Vector3()
84
+ stock.getWorldPosition(stockWorld)
85
+
86
+ anchorWorld.x.should.be.approximately(stockWorld.x, 0.0001)
87
+ anchorWorld.y.should.be.approximately(stockWorld.y, 0.0001)
88
+ anchorWorld.z.should.be.approximately(stockWorld.z, 0.0001)
89
+ })
90
+
91
+ it('rack 가 translated (1000, 500, 200) 일 때 — anchor === stock', () => {
92
+ const rackObj = new THREE.Group()
93
+ rackObj.position.set(1000, 500, 200)
94
+ rackObj.updateMatrixWorld(true)
95
+
96
+ const cell = makeCell(3, 1, 3, { bayWidth: bw, rowDepth: rd, levelHeight: lh, shelfBase: 0 })
97
+ const anchorPos = computeAnchorPos(cell, rp)
98
+ const stockPos = computeStockPos(cell, rp)
99
+
100
+ const anchor = new THREE.Object3D(); anchor.position.set(anchorPos.x, anchorPos.y, anchorPos.z)
101
+ rackObj.add(anchor); anchor.updateMatrixWorld(true)
102
+ const stock = new THREE.Object3D(); stock.position.set(stockPos.x, stockPos.y, stockPos.z)
103
+ rackObj.add(stock); stock.updateMatrixWorld(true)
104
+
105
+ const aw = new THREE.Vector3(); anchor.getWorldPosition(aw)
106
+ const sw = new THREE.Vector3(); stock.getWorldPosition(sw)
107
+ aw.x.should.be.approximately(sw.x, 0.0001)
108
+ aw.y.should.be.approximately(sw.y, 0.0001)
109
+ aw.z.should.be.approximately(sw.z, 0.0001)
110
+ // 또한 rack 의 translation 이 정확히 반영되어야 함
111
+ aw.x.should.be.approximately(anchorPos.x + 1000, 0.0001)
112
+ aw.y.should.be.approximately(anchorPos.y + 500, 0.0001)
113
+ aw.z.should.be.approximately(anchorPos.z + 200, 0.0001)
114
+ })
115
+
116
+ it('rack 가 회전 (Y 축 45°) 일 때 — anchor === stock', () => {
117
+ const rackObj = new THREE.Group()
118
+ rackObj.rotation.y = Math.PI / 4
119
+ rackObj.updateMatrixWorld(true)
120
+
121
+ const cell = makeCell(1, 1, 1, { bayWidth: bw, rowDepth: rd, levelHeight: lh, shelfBase: 0 })
122
+ const anchorPos = computeAnchorPos(cell, rp)
123
+ const stockPos = computeStockPos(cell, rp)
124
+
125
+ const anchor = new THREE.Object3D(); anchor.position.set(anchorPos.x, anchorPos.y, anchorPos.z)
126
+ rackObj.add(anchor); anchor.updateMatrixWorld(true)
127
+ const stock = new THREE.Object3D(); stock.position.set(stockPos.x, stockPos.y, stockPos.z)
128
+ rackObj.add(stock); stock.updateMatrixWorld(true)
129
+
130
+ const aw = new THREE.Vector3(); anchor.getWorldPosition(aw)
131
+ const sw = new THREE.Vector3(); stock.getWorldPosition(sw)
132
+ aw.x.should.be.approximately(sw.x, 0.0001)
133
+ aw.y.should.be.approximately(sw.y, 0.0001)
134
+ aw.z.should.be.approximately(sw.z, 0.0001)
135
+ })
136
+
137
+ it('5×4 grid 모든 cell — anchor === stock', () => {
138
+ const rackObj = new THREE.Group()
139
+ rackObj.position.set(100, 200, 300)
140
+ rackObj.rotation.set(0, Math.PI / 6, 0)
141
+ rackObj.updateMatrixWorld(true)
142
+
143
+ for (let b = 1; b <= 5; b++) {
144
+ for (let l = 1; l <= 4; l++) {
145
+ const cell = makeCell(b, 1, l, { bayWidth: bw, rowDepth: rd, levelHeight: lh, shelfBase: 0 })
146
+ const aPos = computeAnchorPos(cell, rp)
147
+ const sPos = computeStockPos(cell, rp)
148
+ const a = new THREE.Object3D(); a.position.set(aPos.x, aPos.y, aPos.z); rackObj.add(a); a.updateMatrixWorld(true)
149
+ const s = new THREE.Object3D(); s.position.set(sPos.x, sPos.y, sPos.z); rackObj.add(s); s.updateMatrixWorld(true)
150
+ const aw = new THREE.Vector3(); a.getWorldPosition(aw)
151
+ const sw = new THREE.Vector3(); s.getWorldPosition(sw)
152
+ aw.x.should.be.approximately(sw.x, 0.0001)
153
+ aw.y.should.be.approximately(sw.y, 0.0001)
154
+ aw.z.should.be.approximately(sw.z, 0.0001)
155
+ }
156
+ }
157
+ })
158
+ })
@@ -0,0 +1,116 @@
1
+ /*
2
+ * SlotTarget.center 의 좌표 컨벤션 검증.
3
+ *
4
+ * 핵심 가설: things-scene 의 모든 컴포넌트의 `.center` 는 *parent 좌표계 의 center*
5
+ * (= `bounds.left + width/2`). Crane.moveTo 가 *target.center → target.toScene* 으로
6
+ * X 도달 위치를 결정. 이때 *pick target (예: parcel)* 와 *place target (SlotTarget)*
7
+ * 의 .center 가 *같은 좌표계 의 값* 이어야 동일한 변환 체인이 정확히 작동.
8
+ *
9
+ * 결함 시나리오: SlotTarget.center 가 *rack-local-inner 좌표* (rack.left/top 미포함)
10
+ * 를 반환하면, rack.toScene 이 같은 컨벤션 하에 처리해도 *layer 좌표* 의 점과 어긋남.
11
+ * → place 만 X 위치 어긋남, pick 은 정상 (parcel.center 는 layer 좌표).
12
+ *
13
+ * 이 테스트가 *pickup vs place 의 좌표 일관성* 을 검증. 사용자가 본 결함:
14
+ * "pick 은 비교적 잘 위치를 잡는데, place 는 완전히 bay 위치를 못 잡는다" — 의 직접 원인.
15
+ */
16
+ import 'should'
17
+
18
+ // ── things-scene 의 get_center 시뮬 — Component 의 .center 는 parent 좌표 의 center
19
+ function componentCenter(bounds: { left: number; top: number; width: number; height: number }) {
20
+ return {
21
+ x: bounds.left + bounds.width / 2,
22
+ y: bounds.top + bounds.height / 2
23
+ }
24
+ }
25
+
26
+ // ── 새 cellCenter2D 공식 (수정 후) — rack.left + bayCenter
27
+ function cellCenter2D_NEW(
28
+ cellId: string,
29
+ rack: { left: number; top: number; width: number; height: number; bays: number }
30
+ ) {
31
+ const bayIdx = Number(cellId.split('-')[0] ?? 0)
32
+ const bayWidth = rack.width / rack.bays
33
+ return {
34
+ x: rack.left + bayIdx * bayWidth + bayWidth / 2,
35
+ y: rack.top + rack.height / 2
36
+ }
37
+ }
38
+
39
+ // ── 이전 (결함) cellCenter2D 공식 — rack.left/top 미포함
40
+ function cellCenter2D_OLD(
41
+ cellId: string,
42
+ rack: { left: number; top: number; width: number; height: number; bays: number }
43
+ ) {
44
+ const bayIdx = Number(cellId.split('-')[0] ?? 0)
45
+ const bayWidth = rack.width / rack.bays
46
+ return {
47
+ x: bayIdx * bayWidth + bayWidth / 2,
48
+ y: rack.height / 2
49
+ }
50
+ }
51
+
52
+ describe('SlotTarget.center 의 좌표 컨벤션 — pickup vs place 의 일관성', () => {
53
+
54
+ // 사용자의 실 환경 값 — rackState left=365.17 top=478.6 W=800 D=800 H=50 bays=20
55
+ const rack = { left: 365.17, top: 478.6, width: 800, height: 50, bays: 20 }
56
+
57
+ it('parcel.center === bay 0 의 hypothetical "real cell" .center (둘 다 layer 좌표)', () => {
58
+ // 가상의 *진짜 cell 컴포넌트* 가 있다고 가정 — 그 bounds 는 rack.left + bayLeft, rack.top
59
+ // bay 0 의 bounds = { left: rack.left + 0*bayWidth, top: rack.top, width: bayWidth, height: rack.height }
60
+ const bayWidth = rack.width / rack.bays
61
+ const realCellBounds = { left: rack.left + 0 * bayWidth, top: rack.top, width: bayWidth, height: rack.height }
62
+ const realCellCenter = componentCenter(realCellBounds)
63
+
64
+ // 이게 SlotTarget.center 가 *반환해야 할 값* — 진짜 cell 컴포넌트 와 동일.
65
+ realCellCenter.x.should.equal(rack.left + bayWidth / 2) // = 365.17 + 20 = 385.17
66
+ realCellCenter.y.should.equal(rack.top + rack.height / 2) // = 478.6 + 25 = 503.6
67
+ })
68
+
69
+ it('NEW cellCenter2D === hypothetical real cell .center (일관성)', () => {
70
+ const bayWidth = rack.width / rack.bays
71
+ const realCellCenter = componentCenter({
72
+ left: rack.left + 0 * bayWidth, top: rack.top, width: bayWidth, height: rack.height
73
+ })
74
+ const slotCenter = cellCenter2D_NEW('0-0-0', rack)
75
+
76
+ slotCenter.x.should.equal(realCellCenter.x)
77
+ slotCenter.y.should.equal(realCellCenter.y)
78
+ })
79
+
80
+ it('OLD cellCenter2D ≠ hypothetical real cell .center (결함 재현)', () => {
81
+ const bayWidth = rack.width / rack.bays
82
+ const realCellCenter = componentCenter({
83
+ left: rack.left + 0 * bayWidth, top: rack.top, width: bayWidth, height: rack.height
84
+ })
85
+ const slotCenter = cellCenter2D_OLD('0-0-0', rack)
86
+
87
+ // OLD 는 rack.left, rack.top 미포함이므로 *rack.left, rack.top 만큼* 차이.
88
+ slotCenter.x.should.not.equal(realCellCenter.x)
89
+ slotCenter.y.should.not.equal(realCellCenter.y)
90
+ Math.abs(slotCenter.x - realCellCenter.x).should.be.approximately(rack.left, 0.001)
91
+ Math.abs(slotCenter.y - realCellCenter.y).should.be.approximately(rack.top, 0.001)
92
+ // 차이가 *정확히 rack 의 left/top* — 이것이 사용자가 본 "완전히 어긋남" 의 크기 (≈ 365)
93
+ })
94
+
95
+ it('NEW cellCenter2D: 다양한 bay/level 에 대해 layer 좌표 일관성', () => {
96
+ for (let b = 0; b < rack.bays; b++) {
97
+ const bayWidth = rack.width / rack.bays
98
+ const realCenter = componentCenter({
99
+ left: rack.left + b * bayWidth, top: rack.top, width: bayWidth, height: rack.height
100
+ })
101
+ const slotCenter = cellCenter2D_NEW(`${b}-0-0`, rack)
102
+ slotCenter.x.should.equal(realCenter.x)
103
+ slotCenter.y.should.equal(realCenter.y)
104
+ }
105
+ })
106
+
107
+ it('어긋남의 크기 = rack.left (= 365.17) — pickup 정확, place ≈ -365 X 어긋남', () => {
108
+ // 사용자 환경에서 OLD 가 만든 어긋남 크기.
109
+ const oldCenter = cellCenter2D_OLD('0-0-0', rack)
110
+ const newCenter = cellCenter2D_NEW('0-0-0', rack)
111
+ const xShift = newCenter.x - oldCenter.x // 365.17
112
+ xShift.should.be.approximately(rack.left, 0.001)
113
+ // 즉 OLD 는 *bay 0 의 시각 위치 (= 385.17)* 가 아닌 *(20, 25) — rack 의 왼쪽 위쪽 가까운 점*
114
+ // 으로 Crane 을 보냄. fork 가 rack 자체에서 벗어남.
115
+ })
116
+ })
@@ -0,0 +1,189 @@
1
+ /*
2
+ * Plan A — SlotTarget 의 Mover/Crane 인터페이스 호환성 검증.
3
+ *
4
+ * SlotTarget 은 Mover.pickAndPlace 의 dest 로 들어가며, framework 가 다음 surface 를
5
+ * 호출함:
6
+ * - target.state — Crane.engage (cellId 식별)
7
+ * - target.center — Mover.moveTo 의 2D path
8
+ * - target._realObject.object3d — getWorldPose / projectToScreen
9
+ * - target.parent — legacy 경로
10
+ * - target.canReceive — Mover.place dispatch
11
+ * - target.receive — Mover.place dispatch
12
+ * - target.attachPointFor — Carriable.applyHolderAttachPoint
13
+ *
14
+ * SlotTarget 이 holder 의 SlottedHolder API 에 정확히 위임하는지를 격리 검증.
15
+ */
16
+
17
+ import 'should'
18
+ import * as THREE from 'three'
19
+ import { SlotTarget } from '../../scene-base/src/slotted-holder.js'
20
+
21
+ // ── Mock holder — SlottedHolder 컨트랙 구현 ─────────────────────────────────
22
+
23
+ class MockHolder {
24
+ parent: any = { tag: 'parent-scene' }
25
+ center = { x: 100, y: 200 }
26
+ state: any = { type: 'mock-holder' }
27
+
28
+ _slotAnchors = new Map<string, THREE.Object3D>()
29
+ _receivedCalls: any[] = []
30
+ _canReceiveResult = true
31
+ _attachCenters = new Map<string, { x: number; y: number }>()
32
+
33
+ hasCarrierAt(_slotId: string): boolean { return false }
34
+ obtainCarrier(_slotId: string): any { return null }
35
+ canReceiveAt(_slotId: string, _carrier?: any): boolean { return this._canReceiveResult }
36
+ async receiveAt(slotId: string, carrier: any, options?: any): Promise<void> {
37
+ this._receivedCalls.push({ slotId, carrier, options })
38
+ }
39
+ recordFromCarrier(_carrier: any, slotId: string): any {
40
+ return { cellId: slotId }
41
+ }
42
+ getSlotAttachObject3d(slotId: string): THREE.Object3D | undefined {
43
+ let obj = this._slotAnchors.get(slotId)
44
+ if (!obj) {
45
+ obj = new THREE.Object3D()
46
+ obj.name = `mock-slot:${slotId}`
47
+ this._slotAnchors.set(slotId, obj)
48
+ }
49
+ return obj
50
+ }
51
+
52
+ cellCenter2D(slotId: string): { x: number; y: number } | null {
53
+ return this._attachCenters.get(slotId) ?? null
54
+ }
55
+
56
+ slotTargetAt(slotId: string): SlotTarget {
57
+ return new SlotTarget(this as any, slotId)
58
+ }
59
+ }
60
+
61
+ // ── Group 1: state ───────────────────────────────────────────────────────────
62
+
63
+ describe('SlotTarget: state — cellId/slotId 노출', () => {
64
+ it('state.cellId 와 state.slotId 둘 다 slotId 와 일치', () => {
65
+ const h = new MockHolder()
66
+ const st = h.slotTargetAt('5-0-7')
67
+ st.state.cellId.should.equal('5-0-7')
68
+ st.state.slotId.should.equal('5-0-7')
69
+ })
70
+
71
+ it('state.type 은 slot-target — 다른 컴포넌트 type 과 구분', () => {
72
+ const h = new MockHolder()
73
+ const st = h.slotTargetAt('A')
74
+ st.state.type.should.equal('slot-target')
75
+ })
76
+ })
77
+
78
+ // ── Group 2: center — 2D path planning ──────────────────────────────────────
79
+
80
+ describe('SlotTarget: center — holder.cellCenter2D 로 위임', () => {
81
+ it('holder.cellCenter2D 가 값 반환 → SlotTarget.center 가 그걸 그대로 반환', () => {
82
+ const h = new MockHolder()
83
+ h._attachCenters.set('A', { x: 555, y: 777 })
84
+ const st = h.slotTargetAt('A')
85
+ st.center.should.deepEqual({ x: 555, y: 777 })
86
+ })
87
+
88
+ it('cellCenter2D 가 null → holder.center 로 fallback', () => {
89
+ const h = new MockHolder()
90
+ h.center = { x: 42, y: 84 }
91
+ // cellCenter2D 에 등록 안 함 → null 반환
92
+ const st = h.slotTargetAt('未-등록')
93
+ st.center.should.deepEqual({ x: 42, y: 84 })
94
+ })
95
+ })
96
+
97
+ // ── Group 3: _realObject.object3d — getWorldPose 호환 ───────────────────────
98
+
99
+ describe('SlotTarget: _realObject.object3d — getSlotAttachObject3d 위임', () => {
100
+ it('object3d 반환은 holder.getSlotAttachObject3d 와 동일 인스턴스', () => {
101
+ const h = new MockHolder()
102
+ const st = h.slotTargetAt('A')
103
+ const obj = st._realObject.object3d
104
+ obj!.should.equal(h.getSlotAttachObject3d('A'))
105
+ })
106
+
107
+ it('singleton — 같은 slotId 두 번 호출도 같은 object3d', () => {
108
+ const h = new MockHolder()
109
+ const st1 = h.slotTargetAt('A')
110
+ const st2 = h.slotTargetAt('A')
111
+ st1._realObject.object3d!.should.equal(st2._realObject.object3d!)
112
+ })
113
+
114
+ it('서로 다른 slotId → 서로 다른 object3d', () => {
115
+ const h = new MockHolder()
116
+ const a = h.slotTargetAt('A')._realObject.object3d
117
+ const b = h.slotTargetAt('B')._realObject.object3d
118
+ ;(a !== b).should.be.true()
119
+ })
120
+ })
121
+
122
+ // ── Group 4: canReceive / receive — Transferable 인터페이스 ──────────────────
123
+
124
+ describe('SlotTarget: canReceive / receive — holder 에 위임', () => {
125
+ it('canReceive 가 holder.canReceiveAt 그대로 반환', () => {
126
+ const h = new MockHolder()
127
+ h._canReceiveResult = true
128
+ h.slotTargetAt('A').canReceive({} as any).should.be.true()
129
+ h._canReceiveResult = false
130
+ h.slotTargetAt('A').canReceive({} as any).should.be.false()
131
+ })
132
+
133
+ it('receive 가 holder.receiveAt 으로 위임 (slotId 와 carrier 전달)', async () => {
134
+ const h = new MockHolder()
135
+ const carrier = { tag: 'carrier-X' }
136
+ await h.slotTargetAt('C-0-0').receive(carrier as any, { dur: 200 })
137
+ h._receivedCalls.length.should.equal(1)
138
+ h._receivedCalls[0].slotId.should.equal('C-0-0')
139
+ h._receivedCalls[0].carrier.should.equal(carrier)
140
+ h._receivedCalls[0].options.dur.should.equal(200)
141
+ })
142
+ })
143
+
144
+ // ── Group 5: attachPointFor — Carriable.applyHolderAttachPoint 호환 ───────────
145
+
146
+ describe('SlotTarget: attachPointFor — slot anchor object3d 가 attach frame', () => {
147
+ it('attachPointFor 반환의 .attach 가 _realObject.object3d 와 동일', () => {
148
+ const h = new MockHolder()
149
+ const st = h.slotTargetAt('A')
150
+ const frame = st.attachPointFor({} as any)
151
+ frame!.attach.should.equal(st._realObject.object3d)
152
+ })
153
+
154
+ it('holder 의 getSlotAttachObject3d 가 undefined → attachPointFor null', () => {
155
+ const h: any = new MockHolder()
156
+ h.getSlotAttachObject3d = () => undefined
157
+ const st = (h as MockHolder).slotTargetAt('?')
158
+ ;(st.attachPointFor({} as any) === null).should.be.true()
159
+ })
160
+ })
161
+
162
+ // ── Group 6: parent — legacy 경로 ────────────────────────────────────────────
163
+
164
+ describe('SlotTarget: parent — holder.parent 그대로 노출', () => {
165
+ it('parent 는 holder.parent 와 동일', () => {
166
+ const h = new MockHolder()
167
+ const st = h.slotTargetAt('A')
168
+ st.parent.should.equal(h.parent)
169
+ })
170
+ })
171
+
172
+ // ── Group 7: 동일 holder 의 여러 SlotTarget 끼리 독립성 ─────────────────────
173
+
174
+ describe('SlotTarget: 여러 인스턴스 사이의 독립성', () => {
175
+ it('서로 다른 slotId → 서로 다른 state/center/object3d', () => {
176
+ const h = new MockHolder()
177
+ h._attachCenters.set('A', { x: 1, y: 1 })
178
+ h._attachCenters.set('B', { x: 99, y: 99 })
179
+
180
+ const sA = h.slotTargetAt('A')
181
+ const sB = h.slotTargetAt('B')
182
+
183
+ sA.state.cellId.should.equal('A')
184
+ sB.state.cellId.should.equal('B')
185
+ sA.center.should.deepEqual({ x: 1, y: 1 })
186
+ sB.center.should.deepEqual({ x: 99, y: 99 })
187
+ ;(sA._realObject.object3d !== sB._realObject.object3d).should.be.true()
188
+ })
189
+ })