@things-factory/board-ui 10.0.0-beta.64 → 10.0.0-beta.65

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.
@@ -0,0 +1,858 @@
1
+ /**
2
+ * board-edit-dispatch.ts 단위 테스트.
3
+ *
4
+ * 핵심 보호:
5
+ * - 호스트 측 in-place 적용 동작 — scene API (.add/.remove/target.set/.align/...)
6
+ * 호출 인자 + 호출 순서 검증
7
+ * - revert 용 inverse op 정확성 — 적용 직전 좌표/모델 캡처가 정확한가
8
+ * - silent fail (없는 refid, 미만 개수의 refids 등) 분류 — applied:false 반환
9
+ *
10
+ * 호스트 onBoardEditPatch 가 dispatcher 결과를 사용해 missed warning + patchInverses
11
+ * 누적하므로, dispatcher 가 잘못 분류하면 사용자에게는 "AI 응답이 적용 안 됨" 또는
12
+ * "revert 가 동작 안 함" 회귀로 직접 노출됨.
13
+ */
14
+ import { dispatchBoardEditOp, collectAllRefids, captureOldKeys, computeArrangePositions } from './board-edit-dispatch';
15
+ // ── Mock things-scene component ─────────────────────────────────────
16
+ // scene 의 컴포넌트는 .model 객체 + .get(key) / .set(merged) / .parent / .components 를 가짐.
17
+ // commander.execute / scene.add / scene.remove / scene.align 등을 mock 으로 캡처.
18
+ function makeMockSceneComp(model, parent = null) {
19
+ const comp = {
20
+ model: { ...model },
21
+ parent,
22
+ components: [],
23
+ get(key) {
24
+ return this.model[key];
25
+ },
26
+ set(merged) {
27
+ this.model = merged;
28
+ }
29
+ };
30
+ return comp;
31
+ }
32
+ function makeMockScene(opts = {}) {
33
+ const comps = (opts.components || []).map(m => makeMockSceneComp(m));
34
+ const refidIndexMap = new Map();
35
+ for (const c of comps)
36
+ refidIndexMap.set(c.model.refid, c);
37
+ const rootMock = opts.rootModel
38
+ ? {
39
+ model: { ...opts.rootModel },
40
+ set(merged) {
41
+ this.model = merged;
42
+ }
43
+ }
44
+ : null;
45
+ const calls = [];
46
+ const scene = {
47
+ selected: [],
48
+ rootContainer: { refidIndexMap },
49
+ root: rootMock,
50
+ commander: {
51
+ execute(...args) {
52
+ calls.push({ method: 'commander.execute', args });
53
+ }
54
+ },
55
+ add(component, _bounds) {
56
+ calls.push({ method: 'add', component });
57
+ // 새 refid 발급 (auto-increment) — collectAllRefids 가 차집합으로 캡처
58
+ const next = (Math.max(0, ...refidIndexMap.keys()) || 0) + 1;
59
+ const newComp = makeMockSceneComp({ ...component, refid: next });
60
+ newComp.parent = this.rootContainer;
61
+ refidIndexMap.set(next, newComp);
62
+ },
63
+ remove() {
64
+ // selected 의 컴포넌트들을 refidIndexMap 에서 제거
65
+ for (const sel of this.selected) {
66
+ if (sel?.model?.refid != null)
67
+ refidIndexMap.delete(sel.model.refid);
68
+ }
69
+ calls.push({ method: 'remove', selected: [...this.selected] });
70
+ },
71
+ align(direction) {
72
+ calls.push({ method: 'align', direction, selected: [...this.selected] });
73
+ // 좌표 변경 시뮬레이션 — 'left' 면 모든 selected 의 left = 0
74
+ if (direction === 'left') {
75
+ for (const c of this.selected)
76
+ c.model.left = 0;
77
+ }
78
+ },
79
+ distribute(axis) {
80
+ calls.push({ method: 'distribute', axis, selected: [...this.selected] });
81
+ },
82
+ group() {
83
+ // group → 새 Group 컴포넌트 1개 생성 + selected 들이 그 자식이 됨
84
+ const next = (Math.max(0, ...refidIndexMap.keys()) || 0) + 1;
85
+ const groupComp = makeMockSceneComp({ refid: next, type: 'group' });
86
+ groupComp.components = [...this.selected];
87
+ refidIndexMap.set(next, groupComp);
88
+ calls.push({ method: 'group', children: [...this.selected], newRefid: next });
89
+ },
90
+ ungroup() {
91
+ // selected[0] 의 자식들이 부모로 흡수, group 자체는 제거
92
+ const target = this.selected[0];
93
+ if (target)
94
+ refidIndexMap.delete(target.model.refid);
95
+ calls.push({ method: 'ungroup', target });
96
+ },
97
+ zorder(direction) {
98
+ calls.push({ method: 'zorder', direction, selected: [...this.selected] });
99
+ }
100
+ };
101
+ return { scene, calls, refidIndexMap };
102
+ }
103
+ // ── collectAllRefids ────────────────────────────────────────────────
104
+ describe('collectAllRefids', () => {
105
+ test('refidIndexMap 의 모든 refid 수집', () => {
106
+ const { scene } = makeMockScene({
107
+ components: [{ refid: 1 }, { refid: 35 }, { refid: 99 }]
108
+ });
109
+ expect(collectAllRefids(scene).sort((a, b) => a - b)).toEqual([1, 35, 99]);
110
+ });
111
+ test('빈 scene → 빈 배열', () => {
112
+ const { scene } = makeMockScene();
113
+ expect(collectAllRefids(scene)).toEqual([]);
114
+ });
115
+ test('rootContainer 없을 때도 안전', () => {
116
+ expect(collectAllRefids({})).toEqual([]);
117
+ expect(collectAllRefids(null)).toEqual([]);
118
+ });
119
+ });
120
+ // ── captureOldKeys ──────────────────────────────────────────────────
121
+ describe('captureOldKeys', () => {
122
+ test('patch 가 건드린 키만 deep-clone 으로 보관', () => {
123
+ const model = { left: 10, top: 20, fillStyle: '#fff', height: 100 };
124
+ const patch = { left: 999, fillStyle: '#000' };
125
+ expect(captureOldKeys(model, patch)).toEqual({ left: 10, fillStyle: '#fff' });
126
+ });
127
+ test('원본에 없는 키는 null 보관 (revert 시 명시적 null reset)', () => {
128
+ const model = { left: 10 };
129
+ const patch = { left: 999, newKey: 'x' };
130
+ expect(captureOldKeys(model, patch)).toEqual({ left: 10, newKey: null });
131
+ });
132
+ test('nested 값은 통째로 deep-clone (참조 공유 X)', () => {
133
+ const model = { threeD: { material: { color: '#f00' } } };
134
+ const patch = { threeD: { material: { color: '#0f0' } } };
135
+ const out = captureOldKeys(model, patch);
136
+ // 원본 참조와 다른 객체
137
+ expect(out.threeD).not.toBe(model.threeD);
138
+ // 그러나 값은 동일
139
+ expect(out.threeD).toEqual({ material: { color: '#f00' } });
140
+ // 원본 mutate 해도 캡처본은 영향 X
141
+ model.threeD.material.color = '#000';
142
+ expect(out.threeD.material.color).toBe('#f00');
143
+ });
144
+ test('patch 가 빈 객체 → 빈 결과', () => {
145
+ expect(captureOldKeys({ a: 1 }, {})).toEqual({});
146
+ expect(captureOldKeys({ a: 1 }, null)).toEqual({});
147
+ });
148
+ test('model 이 null 이어도 안전 — 모든 키 null 로', () => {
149
+ expect(captureOldKeys(null, { a: 1, b: 2 })).toEqual({ a: null, b: null });
150
+ });
151
+ });
152
+ // ── dispatchBoardEditOp — add ───────────────────────────────────────
153
+ describe('dispatchBoardEditOp — add', () => {
154
+ test('정상 — scene.add 호출 + 새 refid 의 inverse remove 반환', () => {
155
+ const { scene, calls, refidIndexMap } = makeMockScene({
156
+ components: [{ refid: 1, type: 'rect' }]
157
+ });
158
+ const r = dispatchBoardEditOp(scene, {
159
+ op: 'add',
160
+ component: { type: 'rect', left: 100 }
161
+ });
162
+ expect(r.applied).toBe(true);
163
+ // mock 의 add 는 refid=2 발급 (max=1 → +1)
164
+ expect(r.inverseOps).toEqual([{ op: 'remove', refid: 2 }]);
165
+ expect(calls.find(c => c.method === 'add')).toBeDefined();
166
+ expect(refidIndexMap.has(2)).toBe(true);
167
+ });
168
+ test('빈 scene 에 추가 — 첫 refid 1 발급', () => {
169
+ const { scene } = makeMockScene();
170
+ const r = dispatchBoardEditOp(scene, {
171
+ op: 'add',
172
+ component: { type: 'rect' }
173
+ });
174
+ expect(r.applied).toBe(true);
175
+ expect(r.inverseOps).toEqual([{ op: 'remove', refid: 1 }]);
176
+ });
177
+ test('normalize 콜백 사용 — add 에 전달되는 component 가 normalized', () => {
178
+ const { scene, calls } = makeMockScene();
179
+ const normalize = (c) => ({ ...c, value: 50, fillStyle: '#default' });
180
+ dispatchBoardEditOp(scene, { op: 'add', component: { type: 'gauge' } }, { normalize });
181
+ const addCall = calls.find(c => c.method === 'add');
182
+ expect(addCall.component.value).toBe(50);
183
+ expect(addCall.component.fillStyle).toBe('#default');
184
+ });
185
+ test('normalize 미지정 — component 그대로 전달', () => {
186
+ const { scene, calls } = makeMockScene();
187
+ dispatchBoardEditOp(scene, {
188
+ op: 'add',
189
+ component: { type: 'rect', left: 5 }
190
+ });
191
+ const addCall = calls.find(c => c.method === 'add');
192
+ expect(addCall.component).toEqual({ type: 'rect', left: 5 });
193
+ });
194
+ });
195
+ // ── dispatchBoardEditOp — remove ────────────────────────────────────
196
+ describe('dispatchBoardEditOp — remove', () => {
197
+ test('정상 — 삭제 직전 model 을 add inverse 로 캡처', () => {
198
+ const { scene, calls, refidIndexMap } = makeMockScene({
199
+ components: [{ refid: 35, type: 'rect', left: 10, name: 'AGV-1' }]
200
+ });
201
+ // 컴포넌트의 parent 를 mock 으로 설정 (remove 가 parent 체크함)
202
+ refidIndexMap.get(35).parent = { fake: 'parent' };
203
+ const r = dispatchBoardEditOp(scene, { op: 'remove', refid: 35 });
204
+ expect(r.applied).toBe(true);
205
+ expect(r.inverseOps).toHaveLength(1);
206
+ const inv = r.inverseOps[0];
207
+ expect(inv.op).toBe('add');
208
+ expect(inv.component.refid).toBe(35);
209
+ expect(inv.component.name).toBe('AGV-1');
210
+ // scene.remove 호출 + selected 가 target 이었는지
211
+ const removeCall = calls.find(c => c.method === 'remove');
212
+ expect(removeCall.selected[0].model.refid).toBe(35);
213
+ expect(refidIndexMap.has(35)).toBe(false);
214
+ });
215
+ test('없는 refid → applied:false, scene.remove 미호출', () => {
216
+ const { scene, calls } = makeMockScene({ components: [{ refid: 1 }] });
217
+ const r = dispatchBoardEditOp(scene, { op: 'remove', refid: 999 });
218
+ expect(r.applied).toBe(false);
219
+ expect(r.inverseOps).toEqual([]);
220
+ expect(calls.find(c => c.method === 'remove')).toBeUndefined();
221
+ });
222
+ test('parent 가 없는 (orphan) 컴포넌트 → applied:false', () => {
223
+ // 회귀 방지: refidIndexMap 에 있어도 parent 가 없으면 things-scene 의 remove 가
224
+ // 동작하지 않는다 (이미 detach 된 상태). 분명하게 missed 로 분류해야 함.
225
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
226
+ // 의도적으로 parent 미설정 (makeMockSceneComp default 가 null)
227
+ const r = dispatchBoardEditOp(scene, { op: 'remove', refid: 1 });
228
+ expect(r.applied).toBe(false);
229
+ });
230
+ test('inverse 의 component 는 깊은 클론 — 원본 변형이 복원에 영향 안 줌', () => {
231
+ const { scene, refidIndexMap } = makeMockScene({
232
+ components: [{ refid: 5, threeD: { material: { color: '#f00' } } }]
233
+ });
234
+ refidIndexMap.get(5).parent = { fake: 'p' };
235
+ const original = refidIndexMap.get(5).model;
236
+ const r = dispatchBoardEditOp(scene, { op: 'remove', refid: 5 });
237
+ // 원본 model mutate
238
+ original.threeD.material.color = '#0f0';
239
+ const inv = r.inverseOps[0];
240
+ expect(inv.component.threeD.material.color).toBe('#f00');
241
+ });
242
+ test('selected 복원 — 삭제 대상 외 selected 는 그대로', () => {
243
+ const { scene, refidIndexMap } = makeMockScene({
244
+ components: [{ refid: 1 }, { refid: 2 }]
245
+ });
246
+ refidIndexMap.get(1).parent = { p: 1 };
247
+ const other = refidIndexMap.get(2);
248
+ scene.selected = [refidIndexMap.get(1), other];
249
+ dispatchBoardEditOp(scene, { op: 'remove', refid: 1 });
250
+ expect(scene.selected).toEqual([other]);
251
+ });
252
+ });
253
+ // ── dispatchBoardEditOp — modify ────────────────────────────────────
254
+ describe('dispatchBoardEditOp — modify', () => {
255
+ test('정상 — target.set 에 merged 전달, inverse 는 원본 키 값', () => {
256
+ const { scene, calls, refidIndexMap } = makeMockScene({
257
+ components: [{ refid: 35, type: 'rect', left: 10, top: 20, fillStyle: '#fff' }]
258
+ });
259
+ const r = dispatchBoardEditOp(scene, {
260
+ op: 'modify',
261
+ refid: 35,
262
+ patch: { left: 100, fillStyle: '#000' }
263
+ });
264
+ expect(r.applied).toBe(true);
265
+ const target = refidIndexMap.get(35);
266
+ expect(target.model.left).toBe(100);
267
+ expect(target.model.fillStyle).toBe('#000');
268
+ expect(target.model.top).toBe(20); // 다른 키 보존
269
+ // inverse 는 원본 값
270
+ expect(r.inverseOps).toEqual([
271
+ { op: 'modify', refid: 35, patch: { left: 10, fillStyle: '#fff' } }
272
+ ]);
273
+ // commander.execute 호출 (snapshot push)
274
+ expect(calls.find(c => c.method === 'commander.execute')).toBeDefined();
275
+ });
276
+ test('nested object deep-merge — 색만 바꿀 때 geometry 보존', () => {
277
+ const { scene, refidIndexMap } = makeMockScene({
278
+ components: [
279
+ {
280
+ refid: 1,
281
+ type: 'rect',
282
+ threeD: {
283
+ enabled: true,
284
+ geometry: { type: 'box', size: { x: 100 } },
285
+ material: { color: '#f00', metalness: 0.5 }
286
+ }
287
+ }
288
+ ]
289
+ });
290
+ dispatchBoardEditOp(scene, {
291
+ op: 'modify',
292
+ refid: 1,
293
+ patch: { threeD: { material: { color: '#0f0' } } }
294
+ });
295
+ const target = refidIndexMap.get(1);
296
+ expect(target.model.threeD.material.color).toBe('#0f0');
297
+ expect(target.model.threeD.material.metalness).toBe(0.5); // 보존
298
+ expect(target.model.threeD.geometry).toEqual({ type: 'box', size: { x: 100 } });
299
+ expect(target.model.threeD.enabled).toBe(true);
300
+ });
301
+ test('없는 refid → applied:false', () => {
302
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
303
+ const r = dispatchBoardEditOp(scene, {
304
+ op: 'modify',
305
+ refid: 999,
306
+ patch: { left: 10 }
307
+ });
308
+ expect(r.applied).toBe(false);
309
+ expect(r.inverseOps).toEqual([]);
310
+ });
311
+ });
312
+ // ── dispatchBoardEditOp — modifyBoard ───────────────────────────────
313
+ describe('dispatchBoardEditOp — modifyBoard', () => {
314
+ test('정상 — root.set 호출 + inverse 는 root 의 원본 키', () => {
315
+ const { scene, calls } = makeMockScene({
316
+ rootModel: { fillStyle: '#fff', name: 'Floor' },
317
+ components: []
318
+ });
319
+ const r = dispatchBoardEditOp(scene, {
320
+ op: 'modifyBoard',
321
+ patch: { fillStyle: '#0a1929' }
322
+ });
323
+ expect(r.applied).toBe(true);
324
+ expect(scene.root.model.fillStyle).toBe('#0a1929');
325
+ expect(scene.root.model.name).toBe('Floor'); // 다른 키 보존
326
+ expect(r.inverseOps).toEqual([
327
+ { op: 'modifyBoard', patch: { fillStyle: '#fff' } }
328
+ ]);
329
+ expect(calls.find(c => c.method === 'commander.execute')).toBeDefined();
330
+ });
331
+ test('patch 의 components 키는 무시 — 자식 변경은 별도 op', () => {
332
+ const { scene } = makeMockScene({
333
+ rootModel: { fillStyle: '#fff' },
334
+ components: [{ refid: 1, type: 'rect' }]
335
+ });
336
+ const r = dispatchBoardEditOp(scene, {
337
+ op: 'modifyBoard',
338
+ patch: { fillStyle: '#000', components: [] }
339
+ });
340
+ expect(r.applied).toBe(true);
341
+ expect(scene.root.model.fillStyle).toBe('#000');
342
+ expect(scene.root.model.components).toBeUndefined();
343
+ // inverse 도 components 키 미포함
344
+ const inv = r.inverseOps[0];
345
+ expect(Object.keys(inv.patch).sort()).toEqual(['fillStyle']);
346
+ });
347
+ test('scene.root 없음 / set 미정의 → applied:false', () => {
348
+ const { scene } = makeMockScene(); // rootModel 없음
349
+ const r = dispatchBoardEditOp(scene, {
350
+ op: 'modifyBoard',
351
+ patch: { fillStyle: '#000' }
352
+ });
353
+ expect(r.applied).toBe(false);
354
+ });
355
+ test('원본에 없는 root 키 → inverse 에 null 보관 (revert 시 reset)', () => {
356
+ const { scene } = makeMockScene({
357
+ rootModel: { fillStyle: '#fff' },
358
+ components: []
359
+ });
360
+ const r = dispatchBoardEditOp(scene, {
361
+ op: 'modifyBoard',
362
+ patch: { sky: 'color', exposure: 0.7 }
363
+ });
364
+ expect(r.applied).toBe(true);
365
+ expect(r.inverseOps).toEqual([
366
+ { op: 'modifyBoard', patch: { sky: null, exposure: null } }
367
+ ]);
368
+ });
369
+ });
370
+ // ── dispatchBoardEditOp — replace ───────────────────────────────────
371
+ describe('dispatchBoardEditOp — replace (호스트 wholesale 경로)', () => {
372
+ test('replace 는 dispatcher 가 받지 않음 — applied:false', () => {
373
+ const { scene } = makeMockScene();
374
+ const r = dispatchBoardEditOp(scene, {
375
+ op: 'replace',
376
+ board: { width: 800, height: 600, components: [] }
377
+ });
378
+ expect(r.applied).toBe(false);
379
+ expect(r.inverseOps).toEqual([]);
380
+ });
381
+ });
382
+ // ── dispatchBoardEditOp — align ─────────────────────────────────────
383
+ describe('dispatchBoardEditOp — align', () => {
384
+ test('정상 — scene.align 호출 + 직전 좌표를 modify inverse 시퀀스로', () => {
385
+ const { scene, calls, refidIndexMap } = makeMockScene({
386
+ components: [
387
+ { refid: 1, type: 'rect', left: 10, top: 0, width: 100, height: 50 },
388
+ { refid: 2, type: 'rect', left: 50, top: 0, width: 80, height: 40 }
389
+ ]
390
+ });
391
+ const r = dispatchBoardEditOp(scene, {
392
+ op: 'align',
393
+ refids: [1, 2],
394
+ direction: 'left'
395
+ });
396
+ expect(r.applied).toBe(true);
397
+ // mock 의 align('left') 가 모든 selected 의 left=0 으로 만든다
398
+ expect(refidIndexMap.get(1).model.left).toBe(0);
399
+ expect(refidIndexMap.get(2).model.left).toBe(0);
400
+ // inverse 는 직전 좌표를 modify 시퀀스로 — 원래 left 복원
401
+ expect(r.inverseOps).toEqual([
402
+ { op: 'modify', refid: 1, patch: { left: 10, top: 0, width: 100, height: 50 } },
403
+ { op: 'modify', refid: 2, patch: { left: 50, top: 0, width: 80, height: 40 } }
404
+ ]);
405
+ const alignCall = calls.find(c => c.method === 'align');
406
+ expect(alignCall.direction).toBe('left');
407
+ });
408
+ test('refids 가 1개 미만 — applied:false (align 은 2개 이상 필요)', () => {
409
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
410
+ const r = dispatchBoardEditOp(scene, {
411
+ op: 'align',
412
+ refids: [1],
413
+ direction: 'left'
414
+ });
415
+ expect(r.applied).toBe(false);
416
+ });
417
+ test('refids 안에 없는 refid 가 있어도 매칭된 게 2개 이상이면 진행', () => {
418
+ const { scene } = makeMockScene({
419
+ components: [
420
+ { refid: 1, left: 10 },
421
+ { refid: 2, left: 20 }
422
+ ]
423
+ });
424
+ const r = dispatchBoardEditOp(scene, {
425
+ op: 'align',
426
+ refids: [1, 999, 2],
427
+ direction: 'left'
428
+ });
429
+ expect(r.applied).toBe(true);
430
+ expect(r.inverseOps).toHaveLength(2);
431
+ });
432
+ test('selected 복원 — align 후 직전 selected 으로 되돌림', () => {
433
+ const { scene, refidIndexMap } = makeMockScene({
434
+ components: [{ refid: 1 }, { refid: 2 }]
435
+ });
436
+ const sentinel = { sentinel: true };
437
+ scene.selected = [sentinel];
438
+ dispatchBoardEditOp(scene, {
439
+ op: 'align',
440
+ refids: [1, 2],
441
+ direction: 'left'
442
+ });
443
+ // align 진행 중에는 selected = targets, 완료 후 sentinel 복원
444
+ expect(scene.selected).toEqual([sentinel]);
445
+ });
446
+ });
447
+ // ── dispatchBoardEditOp — distribute ────────────────────────────────
448
+ describe('dispatchBoardEditOp — distribute', () => {
449
+ test('horizontal → scene.distribute("HORIZONTAL") 대문자 매핑', () => {
450
+ const { scene, calls } = makeMockScene({
451
+ components: [{ refid: 1, left: 0, top: 0 }, { refid: 2, left: 100, top: 0 }]
452
+ });
453
+ dispatchBoardEditOp(scene, {
454
+ op: 'distribute',
455
+ refids: [1, 2],
456
+ axis: 'horizontal'
457
+ });
458
+ const dCall = calls.find(c => c.method === 'distribute');
459
+ expect(dCall.axis).toBe('HORIZONTAL'); // 대문자 매핑 회귀 방지
460
+ });
461
+ test('vertical → scene.distribute("VERTICAL")', () => {
462
+ const { scene, calls } = makeMockScene({
463
+ components: [{ refid: 1, left: 0, top: 0 }, { refid: 2, left: 0, top: 100 }]
464
+ });
465
+ dispatchBoardEditOp(scene, {
466
+ op: 'distribute',
467
+ refids: [1, 2],
468
+ axis: 'vertical'
469
+ });
470
+ expect(calls.find(c => c.method === 'distribute').axis).toBe('VERTICAL');
471
+ });
472
+ test('inverse 는 직전 left/top 의 modify 시퀀스 (width/height 미포함)', () => {
473
+ // align 과 달리 distribute 는 좌표만 변경 → inverse 도 좌표만 보관 (size 키 없음)
474
+ const { scene } = makeMockScene({
475
+ components: [
476
+ { refid: 1, left: 10, top: 5, width: 100, height: 50 },
477
+ { refid: 2, left: 50, top: 5, width: 80, height: 40 }
478
+ ]
479
+ });
480
+ const r = dispatchBoardEditOp(scene, {
481
+ op: 'distribute',
482
+ refids: [1, 2],
483
+ axis: 'horizontal'
484
+ });
485
+ expect(r.inverseOps).toEqual([
486
+ { op: 'modify', refid: 1, patch: { left: 10, top: 5 } },
487
+ { op: 'modify', refid: 2, patch: { left: 50, top: 5 } }
488
+ ]);
489
+ });
490
+ test('refids 부족 (<2 매칭) → applied:false', () => {
491
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
492
+ const r = dispatchBoardEditOp(scene, {
493
+ op: 'distribute',
494
+ refids: [1],
495
+ axis: 'horizontal'
496
+ });
497
+ expect(r.applied).toBe(false);
498
+ });
499
+ });
500
+ // ── dispatchBoardEditOp — group ─────────────────────────────────────
501
+ describe('dispatchBoardEditOp — group', () => {
502
+ test('정상 — scene.group 호출 + 새 group refid 의 ungroup inverse', () => {
503
+ const { scene, calls, refidIndexMap } = makeMockScene({
504
+ components: [{ refid: 1 }, { refid: 2 }]
505
+ });
506
+ const r = dispatchBoardEditOp(scene, { op: 'group', refids: [1, 2] });
507
+ expect(r.applied).toBe(true);
508
+ expect(r.inverseOps).toHaveLength(1);
509
+ const inv = r.inverseOps[0];
510
+ expect(inv.op).toBe('ungroup');
511
+ // mock group 은 새 refid 3 발급
512
+ expect(inv.refid).toBe(3);
513
+ expect(refidIndexMap.has(3)).toBe(true);
514
+ expect(calls.find(c => c.method === 'group')).toBeDefined();
515
+ });
516
+ test('refids < 2 — applied:false (group 은 2개 이상 필요)', () => {
517
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
518
+ const r = dispatchBoardEditOp(scene, { op: 'group', refids: [1] });
519
+ expect(r.applied).toBe(false);
520
+ });
521
+ test('selected 복원 — group 직전 selected 로 되돌림', () => {
522
+ const { scene, refidIndexMap } = makeMockScene({
523
+ components: [{ refid: 1 }, { refid: 2 }]
524
+ });
525
+ const sentinel = { id: 'before-group' };
526
+ scene.selected = [sentinel];
527
+ dispatchBoardEditOp(scene, { op: 'group', refids: [1, 2] });
528
+ expect(scene.selected).toEqual([sentinel]);
529
+ });
530
+ });
531
+ // ── dispatchBoardEditOp — ungroup ───────────────────────────────────
532
+ describe('dispatchBoardEditOp — ungroup', () => {
533
+ test('정상 — 자식 refid 들 캡처해 group inverse 생성', () => {
534
+ const { scene, refidIndexMap, calls } = makeMockScene({
535
+ components: [{ refid: 5, type: 'group' }]
536
+ });
537
+ // mock group 자식 — child 의 .get('refid') 가 작동하도록 makeMockSceneComp 형태
538
+ const g = refidIndexMap.get(5);
539
+ g.components = [makeMockSceneComp({ refid: 11 }), makeMockSceneComp({ refid: 12 })];
540
+ const r = dispatchBoardEditOp(scene, { op: 'ungroup', refid: 5 });
541
+ expect(r.applied).toBe(true);
542
+ expect(r.inverseOps).toEqual([{ op: 'group', refids: [11, 12] }]);
543
+ expect(calls.find(c => c.method === 'ungroup')).toBeDefined();
544
+ });
545
+ test('자식이 1개 — inverse 없음 (group 은 2개 이상 필요), applied:true 유지', () => {
546
+ const { scene, refidIndexMap } = makeMockScene({
547
+ components: [{ refid: 5 }]
548
+ });
549
+ const g = refidIndexMap.get(5);
550
+ g.components = [makeMockSceneComp({ refid: 11 })];
551
+ const r = dispatchBoardEditOp(scene, { op: 'ungroup', refid: 5 });
552
+ expect(r.applied).toBe(true);
553
+ expect(r.inverseOps).toEqual([]);
554
+ });
555
+ test('없는 refid → applied:false', () => {
556
+ const { scene } = makeMockScene();
557
+ const r = dispatchBoardEditOp(scene, { op: 'ungroup', refid: 999 });
558
+ expect(r.applied).toBe(false);
559
+ });
560
+ });
561
+ // ── dispatchBoardEditOp — zorder ────────────────────────────────────
562
+ describe('dispatchBoardEditOp — zorder', () => {
563
+ test('forward → 정상 + inverse 는 backward', () => {
564
+ const { scene, calls } = makeMockScene({ components: [{ refid: 1 }] });
565
+ const r = dispatchBoardEditOp(scene, {
566
+ op: 'zorder',
567
+ refid: 1,
568
+ direction: 'forward'
569
+ });
570
+ expect(r.applied).toBe(true);
571
+ expect(r.inverseOps).toEqual([
572
+ { op: 'zorder', refid: 1, direction: 'backward' }
573
+ ]);
574
+ expect(calls.find(c => c.method === 'zorder').direction).toBe('forward');
575
+ });
576
+ test('4 direction 모두 — 반대 방향이 inverse', () => {
577
+ const cases = [
578
+ ['forward', 'backward'],
579
+ ['backward', 'forward'],
580
+ ['front', 'back'],
581
+ ['back', 'front']
582
+ ];
583
+ for (const [dir, opp] of cases) {
584
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
585
+ const r = dispatchBoardEditOp(scene, {
586
+ op: 'zorder',
587
+ refid: 1,
588
+ direction: dir
589
+ });
590
+ expect(r.inverseOps[0]).toEqual({
591
+ op: 'zorder',
592
+ refid: 1,
593
+ direction: opp
594
+ });
595
+ }
596
+ });
597
+ test('없는 refid → applied:false', () => {
598
+ const { scene } = makeMockScene();
599
+ const r = dispatchBoardEditOp(scene, {
600
+ op: 'zorder',
601
+ refid: 999,
602
+ direction: 'forward'
603
+ });
604
+ expect(r.applied).toBe(false);
605
+ });
606
+ });
607
+ // ── computeArrangePositions — pure 위치 계산 ─────────────────────────
608
+ describe('computeArrangePositions — grid', () => {
609
+ test('2x2 그리드 — 4 컴포넌트 동일 사이즈, 기본 gap=10', () => {
610
+ const r = computeArrangePositions({ type: 'grid', cols: 2, anchor: { left: 0, top: 0 } }, [
611
+ { left: 0, top: 0 },
612
+ { left: 0, top: 0 },
613
+ { left: 0, top: 0 },
614
+ { left: 0, top: 0 }
615
+ ], [
616
+ { width: 100, height: 50 },
617
+ { width: 100, height: 50 },
618
+ { width: 100, height: 50 },
619
+ { width: 100, height: 50 }
620
+ ]);
621
+ expect(r).toEqual([
622
+ { left: 0, top: 0 }, // (0,0)
623
+ { left: 110, top: 0 }, // (0,1) — width + gap
624
+ { left: 0, top: 60 }, // (1,0)
625
+ { left: 110, top: 60 } // (1,1)
626
+ ]);
627
+ });
628
+ test('cell 크기 = 모든 사이즈의 max — 다른 사이즈에서도 겹침 없음', () => {
629
+ const r = computeArrangePositions({ type: 'grid', cols: 2, gap: 0, anchor: { left: 0, top: 0 } }, [
630
+ { left: 0, top: 0 },
631
+ { left: 0, top: 0 },
632
+ { left: 0, top: 0 }
633
+ ], [
634
+ { width: 50, height: 100 }, // 가장 키 큰 컴포넌트
635
+ { width: 200, height: 30 }, // 가장 너비 큰 컴포넌트
636
+ { width: 80, height: 60 }
637
+ ]);
638
+ // cell = 200 × 100
639
+ expect(r).toEqual([
640
+ { left: 0, top: 0 },
641
+ { left: 200, top: 0 },
642
+ { left: 0, top: 100 }
643
+ ]);
644
+ });
645
+ test('anchor 미지정 시 첫 컴포넌트의 현재 (left, top) 사용', () => {
646
+ const r = computeArrangePositions({ type: 'grid', cols: 2, gap: 0 }, [
647
+ { left: 100, top: 50 },
648
+ { left: 999, top: 999 }, // 무시
649
+ { left: 0, top: 0 }
650
+ ], [
651
+ { width: 50, height: 50 },
652
+ { width: 50, height: 50 },
653
+ { width: 50, height: 50 }
654
+ ]);
655
+ expect(r[0]).toEqual({ left: 100, top: 50 });
656
+ expect(r[1]).toEqual({ left: 150, top: 50 });
657
+ expect(r[2]).toEqual({ left: 100, top: 100 });
658
+ });
659
+ test('cols 가 floor 처리 (소수도 허용 — Math.floor)', () => {
660
+ const r = computeArrangePositions({ type: 'grid', cols: 2.7, anchor: { left: 0, top: 0 } }, [
661
+ { left: 0, top: 0 },
662
+ { left: 0, top: 0 }
663
+ ], [
664
+ { width: 100, height: 100 },
665
+ { width: 100, height: 100 }
666
+ ]);
667
+ // 2.7 → 2 cols
668
+ expect(r).toEqual([
669
+ { left: 0, top: 0 },
670
+ { left: 110, top: 0 }
671
+ ]);
672
+ });
673
+ });
674
+ describe('computeArrangePositions — row', () => {
675
+ test('가로 일렬 — 누적 width + gap', () => {
676
+ const r = computeArrangePositions({ type: 'row', gap: 10, anchor: { left: 50, top: 100 } }, [
677
+ { left: 0, top: 0 },
678
+ { left: 0, top: 0 },
679
+ { left: 0, top: 0 }
680
+ ], [
681
+ { width: 80, height: 60 },
682
+ { width: 120, height: 60 },
683
+ { width: 60, height: 60 }
684
+ ]);
685
+ expect(r).toEqual([
686
+ { left: 50, top: 100 },
687
+ { left: 140, top: 100 }, // 50 + 80 + 10
688
+ { left: 270, top: 100 } // 140 + 120 + 10
689
+ ]);
690
+ });
691
+ test('align: center — 다른 height 들이 maxH 기준으로 세로 가운데 정렬', () => {
692
+ const r = computeArrangePositions({ type: 'row', gap: 0, anchor: { left: 0, top: 0 }, align: 'center' }, [
693
+ { left: 0, top: 0 },
694
+ { left: 0, top: 0 }
695
+ ], [
696
+ { width: 100, height: 80 }, // maxH
697
+ { width: 100, height: 40 }
698
+ ]);
699
+ expect(r[0].top).toBe(0); // tall 한 거 자체는 anchor.top
700
+ expect(r[1].top).toBe(20); // (80 - 40) / 2
701
+ });
702
+ test('align: end — 바닥 정렬', () => {
703
+ const r = computeArrangePositions({ type: 'row', gap: 0, anchor: { left: 0, top: 0 }, align: 'end' }, [
704
+ { left: 0, top: 0 },
705
+ { left: 0, top: 0 }
706
+ ], [
707
+ { width: 100, height: 80 },
708
+ { width: 100, height: 40 }
709
+ ]);
710
+ expect(r[0].top).toBe(0); // tallest stays
711
+ expect(r[1].top).toBe(40); // 80 - 40
712
+ });
713
+ test('align 기본값 = start (top-aligned)', () => {
714
+ const r = computeArrangePositions({ type: 'row', anchor: { left: 0, top: 50 } }, [
715
+ { left: 0, top: 0 },
716
+ { left: 0, top: 0 }
717
+ ], [
718
+ { width: 100, height: 80 },
719
+ { width: 100, height: 40 }
720
+ ]);
721
+ expect(r[0].top).toBe(50);
722
+ expect(r[1].top).toBe(50); // start = same top
723
+ });
724
+ });
725
+ describe('computeArrangePositions — column', () => {
726
+ test('세로 일렬 — 누적 height + gap', () => {
727
+ const r = computeArrangePositions({ type: 'column', gap: 10, anchor: { left: 50, top: 0 } }, [
728
+ { left: 0, top: 0 },
729
+ { left: 0, top: 0 }
730
+ ], [
731
+ { width: 100, height: 60 },
732
+ { width: 100, height: 80 }
733
+ ]);
734
+ expect(r).toEqual([
735
+ { left: 50, top: 0 },
736
+ { left: 50, top: 70 } // 0 + 60 + 10
737
+ ]);
738
+ });
739
+ test('align: center — 다른 width 들이 maxW 기준으로 가로 가운데 정렬', () => {
740
+ const r = computeArrangePositions({ type: 'column', gap: 0, anchor: { left: 0, top: 0 }, align: 'center' }, [
741
+ { left: 0, top: 0 },
742
+ { left: 0, top: 0 }
743
+ ], [
744
+ { width: 200, height: 50 },
745
+ { width: 100, height: 50 } // narrower
746
+ ]);
747
+ expect(r[0].left).toBe(0);
748
+ expect(r[1].left).toBe(50); // (200 - 100) / 2
749
+ });
750
+ test('align: end — 우측 정렬', () => {
751
+ const r = computeArrangePositions({ type: 'column', gap: 0, anchor: { left: 0, top: 0 }, align: 'end' }, [
752
+ { left: 0, top: 0 },
753
+ { left: 0, top: 0 }
754
+ ], [
755
+ { width: 200, height: 50 },
756
+ { width: 100, height: 50 }
757
+ ]);
758
+ expect(r[1].left).toBe(100); // 200 - 100
759
+ });
760
+ });
761
+ // ── dispatchBoardEditOp — arrange ─────────────────────────────────
762
+ describe('dispatchBoardEditOp — arrange', () => {
763
+ test('grid: target.set 호출 + 새 좌표 적용 + inverse 는 modify 시퀀스', () => {
764
+ const { scene, calls, refidIndexMap } = makeMockScene({
765
+ components: [
766
+ { refid: 1, type: 'rect', left: 999, top: 999, width: 100, height: 50 },
767
+ { refid: 2, type: 'rect', left: 999, top: 999, width: 100, height: 50 },
768
+ { refid: 3, type: 'rect', left: 999, top: 999, width: 100, height: 50 },
769
+ { refid: 4, type: 'rect', left: 999, top: 999, width: 100, height: 50 }
770
+ ]
771
+ });
772
+ const r = dispatchBoardEditOp(scene, {
773
+ op: 'arrange',
774
+ refids: [1, 2, 3, 4],
775
+ layout: { type: 'grid', cols: 2, gap: 0, anchor: { left: 0, top: 0 } }
776
+ });
777
+ expect(r.applied).toBe(true);
778
+ // 좌표가 grid 에 따라 갱신
779
+ expect(refidIndexMap.get(1).model.left).toBe(0);
780
+ expect(refidIndexMap.get(1).model.top).toBe(0);
781
+ expect(refidIndexMap.get(2).model.left).toBe(100);
782
+ expect(refidIndexMap.get(2).model.top).toBe(0);
783
+ expect(refidIndexMap.get(3).model.left).toBe(0);
784
+ expect(refidIndexMap.get(3).model.top).toBe(50);
785
+ expect(refidIndexMap.get(4).model.left).toBe(100);
786
+ expect(refidIndexMap.get(4).model.top).toBe(50);
787
+ // inverse — 직전 좌표 (모두 999, 999)
788
+ expect(r.inverseOps).toHaveLength(4);
789
+ expect(r.inverseOps[0]).toEqual({
790
+ op: 'modify',
791
+ refid: 1,
792
+ patch: { left: 999, top: 999 }
793
+ });
794
+ // commander.execute (단일 snapshot)
795
+ expect(calls.find(c => c.method === 'commander.execute')).toBeDefined();
796
+ });
797
+ test('row: 누적 width + gap', () => {
798
+ const { scene, refidIndexMap } = makeMockScene({
799
+ components: [
800
+ { refid: 1, type: 'rect', left: 0, top: 0, width: 80, height: 50 },
801
+ { refid: 2, type: 'rect', left: 0, top: 0, width: 120, height: 50 }
802
+ ]
803
+ });
804
+ dispatchBoardEditOp(scene, {
805
+ op: 'arrange',
806
+ refids: [1, 2],
807
+ layout: { type: 'row', gap: 10, anchor: { left: 0, top: 0 } }
808
+ });
809
+ expect(refidIndexMap.get(1).model.left).toBe(0);
810
+ expect(refidIndexMap.get(2).model.left).toBe(90); // 0 + 80 + 10
811
+ });
812
+ test('refids < 2 → applied:false', () => {
813
+ const { scene } = makeMockScene({ components: [{ refid: 1 }] });
814
+ const r = dispatchBoardEditOp(scene, {
815
+ op: 'arrange',
816
+ refids: [1],
817
+ layout: { type: 'grid', cols: 1 }
818
+ });
819
+ expect(r.applied).toBe(false);
820
+ });
821
+ test('일부 refid 누락 — 매칭된 것이 2 이상이면 진행', () => {
822
+ const { scene, refidIndexMap } = makeMockScene({
823
+ components: [
824
+ { refid: 1, left: 0, top: 0, width: 50, height: 50 },
825
+ { refid: 2, left: 0, top: 0, width: 50, height: 50 }
826
+ ]
827
+ });
828
+ const r = dispatchBoardEditOp(scene, {
829
+ op: 'arrange',
830
+ refids: [1, 999, 2],
831
+ layout: { type: 'row', gap: 10, anchor: { left: 0, top: 0 } }
832
+ });
833
+ expect(r.applied).toBe(true);
834
+ expect(refidIndexMap.get(1).model.left).toBe(0);
835
+ expect(refidIndexMap.get(2).model.left).toBe(60);
836
+ });
837
+ });
838
+ // ── 방어 ────────────────────────────────────────────────────────────
839
+ describe('dispatchBoardEditOp — 방어', () => {
840
+ test('scene null → applied:false', () => {
841
+ expect(dispatchBoardEditOp(null, { op: 'add', component: { type: 'rect' } })).toEqual({ applied: false, inverseOps: [] });
842
+ });
843
+ test('op null → applied:false', () => {
844
+ const { scene } = makeMockScene();
845
+ expect(dispatchBoardEditOp(scene, null)).toEqual({
846
+ applied: false,
847
+ inverseOps: []
848
+ });
849
+ });
850
+ test('알 수 없는 op → applied:false', () => {
851
+ const { scene } = makeMockScene();
852
+ expect(dispatchBoardEditOp(scene, { op: 'unknown' })).toEqual({
853
+ applied: false,
854
+ inverseOps: []
855
+ });
856
+ });
857
+ });
858
+ //# sourceMappingURL=board-edit-dispatch.test.js.map