@things-factory/board-ai 10.1.24 → 10.1.26

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 (107) hide show
  1. package/PLAN-styling-tools.md +1 -1
  2. package/dist-client/server/service/assistant.d.ts +5 -4
  3. package/dist-client/server/service/assistant.js +35 -35
  4. package/dist-client/server/service/assistant.js.map +1 -1
  5. package/dist-client/server/service/styling/clear-tools.d.ts +2 -2
  6. package/dist-client/server/service/styling/clear-tools.js.map +1 -1
  7. package/dist-client/server/service/styling/copy-tools.d.ts +3 -3
  8. package/dist-client/server/service/styling/copy-tools.js +1 -1
  9. package/dist-client/server/service/styling/copy-tools.js.map +1 -1
  10. package/dist-client/server/service/styling/effect-tools.d.ts +2 -2
  11. package/dist-client/server/service/styling/effect-tools.js.map +1 -1
  12. package/dist-client/server/service/styling/fill-tools.d.ts +3 -3
  13. package/dist-client/server/service/styling/fill-tools.js +1 -1
  14. package/dist-client/server/service/styling/fill-tools.js.map +1 -1
  15. package/dist-client/server/service/styling/material-tools.d.ts +2 -2
  16. package/dist-client/server/service/styling/material-tools.js.map +1 -1
  17. package/dist-client/server/service/styling/registry.d.ts +6 -6
  18. package/dist-client/server/service/styling/registry.js +2 -2
  19. package/dist-client/server/service/styling/registry.js.map +1 -1
  20. package/dist-client/server/service/styling/stroke-tools.d.ts +2 -2
  21. package/dist-client/server/service/styling/stroke-tools.js.map +1 -1
  22. package/dist-client/server/service/styling/text-tools.d.ts +2 -2
  23. package/dist-client/server/service/styling/text-tools.js.map +1 -1
  24. package/dist-client/server/service/types.d.ts +6 -148
  25. package/dist-client/server/service/types.js.map +1 -1
  26. package/dist-client/server/service/validation/board-model-schema.d.ts +262 -262
  27. package/dist-client/server/service/validation/board-model-schema.js +4 -4
  28. package/dist-client/server/service/validation/board-model-schema.js.map +1 -1
  29. package/dist-client/server/service/validation/tool-validation.js +5 -5
  30. package/dist-client/server/service/validation/tool-validation.js.map +1 -1
  31. package/dist-client/tsconfig.tsbuildinfo +1 -1
  32. package/dist-server/index.d.ts +2 -2
  33. package/dist-server/index.js +2 -2
  34. package/dist-server/index.js.map +1 -1
  35. package/dist-server/service/assistant.d.ts +5 -4
  36. package/dist-server/service/assistant.js +37 -37
  37. package/dist-server/service/assistant.js.map +1 -1
  38. package/dist-server/service/board-ai-resolver.js +2 -2
  39. package/dist-server/service/board-ai-resolver.js.map +1 -1
  40. package/dist-server/service/index.d.ts +0 -1
  41. package/dist-server/service/index.js +0 -1
  42. package/dist-server/service/index.js.map +1 -1
  43. package/dist-server/service/patch-entry/board-patch-subscription.js +1 -1
  44. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -1
  45. package/dist-server/service/patch-entry/patch-entry.d.ts +1 -1
  46. package/dist-server/service/patch-entry/patch-entry.js +1 -1
  47. package/dist-server/service/patch-entry/patch-entry.js.map +1 -1
  48. package/dist-server/service/styling/clear-tools.d.ts +2 -2
  49. package/dist-server/service/styling/clear-tools.js.map +1 -1
  50. package/dist-server/service/styling/copy-tools.d.ts +3 -3
  51. package/dist-server/service/styling/copy-tools.js +1 -1
  52. package/dist-server/service/styling/copy-tools.js.map +1 -1
  53. package/dist-server/service/styling/effect-tools.d.ts +2 -2
  54. package/dist-server/service/styling/effect-tools.js.map +1 -1
  55. package/dist-server/service/styling/fill-tools.d.ts +3 -3
  56. package/dist-server/service/styling/fill-tools.js +1 -1
  57. package/dist-server/service/styling/fill-tools.js.map +1 -1
  58. package/dist-server/service/styling/material-tools.d.ts +2 -2
  59. package/dist-server/service/styling/material-tools.js.map +1 -1
  60. package/dist-server/service/styling/registry.d.ts +6 -6
  61. package/dist-server/service/styling/registry.js +2 -2
  62. package/dist-server/service/styling/registry.js.map +1 -1
  63. package/dist-server/service/styling/stroke-tools.d.ts +2 -2
  64. package/dist-server/service/styling/stroke-tools.js.map +1 -1
  65. package/dist-server/service/styling/text-tools.d.ts +2 -2
  66. package/dist-server/service/styling/text-tools.js.map +1 -1
  67. package/dist-server/service/types.d.ts +6 -148
  68. package/dist-server/service/types.js.map +1 -1
  69. package/dist-server/service/validation/board-model-schema.d.ts +262 -262
  70. package/dist-server/service/validation/board-model-schema.js +4 -4
  71. package/dist-server/service/validation/board-model-schema.js.map +1 -1
  72. package/dist-server/service/validation/tool-validation.js +5 -5
  73. package/dist-server/service/validation/tool-validation.js.map +1 -1
  74. package/dist-server/tsconfig.tsbuildinfo +1 -1
  75. package/logs/.5e5d741d8b7784a2fbad65eedc0fd46946aaf6f2-audit.json +5 -0
  76. package/logs/connections-2026-09-17-16.log +0 -0
  77. package/package.json +8 -7
  78. package/server/index.ts +2 -2
  79. package/server/service/assistant-integration.test.ts +44 -44
  80. package/server/service/assistant-llm-smoke.test.ts +5 -5
  81. package/server/service/assistant.test.ts +86 -86
  82. package/server/service/assistant.ts +41 -43
  83. package/server/service/board-ai-resolver.ts +2 -2
  84. package/server/service/index.ts +0 -1
  85. package/server/service/patch-entry/board-patch-subscription.ts +1 -1
  86. package/server/service/patch-entry/patch-entry.ts +2 -2
  87. package/server/service/styling/clear-tools.ts +2 -2
  88. package/server/service/styling/copy-tools.ts +3 -3
  89. package/server/service/styling/effect-tools.ts +2 -2
  90. package/server/service/styling/fill-tools.test.ts +1 -1
  91. package/server/service/styling/fill-tools.ts +4 -4
  92. package/server/service/styling/material-tools.ts +2 -2
  93. package/server/service/styling/registry.ts +6 -6
  94. package/server/service/styling/stroke-tools.test.ts +1 -1
  95. package/server/service/styling/stroke-tools.ts +3 -3
  96. package/server/service/styling/text-tools.ts +2 -2
  97. package/server/service/tool-exposure.test.ts +7 -7
  98. package/server/service/types.ts +9 -110
  99. package/server/service/validation/board-model-schema.test.ts +3 -3
  100. package/server/service/validation/board-model-schema.ts +4 -4
  101. package/server/service/validation/tool-validation.ts +5 -5
  102. package/dist-server/service/apply-patch.d.ts +0 -47
  103. package/dist-server/service/apply-patch.js +0 -369
  104. package/dist-server/service/apply-patch.js.map +0 -1
  105. package/server/service/apply-patch-drift.test.ts +0 -831
  106. package/server/service/apply-patch.test.ts +0 -757
  107. package/server/service/apply-patch.ts +0 -411
@@ -1,757 +0,0 @@
1
- /**
2
- * applyBoardEditPatch / applyOp 단위 테스트.
3
- *
4
- * BoardEditPatch 4종 op (add / remove / modify / replace) 의 정확한 적용,
5
- * pure function 보장 (입력 mutate 없음), nested object deep merge 동작을 회귀 방지.
6
- *
7
- * 식별자 정책 — 컴포넌트 targeting 은 항상 `refid` (things-scene universal numeric
8
- * handle). `id` 는 데이터 바인딩 이름이며 unique 가 아니라 targeting 에 사용하지 않음.
9
- */
10
- import {
11
- applyBoardEditPatch,
12
- applyBoardEditPatchVerbose,
13
- applyOp,
14
- computeInverseOp,
15
- mergeComponent
16
- } from './apply-patch'
17
- import type { BoardEditOp, BoardEditPatch } from './types'
18
-
19
- const make = (
20
- components: any[] = [],
21
- width = 1000,
22
- height = 600
23
- ): any => ({ width, height, components })
24
-
25
- describe('applyOp — add', () => {
26
- test('빈 보드에 컴포넌트 추가', () => {
27
- const r = applyOp(make(), { op: 'add', component: { refid: 1, type: 'rect' } as any })
28
- expect(r.components).toHaveLength(1)
29
- expect(r.components[0].refid).toBe(1)
30
- })
31
-
32
- test('기존 컴포넌트 보존하고 추가', () => {
33
- const board = make([{ refid: 1, type: 'rect' }])
34
- const r = applyOp(board, { op: 'add', component: { refid: 2, type: 'circle' } as any })
35
- expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
36
- })
37
-
38
- test('width/height 등 보드 메타 보존', () => {
39
- const board = make([], 1920, 1080)
40
- const r = applyOp(board, { op: 'add', component: { refid: 1, type: 'rect' } as any })
41
- expect(r.width).toBe(1920)
42
- expect(r.height).toBe(1080)
43
- })
44
-
45
- test('입력을 mutate 하지 않음', () => {
46
- const board = make([{ refid: 1, type: 'rect' }])
47
- const before = JSON.stringify(board)
48
- applyOp(board, { op: 'add', component: { refid: 2, type: 'circle' } as any })
49
- expect(JSON.stringify(board)).toBe(before)
50
- })
51
- })
52
-
53
- describe('applyOp — remove', () => {
54
- test('refid 매칭 컴포넌트 제거', () => {
55
- const board = make([
56
- { refid: 1, type: 'rect' },
57
- { refid: 2, type: 'circle' }
58
- ])
59
- const r = applyOp(board, { op: 'remove', refid: 1 })
60
- expect(r.components).toHaveLength(1)
61
- expect(r.components[0].refid).toBe(2)
62
- })
63
-
64
- test('없는 refid 는 변화 없음', () => {
65
- const board = make([{ refid: 1, type: 'rect' }])
66
- const r = applyOp(board, { op: 'remove', refid: 999 })
67
- expect(r.components).toHaveLength(1)
68
- })
69
-
70
- test('id 만 같고 refid 다른 컴포넌트는 제거되지 않음 (id 는 unique 아님)', () => {
71
- // 같은 데이터 바인딩 id 를 가진 두 컴포넌트가 있어도, refid 매칭 한 개만 제거.
72
- const board = make([
73
- { refid: 1, type: 'rect', id: 'motor-1' },
74
- { refid: 2, type: 'rect', id: 'motor-1' }
75
- ])
76
- const r = applyOp(board, { op: 'remove', refid: 1 })
77
- expect(r.components).toHaveLength(1)
78
- expect(r.components[0].refid).toBe(2)
79
- })
80
-
81
- test('입력을 mutate 하지 않음', () => {
82
- const board = make([{ refid: 1, type: 'rect' }])
83
- const before = JSON.stringify(board)
84
- applyOp(board, { op: 'remove', refid: 1 })
85
- expect(JSON.stringify(board)).toBe(before)
86
- })
87
- })
88
-
89
- describe('applyOp — modify', () => {
90
- test('top-level 속성 변경', () => {
91
- const board = make([{ refid: 1, type: 'rect', left: 10, top: 20 }])
92
- const r = applyOp(board, { op: 'modify', refid: 1, patch: { left: 100 } as any })
93
- expect((r.components[0] as any).left).toBe(100)
94
- expect((r.components[0] as any).top).toBe(20)
95
- expect((r.components[0] as any).type).toBe('rect')
96
- })
97
-
98
- test('없는 refid 는 변경 없음', () => {
99
- const board = make([{ refid: 1, type: 'rect' }])
100
- const r = applyOp(board, { op: 'modify', refid: 999, patch: { type: 'circle' } as any })
101
- expect((r.components[0] as any).type).toBe('rect')
102
- })
103
-
104
- test('id 같은 다른 컴포넌트는 영향 없음 (id 는 unique 아님 → modify 도 refid 정확 매칭)', () => {
105
- const board = make([
106
- { refid: 1, type: 'rect', id: 'motor-1', left: 10 },
107
- { refid: 2, type: 'rect', id: 'motor-1', left: 50 }
108
- ])
109
- const r = applyOp(board, { op: 'modify', refid: 1, patch: { left: 999 } as any })
110
- expect((r.components[0] as any).left).toBe(999)
111
- expect((r.components[1] as any).left).toBe(50) // 데이터 바인딩 id 가 같아도 영향 없음
112
- })
113
-
114
- test('nested object (threeD 등) 는 deep merge — 색만 바꿀 때 geometry 보존', () => {
115
- const board = make([
116
- {
117
- refid: 1,
118
- type: 'rect',
119
- threeD: {
120
- enabled: true,
121
- geometry: { type: 'box', size: { x: 100, y: 100, z: 50 } },
122
- material: { color: '#ff0000' }
123
- }
124
- }
125
- ])
126
- const r = applyOp(board, {
127
- op: 'modify',
128
- refid: 1,
129
- patch: { threeD: { material: { color: '#00ff00' } } } as any
130
- })
131
- const c = r.components[0] as any
132
- expect(c.threeD.enabled).toBe(true)
133
- expect(c.threeD.geometry).toEqual({ type: 'box', size: { x: 100, y: 100, z: 50 } })
134
- expect(c.threeD.material.color).toBe('#00ff00')
135
- })
136
-
137
- test('배열 값은 deep merge 안 함 (전체 교체)', () => {
138
- const board = make([{ refid: 1, type: 'polyline', points: [[0, 0], [1, 1]] }])
139
- const r = applyOp(board, {
140
- op: 'modify',
141
- refid: 1,
142
- patch: { points: [[5, 5]] } as any
143
- })
144
- expect((r.components[0] as any).points).toEqual([[5, 5]])
145
- })
146
-
147
- test('patch 에 null 은 키 제거 시맨틱 (typeof null === object 함정 회피)', () => {
148
- const board = make([{ refid: 1, type: 'rect', threeD: { enabled: true } }])
149
- const r = applyOp(board, {
150
- op: 'modify',
151
- refid: 1,
152
- patch: { threeD: null } as any
153
- })
154
- // null 로 set 이 아니라 키 자체 제거
155
- expect('threeD' in (r.components[0] as any)).toBe(false)
156
- })
157
-
158
- test('다른 컴포넌트는 영향 없음', () => {
159
- const board = make([
160
- { refid: 1, type: 'rect', left: 10 },
161
- { refid: 2, type: 'circle', cx: 50 }
162
- ])
163
- const r = applyOp(board, { op: 'modify', refid: 1, patch: { left: 100 } as any })
164
- expect((r.components[1] as any).cx).toBe(50)
165
- })
166
-
167
- test('입력을 mutate 하지 않음', () => {
168
- const board = make([
169
- { refid: 1, type: 'rect', threeD: { material: { color: '#ff0000' } } }
170
- ])
171
- const before = JSON.stringify(board)
172
- applyOp(board, {
173
- op: 'modify',
174
- refid: 1,
175
- patch: { threeD: { material: { color: '#00ff00' } } } as any
176
- })
177
- expect(JSON.stringify(board)).toBe(before)
178
- })
179
- })
180
-
181
- describe('applyOp — modifyBoard (root 속성 변경)', () => {
182
- test('fillStyle 단일 변경 — children 영향 없음', () => {
183
- const board = make([{ refid: 1, type: 'rect', fillStyle: '#fff' }])
184
- const r = applyOp(board, { op: 'modifyBoard', patch: { fillStyle: '#0a1929' } as any })
185
- expect((r as any).fillStyle).toBe('#0a1929')
186
- // children 그대로
187
- expect(r.components).toHaveLength(1)
188
- expect((r.components[0] as any).fillStyle).toBe('#fff')
189
- })
190
-
191
- test('width / height 변경', () => {
192
- const board = make([], 1000, 600)
193
- const r = applyOp(board, { op: 'modifyBoard', patch: { width: 1920, height: 1080 } as any })
194
- expect(r.width).toBe(1920)
195
- expect(r.height).toBe(1080)
196
- })
197
-
198
- test('name 등 임의 root 속성', () => {
199
- const board = make([])
200
- const r: any = applyOp(board, {
201
- op: 'modifyBoard',
202
- patch: { name: 'Production Floor', description: 'desc' } as any
203
- })
204
- expect(r.name).toBe('Production Floor')
205
- expect(r.description).toBe('desc')
206
- })
207
-
208
- test('patch 의 components 키는 무시 — children 변경은 별도 op', () => {
209
- const board = make([{ refid: 1, type: 'rect' }])
210
- const r = applyOp(board, {
211
- op: 'modifyBoard',
212
- patch: { fillStyle: '#000', components: [] } as any
213
- })
214
- expect((r as any).fillStyle).toBe('#000')
215
- expect(r.components).toHaveLength(1) // children 보존
216
- })
217
-
218
- test('nested object deep merge (예: 향후 root level theme 객체)', () => {
219
- const board: any = { ...make([]), theme: { primary: '#fff', secondary: '#aaa' } }
220
- const r: any = applyOp(board, {
221
- op: 'modifyBoard',
222
- patch: { theme: { primary: '#0a1929' } } as any
223
- })
224
- expect(r.theme.primary).toBe('#0a1929')
225
- expect(r.theme.secondary).toBe('#aaa') // 보존
226
- })
227
-
228
- test('입력을 mutate 하지 않음', () => {
229
- const board = make([{ refid: 1, type: 'rect' }])
230
- const before = JSON.stringify(board)
231
- applyOp(board, { op: 'modifyBoard', patch: { fillStyle: '#000' } as any })
232
- expect(JSON.stringify(board)).toBe(before)
233
- })
234
- })
235
-
236
- describe('applyOp — replace', () => {
237
- test('전체 보드 교체', () => {
238
- const board = make([{ refid: 1, type: 'rect' }], 1000, 600)
239
- const newBoard: any = { width: 1920, height: 1080, components: [{ refid: 9, type: 'circle' }] }
240
- const r = applyOp(board, { op: 'replace', board: newBoard })
241
- expect(r).toBe(newBoard)
242
- })
243
- })
244
-
245
- describe('applyBoardEditPatch — 다중 op', () => {
246
- test('여러 op 가 순차 적용', () => {
247
- const board = make([{ refid: 1, type: 'rect' }])
248
- const patch: BoardEditPatch = {
249
- ops: [
250
- { op: 'add', component: { refid: 2, type: 'circle' } as any },
251
- { op: 'modify', refid: 1, patch: { left: 100 } as any },
252
- { op: 'remove', refid: 1 }
253
- ],
254
- summary: '',
255
- confidence: 0.9
256
- }
257
- const r = applyBoardEditPatch(board, patch)
258
- expect(r.components).toHaveLength(1)
259
- expect((r.components[0] as any).refid).toBe(2)
260
- })
261
-
262
- test('빈 ops 는 보드 그대로', () => {
263
- const board = make([{ refid: 1, type: 'rect' }])
264
- const r = applyBoardEditPatch(board, { ops: [], summary: '', confidence: 0 })
265
- expect(r).toBe(board)
266
- })
267
-
268
- test('undefined 보드 → 기본 빈 보드 사용', () => {
269
- const r = applyBoardEditPatch(undefined, {
270
- ops: [{ op: 'add', component: { refid: 1, type: 'rect' } as any }],
271
- summary: '',
272
- confidence: 1
273
- })
274
- expect(r.components).toHaveLength(1)
275
- expect(r.width).toBeGreaterThan(0)
276
- expect(r.height).toBeGreaterThan(0)
277
- })
278
-
279
- test('replace + 후속 op 조합', () => {
280
- const board = make([{ refid: 1, type: 'rect' }])
281
- const newBase: any = { width: 800, height: 480, components: [{ refid: 9, type: 'rect' }] }
282
- const patch: BoardEditPatch = {
283
- ops: [
284
- { op: 'replace', board: newBase },
285
- { op: 'add', component: { refid: 10, type: 'circle' } as any }
286
- ],
287
- summary: '',
288
- confidence: 1
289
- }
290
- const r = applyBoardEditPatch(board, patch)
291
- expect(r.width).toBe(800)
292
- expect(r.components.map((c: any) => c.refid)).toEqual([9, 10])
293
- })
294
-
295
- test('알 수 없는 op 는 무시 (default case)', () => {
296
- const board = make([{ refid: 1, type: 'rect' }])
297
- const patch: any = {
298
- ops: [{ op: 'unknown', whatever: 1 }],
299
- summary: '',
300
- confidence: 0
301
- }
302
- const r = applyBoardEditPatch(board, patch)
303
- expect(r.components).toHaveLength(1)
304
- })
305
- })
306
-
307
- describe('applyBoardEditPatchVerbose — silent no-op 검출 (회귀 방지)', () => {
308
- // 핵심: AI 가 잘못된/존재하지 않는 refid 로 modify/remove 를 시도하면 applyOp 는
309
- // silent no-op 으로 끝난다. Verbose 변형이 이를 missed[] 로 보고하지 않으면
310
- // 호스트가 "AI 가 수정했다는데 보드는 그대로" 상황을 사용자에게 알릴 수 없다.
311
- test('존재하지 않는 refid 로 modify → missed', () => {
312
- const board = make([{ refid: 1, type: 'rect' }])
313
- const r = applyBoardEditPatchVerbose(board, {
314
- ops: [{ op: 'modify', refid: 999, patch: { left: 100 } as any }],
315
- summary: '',
316
- confidence: 1
317
- })
318
- expect(r.applied).toHaveLength(0)
319
- expect(r.missed).toHaveLength(1)
320
- expect(r.board).toBe(board)
321
- })
322
-
323
- test('존재하지 않는 refid 로 remove → missed', () => {
324
- const board = make([{ refid: 1, type: 'rect' }])
325
- const r = applyBoardEditPatchVerbose(board, {
326
- ops: [{ op: 'remove', refid: 999 }],
327
- summary: '',
328
- confidence: 1
329
- })
330
- expect(r.applied).toHaveLength(0)
331
- expect(r.missed).toHaveLength(1)
332
- expect(r.board).toBe(board)
333
- })
334
-
335
- test('정상 modify → applied', () => {
336
- const board = make([{ refid: 1, type: 'rect', left: 0 }])
337
- const r = applyBoardEditPatchVerbose(board, {
338
- ops: [{ op: 'modify', refid: 1, patch: { left: 100 } as any }],
339
- summary: '',
340
- confidence: 1
341
- })
342
- expect(r.applied).toHaveLength(1)
343
- expect(r.missed).toHaveLength(0)
344
- expect((r.board.components[0] as any).left).toBe(100)
345
- })
346
-
347
- test('add 는 항상 applied (refid 의존 안 함 — 추가될 컴포넌트의 refid 는 scene 이 부여)', () => {
348
- const board = make()
349
- const r = applyBoardEditPatchVerbose(board, {
350
- ops: [{ op: 'add', component: { refid: 9, type: 'rect' } as any }],
351
- summary: '',
352
- confidence: 1
353
- })
354
- expect(r.applied).toHaveLength(1)
355
- expect(r.missed).toHaveLength(0)
356
- })
357
-
358
- test('replace 는 항상 applied', () => {
359
- const board = make([{ refid: 1, type: 'rect' }])
360
- const r = applyBoardEditPatchVerbose(board, {
361
- ops: [
362
- { op: 'replace', board: { width: 800, height: 600, components: [] } as any }
363
- ],
364
- summary: '',
365
- confidence: 1
366
- })
367
- expect(r.applied).toHaveLength(1)
368
- expect(r.missed).toHaveLength(0)
369
- })
370
-
371
- test('mixed: 일부 applied + 일부 missed', () => {
372
- const board = make([{ refid: 1, type: 'rect' }])
373
- const r = applyBoardEditPatchVerbose(board, {
374
- ops: [
375
- { op: 'modify', refid: 1, patch: { left: 1 } as any },
376
- { op: 'modify', refid: 998, patch: { left: 2 } as any },
377
- { op: 'remove', refid: 999 },
378
- { op: 'add', component: { refid: 2, type: 'circle' } as any }
379
- ],
380
- summary: '',
381
- confidence: 1
382
- })
383
- expect(r.applied).toHaveLength(2) // modify refid 1 + add
384
- expect(r.missed).toHaveLength(2) // modify ghost + remove phantom
385
- expect(r.board.components.map((c: any) => c.refid)).toEqual([1, 2])
386
- })
387
-
388
- test('빈 ops — applied/missed 모두 빈 배열', () => {
389
- const board = make([{ refid: 1, type: 'rect' }])
390
- const r = applyBoardEditPatchVerbose(board, { ops: [], summary: '', confidence: 1 })
391
- expect(r.applied).toEqual([])
392
- expect(r.missed).toEqual([])
393
- expect(r.board).toBe(board)
394
- })
395
-
396
- test('applyBoardEditPatch (non-verbose) 는 board 만 반환 — 기존 호출자 호환', () => {
397
- const board = make([{ refid: 1, type: 'rect', left: 0 }])
398
- const r = applyBoardEditPatch(board, {
399
- ops: [{ op: 'modify', refid: 1, patch: { left: 50 } as any }],
400
- summary: '',
401
- confidence: 1
402
- })
403
- expect((r.components[0] as any).left).toBe(50)
404
- })
405
-
406
- test('modifyBoard 가 fillStyle 외 root 키 (sky/exposure 등) 만 바꿀 때 → applied (회귀 방지)', () => {
407
- // 회귀 방지: componentsUnchanged 가 width/height/fillStyle 만 비교하므로 sky/exposure
408
- // 등 다른 root 키를 바꾸는 modifyBoard 는 false negative 로 missed 분류돼 board 가
409
- // 갱신 안 되던 silent data-loss 버그. componentMutating 분기로 modify/remove 만
410
- // componentsUnchanged 검사하도록 수정.
411
- const board: any = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
412
- const r = applyBoardEditPatchVerbose(board, {
413
- ops: [{ op: 'modifyBoard', patch: { sky: 'color', exposure: 0.7 } as any }],
414
- summary: '',
415
- confidence: 1
416
- })
417
- expect(r.applied).toHaveLength(1)
418
- expect(r.missed).toHaveLength(0)
419
- expect((r.board as any).sky).toBe('color')
420
- expect((r.board as any).exposure).toBe(0.7)
421
- })
422
- })
423
-
424
- describe('mergeComponent — host 측 in-place 적용에 사용되는 deep merge', () => {
425
- test('top-level 속성 추가/변경', () => {
426
- const r = mergeComponent({ refid: 1, type: 'rect', left: 10 } as any, { left: 20, top: 30 } as any)
427
- expect(r).toEqual({ refid: 1, type: 'rect', left: 20, top: 30 })
428
- })
429
-
430
- test('nested object deep merge (threeD)', () => {
431
- const base = {
432
- refid: 1,
433
- type: 'rect',
434
- threeD: {
435
- enabled: true,
436
- material: { color: '#ff0000', metalness: 0.5 },
437
- geometry: { type: 'box', size: { x: 100 } }
438
- }
439
- } as any
440
- const patch = {
441
- threeD: { material: { color: '#00ff00' } }
442
- } as any
443
- const r: any = mergeComponent(base, patch)
444
- expect(r.threeD.material.color).toBe('#00ff00')
445
- expect(r.threeD.material.metalness).toBe(0.5) // 보존
446
- expect(r.threeD.geometry).toEqual({ type: 'box', size: { x: 100 } }) // 보존
447
- expect(r.threeD.enabled).toBe(true) // 보존
448
- })
449
-
450
- test('배열은 deep merge 안 함 (전체 교체)', () => {
451
- const r: any = mergeComponent({ refid: 1, type: 'polyline', points: [[0, 0], [10, 10]] } as any, {
452
- points: [[5, 5]]
453
- } as any)
454
- expect(r.points).toEqual([[5, 5]])
455
- })
456
-
457
- test('patch 에 null = 키 제거 (null 로 set 아님)', () => {
458
- const r: any = mergeComponent({ refid: 1, type: 'rect', threeD: { enabled: true } } as any, {
459
- threeD: null
460
- } as any)
461
- expect('threeD' in r).toBe(false)
462
- })
463
-
464
- test('patch 에 null = 키 제거 — fillStyle (mode=none 시나리오)', () => {
465
- const r: any = mergeComponent(
466
- { refid: 1, type: 'rect', fillStyle: { type: 'gradient', colorStops: [] } } as any,
467
- { fillStyle: null } as any
468
- )
469
- expect('fillStyle' in r).toBe(false)
470
- })
471
-
472
- test('patch 에 null = 키 제거 — nested deep merge', () => {
473
- // threeD 병합 중 metalness 만 제거하고 싶을 때
474
- const r: any = mergeComponent(
475
- { refid: 1, type: 'rect', threeD: { material: { color: '#fff', metalness: 0.5 } } } as any,
476
- { threeD: { material: { metalness: null } } } as any
477
- )
478
- expect(r.threeD.material).toEqual({ color: '#fff' })
479
- expect('metalness' in r.threeD.material).toBe(false)
480
- })
481
-
482
- test('base 를 mutate 하지 않음', () => {
483
- const base = { refid: 1, type: 'rect', threeD: { material: { color: '#f00' } } } as any
484
- const before = JSON.stringify(base)
485
- mergeComponent(base, { threeD: { material: { color: '#0f0' } } } as any)
486
- expect(JSON.stringify(base)).toBe(before)
487
- })
488
- })
489
-
490
- describe('applyBoardEditPatch — 입력 보존', () => {
491
- test('패치 적용 후 원본 board 객체는 변경 없음', () => {
492
- const board = make([{ refid: 1, type: 'rect', left: 10 }])
493
- const before = JSON.stringify(board)
494
- applyBoardEditPatch(board, {
495
- ops: [
496
- { op: 'add', component: { refid: 2, type: 'circle' } as any },
497
- { op: 'modify', refid: 1, patch: { left: 999 } as any }
498
- ],
499
- summary: '',
500
- confidence: 1
501
- })
502
- expect(JSON.stringify(board)).toBe(before)
503
- })
504
- })
505
-
506
- describe('computeInverseOp — Revert 의 역연산 계산', () => {
507
- // 핵심 보장: patch 적용 직전 board 상태에서 op 의 inverse 를 만들 수 있어야 한다.
508
- // 호스트가 patch 시점에 누적해두면 나중에 역순 in-place 적용으로 복원 가능.
509
-
510
- test('add: 모델 단계에서 inverse 계산 불가 → null (호스트가 scene refid 캡처 후 직접 만듦)', () => {
511
- const board = make([])
512
- const r = computeInverseOp(board, {
513
- op: 'add',
514
- component: { refid: 9, type: 'rect' } as any
515
- })
516
- expect(r).toBeNull()
517
- })
518
-
519
- test('remove: 삭제될 컴포넌트의 model 을 add inverse 로 보관', () => {
520
- const board = make([
521
- { refid: 1, type: 'rect', left: 10, top: 20, name: 'AGV-1' },
522
- { refid: 2, type: 'circle' }
523
- ])
524
- const r = computeInverseOp(board, { op: 'remove', refid: 1 })
525
- expect(r).toEqual({
526
- op: 'add',
527
- component: { refid: 1, type: 'rect', left: 10, top: 20, name: 'AGV-1' }
528
- })
529
- })
530
-
531
- test('remove: 매칭 실패 → null (silent no-op 의 inverse 도 없음)', () => {
532
- const board = make([{ refid: 1, type: 'rect' }])
533
- const r = computeInverseOp(board, { op: 'remove', refid: 999 })
534
- expect(r).toBeNull()
535
- })
536
-
537
- test('remove: 깊은 클론 — 원본 mutate 시에도 inverse 보존', () => {
538
- const orig = { refid: 1, type: 'rect', threeD: { material: { color: '#ff0000' } } }
539
- const board = make([orig as any])
540
- const r: any = computeInverseOp(board, { op: 'remove', refid: 1 })
541
- orig.threeD.material.color = '#00ff00'
542
- expect(r.component.threeD.material.color).toBe('#ff0000')
543
- })
544
-
545
- test('modify: patch 가 건드린 키만 원본 값 보관 (전체 컴포넌트 X)', () => {
546
- const board = make([
547
- { refid: 1, type: 'rect', left: 10, top: 20, fillStyle: '#fff', height: 100 }
548
- ])
549
- const r = computeInverseOp(board, {
550
- op: 'modify',
551
- refid: 1,
552
- patch: { left: 999, fillStyle: '#000' } as any
553
- })
554
- expect(r).toEqual({
555
- op: 'modify',
556
- refid: 1,
557
- patch: { left: 10, fillStyle: '#fff' }
558
- })
559
- })
560
-
561
- test('modify: nested 값은 통째로 deep-clone 으로 보관 (threeD.material 등)', () => {
562
- const board = make([
563
- {
564
- refid: 1,
565
- type: 'rect',
566
- threeD: { enabled: true, material: { color: '#ff0000', metalness: 0.5 } }
567
- }
568
- ])
569
- const r: any = computeInverseOp(board, {
570
- op: 'modify',
571
- refid: 1,
572
- patch: { threeD: { material: { color: '#00ff00' } } } as any
573
- })
574
- expect(r.patch.threeD).toEqual({
575
- enabled: true,
576
- material: { color: '#ff0000', metalness: 0.5 }
577
- })
578
- })
579
-
580
- test('modify: 원본에 없던 키는 null 보관 (revert 시 키 제거 시맨틱)', () => {
581
- const board = make([{ refid: 1, type: 'rect' } as any])
582
- const r: any = computeInverseOp(board, {
583
- op: 'modify',
584
- refid: 1,
585
- patch: { fillStyle: '#000' } as any
586
- })
587
- // null 인 inverse 적용 시 mergeComponent 가 키를 제거 → 원본 (키 없음) 복원
588
- expect(r.patch.fillStyle).toBeNull()
589
- })
590
-
591
- test('modify: 매칭 실패 → null', () => {
592
- const board = make([{ refid: 1, type: 'rect' }])
593
- const r = computeInverseOp(board, {
594
- op: 'modify',
595
- refid: 999,
596
- patch: { left: 100 } as any
597
- })
598
- expect(r).toBeNull()
599
- })
600
-
601
- test('modifyBoard: patch 가 건드린 root 키만 원본 보관', () => {
602
- const board: any = {
603
- width: 1920,
604
- height: 1080,
605
- fillStyle: '#fff',
606
- name: 'Floor',
607
- components: []
608
- }
609
- const r = computeInverseOp(board, {
610
- op: 'modifyBoard',
611
- patch: { fillStyle: '#0a1929', exposure: 0.7 } as any
612
- })
613
- expect(r).toEqual({
614
- op: 'modifyBoard',
615
- patch: { fillStyle: '#fff', exposure: null } // exposure 원본에 없음 → null
616
- })
617
- })
618
-
619
- test("modifyBoard: patch 의 'components' 키는 무시 (자식은 별도 op)", () => {
620
- const board = make([{ refid: 1, type: 'rect' }], 1920, 1080)
621
- const r: any = computeInverseOp(board, {
622
- op: 'modifyBoard',
623
- patch: { fillStyle: '#000', components: [] } as any
624
- })
625
- expect(Object.keys(r.patch).sort()).toEqual(['fillStyle'])
626
- })
627
-
628
- test('replace: 이전 보드 통째로 deep-clone 해서 inverse replace', () => {
629
- const board = make([{ refid: 1, type: 'rect' }], 1000, 600)
630
- const r: any = computeInverseOp(board, {
631
- op: 'replace',
632
- board: { width: 800, height: 600, components: [] } as any
633
- })
634
- expect(r.op).toBe('replace')
635
- expect(r.board).toEqual(board)
636
- ;(board.components as any).push({ refid: 999, type: 'circle' })
637
- expect(r.board.components).toHaveLength(1)
638
- })
639
-
640
- test('undefined board → null (방어)', () => {
641
- expect(computeInverseOp(undefined, { op: 'remove', refid: 1 })).toBeNull()
642
- })
643
-
644
- test('round-trip: applyOp 후 inverse 적용 → 원본 복원 (modify)', () => {
645
- const board = make([{ refid: 1, type: 'rect', left: 10, top: 20 }])
646
- const op: BoardEditOp = { op: 'modify', refid: 1, patch: { left: 100 } as any }
647
- const inverse = computeInverseOp(board, op)!
648
- const after = applyOp(board, op)
649
- const restored = applyOp(after, inverse)
650
- expect((restored.components[0] as any).left).toBe(10)
651
- expect((restored.components[0] as any).top).toBe(20)
652
- })
653
-
654
- test('round-trip: applyOp 후 inverse 적용 → 원본 복원 (remove)', () => {
655
- const board = make([
656
- { refid: 1, type: 'rect', name: 'A' },
657
- { refid: 2, type: 'circle' }
658
- ])
659
- const op: BoardEditOp = { op: 'remove', refid: 1 }
660
- const inverse = computeInverseOp(board, op)!
661
- const after = applyOp(board, op)
662
- expect(after.components).toHaveLength(1)
663
- const restored = applyOp(after, inverse)
664
- expect(restored.components).toHaveLength(2)
665
- expect(restored.components.map(c => (c as any).refid).sort()).toEqual([1, 2])
666
- })
667
-
668
- test('round-trip: modifyBoard 의 inverse 로 원본 fillStyle 복원', () => {
669
- const board: any = { width: 1000, height: 600, fillStyle: '#fff', components: [] }
670
- const op: BoardEditOp = { op: 'modifyBoard', patch: { fillStyle: '#0a1929' } as any }
671
- const inverse = computeInverseOp(board, op)!
672
- const after: any = applyOp(board, op)
673
- expect(after.fillStyle).toBe('#0a1929')
674
- const restored: any = applyOp(after, inverse)
675
- expect(restored.fillStyle).toBe('#fff')
676
- })
677
- })
678
-
679
- describe('Phase 2 — Scene 조작 op 의 model 차원 정책', () => {
680
- // model 차원 시뮬레이션은 noop (things-scene API 가 정본). verbose 는 무조건 applied 분류.
681
-
682
- test('align: applyOp 는 board 그대로 반환 (noop)', () => {
683
- const board = make([
684
- { refid: 1, type: 'rect', left: 10, top: 20 },
685
- { refid: 2, type: 'rect', left: 50, top: 80 }
686
- ])
687
- const r = applyOp(board, { op: 'align', refids: [1, 2], direction: 'left' })
688
- expect(r).toBe(board) // reference 동일
689
- })
690
-
691
- test('distribute: noop', () => {
692
- const board = make([{ refid: 1, type: 'rect' }])
693
- const r = applyOp(board, { op: 'distribute', refids: [1, 2, 3], axis: 'horizontal' })
694
- expect(r).toBe(board)
695
- })
696
-
697
- test('group / ungroup / zorder / arrange: noop', () => {
698
- const board = make([{ refid: 1, type: 'rect' }])
699
- expect(applyOp(board, { op: 'group', refids: [1, 2] })).toBe(board)
700
- expect(applyOp(board, { op: 'ungroup', refid: 5 })).toBe(board)
701
- expect(applyOp(board, { op: 'zorder', refid: 1, direction: 'front' })).toBe(board)
702
- expect(
703
- applyOp(board, {
704
- op: 'arrange',
705
- refids: [1, 2],
706
- layout: { type: 'grid', cols: 2 }
707
- })
708
- ).toBe(board)
709
- })
710
-
711
- test('verbose 분류 — scene-only op 는 모두 applied (missed 아님)', () => {
712
- const board = make([{ refid: 1, type: 'rect' }])
713
- const r = applyBoardEditPatchVerbose(board, {
714
- ops: [
715
- { op: 'align', refids: [1], direction: 'left' },
716
- { op: 'distribute', refids: [1, 2], axis: 'horizontal' },
717
- { op: 'group', refids: [1, 2] },
718
- { op: 'ungroup', refid: 1 },
719
- { op: 'zorder', refid: 1, direction: 'front' },
720
- { op: 'arrange', refids: [1, 2], layout: { type: 'row' } }
721
- ],
722
- summary: '',
723
- confidence: 1
724
- })
725
- expect(r.applied).toHaveLength(6)
726
- expect(r.missed).toHaveLength(0)
727
- })
728
-
729
- test('computeInverseOp — zorder 는 자동 반전 (forward↔backward, front↔back)', () => {
730
- const board = make([{ refid: 1, type: 'rect' }])
731
- expect(
732
- computeInverseOp(board, { op: 'zorder', refid: 1, direction: 'forward' })
733
- ).toEqual({ op: 'zorder', refid: 1, direction: 'backward' })
734
- expect(
735
- computeInverseOp(board, { op: 'zorder', refid: 1, direction: 'front' })
736
- ).toEqual({ op: 'zorder', refid: 1, direction: 'back' })
737
- })
738
-
739
- test('computeInverseOp — align/distribute/group/ungroup/arrange 는 null (호스트 책임)', () => {
740
- const board = make([{ refid: 1, type: 'rect' }])
741
- expect(
742
- computeInverseOp(board, { op: 'align', refids: [1, 2], direction: 'left' })
743
- ).toBeNull()
744
- expect(
745
- computeInverseOp(board, { op: 'distribute', refids: [1, 2], axis: 'horizontal' })
746
- ).toBeNull()
747
- expect(computeInverseOp(board, { op: 'group', refids: [1, 2] })).toBeNull()
748
- expect(computeInverseOp(board, { op: 'ungroup', refid: 1 })).toBeNull()
749
- expect(
750
- computeInverseOp(board, {
751
- op: 'arrange',
752
- refids: [1, 2],
753
- layout: { type: 'grid', cols: 2 }
754
- })
755
- ).toBeNull()
756
- })
757
- })