@things-factory/board-ai 10.0.0-beta.64

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 (95) hide show
  1. package/client/components/board-ai-chat.test.ts +120 -0
  2. package/client/components/board-ai-chat.ts +1502 -0
  3. package/client/components/chat-input-builder.ts +40 -0
  4. package/client/components/markdown.test.ts +220 -0
  5. package/client/components/markdown.ts +184 -0
  6. package/client/index.ts +11 -0
  7. package/client/tsconfig.json +13 -0
  8. package/client/utils/board-edit-patch.ts +200 -0
  9. package/config/config.development.js +43 -0
  10. package/config/config.production.js +15 -0
  11. package/dist-client/components/board-ai-chat.d.ts +127 -0
  12. package/dist-client/components/board-ai-chat.js +1455 -0
  13. package/dist-client/components/board-ai-chat.js.map +1 -0
  14. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  15. package/dist-client/components/board-ai-chat.test.js +112 -0
  16. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  17. package/dist-client/components/chat-input-builder.d.ts +30 -0
  18. package/dist-client/components/chat-input-builder.js +25 -0
  19. package/dist-client/components/chat-input-builder.js.map +1 -0
  20. package/dist-client/components/markdown.d.ts +16 -0
  21. package/dist-client/components/markdown.js +167 -0
  22. package/dist-client/components/markdown.js.map +1 -0
  23. package/dist-client/components/markdown.test.d.ts +1 -0
  24. package/dist-client/components/markdown.test.js +187 -0
  25. package/dist-client/components/markdown.test.js.map +1 -0
  26. package/dist-client/index.d.ts +11 -0
  27. package/dist-client/index.js +12 -0
  28. package/dist-client/index.js.map +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -0
  30. package/dist-client/utils/board-edit-patch.d.ts +73 -0
  31. package/dist-client/utils/board-edit-patch.js +159 -0
  32. package/dist-client/utils/board-edit-patch.js.map +1 -0
  33. package/dist-server/index.d.ts +21 -0
  34. package/dist-server/index.js +25 -0
  35. package/dist-server/index.js.map +1 -0
  36. package/dist-server/service/apply-patch.d.ts +46 -0
  37. package/dist-server/service/apply-patch.js +211 -0
  38. package/dist-server/service/apply-patch.js.map +1 -0
  39. package/dist-server/service/assistant.d.ts +75 -0
  40. package/dist-server/service/assistant.js +1298 -0
  41. package/dist-server/service/assistant.js.map +1 -0
  42. package/dist-server/service/board-ai-resolver.d.ts +40 -0
  43. package/dist-server/service/board-ai-resolver.js +260 -0
  44. package/dist-server/service/board-ai-resolver.js.map +1 -0
  45. package/dist-server/service/chat-message/chat-message.d.ts +24 -0
  46. package/dist-server/service/chat-message/chat-message.js +108 -0
  47. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  48. package/dist-server/service/chat-message/index.d.ts +3 -0
  49. package/dist-server/service/chat-message/index.js +7 -0
  50. package/dist-server/service/chat-message/index.js.map +1 -0
  51. package/dist-server/service/chat-session/chat-session.d.ts +22 -0
  52. package/dist-server/service/chat-session/chat-session.js +109 -0
  53. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  54. package/dist-server/service/chat-session/index.d.ts +3 -0
  55. package/dist-server/service/chat-session/index.js +7 -0
  56. package/dist-server/service/chat-session/index.js.map +1 -0
  57. package/dist-server/service/chat-session-resolver.d.ts +13 -0
  58. package/dist-server/service/chat-session-resolver.js +178 -0
  59. package/dist-server/service/chat-session-resolver.js.map +1 -0
  60. package/dist-server/service/index.d.ts +14 -0
  61. package/dist-server/service/index.js +26 -0
  62. package/dist-server/service/index.js.map +1 -0
  63. package/dist-server/service/patch-entry/index.d.ts +3 -0
  64. package/dist-server/service/patch-entry/index.js +7 -0
  65. package/dist-server/service/patch-entry/index.js.map +1 -0
  66. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  67. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  68. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  69. package/dist-server/service/types.d.ts +137 -0
  70. package/dist-server/service/types.js +3 -0
  71. package/dist-server/service/types.js.map +1 -0
  72. package/dist-server/tsconfig.tsbuildinfo +1 -0
  73. package/package.json +47 -0
  74. package/server/index.ts +21 -0
  75. package/server/service/apply-patch.test.ts +640 -0
  76. package/server/service/apply-patch.ts +250 -0
  77. package/server/service/assistant.test.ts +1317 -0
  78. package/server/service/assistant.ts +1431 -0
  79. package/server/service/board-ai-resolver.ts +239 -0
  80. package/server/service/chat-message/chat-message.ts +110 -0
  81. package/server/service/chat-message/index.ts +5 -0
  82. package/server/service/chat-session/chat-session.ts +103 -0
  83. package/server/service/chat-session/index.ts +5 -0
  84. package/server/service/chat-session-resolver.ts +154 -0
  85. package/server/service/index.ts +24 -0
  86. package/server/service/patch-entry/index.ts +5 -0
  87. package/server/service/patch-entry/patch-entry.ts +89 -0
  88. package/server/service/types.ts +138 -0
  89. package/things-factory.config.js +1 -0
  90. package/translations/en.json +39 -0
  91. package/translations/ja.json +39 -0
  92. package/translations/ko.json +40 -0
  93. package/translations/ms.json +39 -0
  94. package/translations/zh.json +39 -0
  95. package/tsconfig.json +9 -0
@@ -0,0 +1,640 @@
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('null 로 명시적 reset', () => {
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
+ expect((r.components[0] as any).threeD).toBeNull()
155
+ })
156
+
157
+ test('다른 컴포넌트는 영향 없음', () => {
158
+ const board = make([
159
+ { refid: 1, type: 'rect', left: 10 },
160
+ { refid: 2, type: 'circle', cx: 50 }
161
+ ])
162
+ const r = applyOp(board, { op: 'modify', refid: 1, patch: { left: 100 } as any })
163
+ expect((r.components[1] as any).cx).toBe(50)
164
+ })
165
+
166
+ test('입력을 mutate 하지 않음', () => {
167
+ const board = make([
168
+ { refid: 1, type: 'rect', threeD: { material: { color: '#ff0000' } } }
169
+ ])
170
+ const before = JSON.stringify(board)
171
+ applyOp(board, {
172
+ op: 'modify',
173
+ refid: 1,
174
+ patch: { threeD: { material: { color: '#00ff00' } } } as any
175
+ })
176
+ expect(JSON.stringify(board)).toBe(before)
177
+ })
178
+ })
179
+
180
+ describe('applyOp — modifyBoard (root 속성 변경)', () => {
181
+ test('fillStyle 단일 변경 — children 영향 없음', () => {
182
+ const board = make([{ refid: 1, type: 'rect', fillStyle: '#fff' }])
183
+ const r = applyOp(board, { op: 'modifyBoard', patch: { fillStyle: '#0a1929' } as any })
184
+ expect((r as any).fillStyle).toBe('#0a1929')
185
+ // children 그대로
186
+ expect(r.components).toHaveLength(1)
187
+ expect((r.components[0] as any).fillStyle).toBe('#fff')
188
+ })
189
+
190
+ test('width / height 변경', () => {
191
+ const board = make([], 1000, 600)
192
+ const r = applyOp(board, { op: 'modifyBoard', patch: { width: 1920, height: 1080 } as any })
193
+ expect(r.width).toBe(1920)
194
+ expect(r.height).toBe(1080)
195
+ })
196
+
197
+ test('name 등 임의 root 속성', () => {
198
+ const board = make([])
199
+ const r: any = applyOp(board, {
200
+ op: 'modifyBoard',
201
+ patch: { name: 'Production Floor', description: 'desc' } as any
202
+ })
203
+ expect(r.name).toBe('Production Floor')
204
+ expect(r.description).toBe('desc')
205
+ })
206
+
207
+ test('patch 의 components 키는 무시 — children 변경은 별도 op', () => {
208
+ const board = make([{ refid: 1, type: 'rect' }])
209
+ const r = applyOp(board, {
210
+ op: 'modifyBoard',
211
+ patch: { fillStyle: '#000', components: [] } as any
212
+ })
213
+ expect((r as any).fillStyle).toBe('#000')
214
+ expect(r.components).toHaveLength(1) // children 보존
215
+ })
216
+
217
+ test('nested object deep merge (예: 향후 root level theme 객체)', () => {
218
+ const board: any = { ...make([]), theme: { primary: '#fff', secondary: '#aaa' } }
219
+ const r: any = applyOp(board, {
220
+ op: 'modifyBoard',
221
+ patch: { theme: { primary: '#0a1929' } } as any
222
+ })
223
+ expect(r.theme.primary).toBe('#0a1929')
224
+ expect(r.theme.secondary).toBe('#aaa') // 보존
225
+ })
226
+
227
+ test('입력을 mutate 하지 않음', () => {
228
+ const board = make([{ refid: 1, type: 'rect' }])
229
+ const before = JSON.stringify(board)
230
+ applyOp(board, { op: 'modifyBoard', patch: { fillStyle: '#000' } as any })
231
+ expect(JSON.stringify(board)).toBe(before)
232
+ })
233
+ })
234
+
235
+ describe('applyOp — replace', () => {
236
+ test('전체 보드 교체', () => {
237
+ const board = make([{ refid: 1, type: 'rect' }], 1000, 600)
238
+ const newBoard: any = { width: 1920, height: 1080, components: [{ refid: 9, type: 'circle' }] }
239
+ const r = applyOp(board, { op: 'replace', board: newBoard })
240
+ expect(r).toBe(newBoard)
241
+ })
242
+ })
243
+
244
+ describe('applyBoardEditPatch — 다중 op', () => {
245
+ test('여러 op 가 순차 적용', () => {
246
+ const board = make([{ refid: 1, type: 'rect' }])
247
+ const patch: BoardEditPatch = {
248
+ ops: [
249
+ { op: 'add', component: { refid: 2, type: 'circle' } as any },
250
+ { op: 'modify', refid: 1, patch: { left: 100 } as any },
251
+ { op: 'remove', refid: 1 }
252
+ ],
253
+ summary: '',
254
+ confidence: 0.9
255
+ }
256
+ const r = applyBoardEditPatch(board, patch)
257
+ expect(r.components).toHaveLength(1)
258
+ expect((r.components[0] as any).refid).toBe(2)
259
+ })
260
+
261
+ test('빈 ops 는 보드 그대로', () => {
262
+ const board = make([{ refid: 1, type: 'rect' }])
263
+ const r = applyBoardEditPatch(board, { ops: [], summary: '', confidence: 0 })
264
+ expect(r).toBe(board)
265
+ })
266
+
267
+ test('undefined 보드 → 기본 빈 보드 사용', () => {
268
+ const r = applyBoardEditPatch(undefined, {
269
+ ops: [{ op: 'add', component: { refid: 1, type: 'rect' } as any }],
270
+ summary: '',
271
+ confidence: 1
272
+ })
273
+ expect(r.components).toHaveLength(1)
274
+ expect(r.width).toBeGreaterThan(0)
275
+ expect(r.height).toBeGreaterThan(0)
276
+ })
277
+
278
+ test('replace + 후속 op 조합', () => {
279
+ const board = make([{ refid: 1, type: 'rect' }])
280
+ const newBase: any = { width: 800, height: 480, components: [{ refid: 9, type: 'rect' }] }
281
+ const patch: BoardEditPatch = {
282
+ ops: [
283
+ { op: 'replace', board: newBase },
284
+ { op: 'add', component: { refid: 10, type: 'circle' } as any }
285
+ ],
286
+ summary: '',
287
+ confidence: 1
288
+ }
289
+ const r = applyBoardEditPatch(board, patch)
290
+ expect(r.width).toBe(800)
291
+ expect(r.components.map((c: any) => c.refid)).toEqual([9, 10])
292
+ })
293
+
294
+ test('알 수 없는 op 는 무시 (default case)', () => {
295
+ const board = make([{ refid: 1, type: 'rect' }])
296
+ const patch: any = {
297
+ ops: [{ op: 'unknown', whatever: 1 }],
298
+ summary: '',
299
+ confidence: 0
300
+ }
301
+ const r = applyBoardEditPatch(board, patch)
302
+ expect(r.components).toHaveLength(1)
303
+ })
304
+ })
305
+
306
+ describe('applyBoardEditPatchVerbose — silent no-op 검출 (회귀 방지)', () => {
307
+ // 핵심: AI 가 잘못된/존재하지 않는 refid 로 modify/remove 를 시도하면 applyOp 는
308
+ // silent no-op 으로 끝난다. Verbose 변형이 이를 missed[] 로 보고하지 않으면
309
+ // 호스트가 "AI 가 수정했다는데 보드는 그대로" 상황을 사용자에게 알릴 수 없다.
310
+ test('존재하지 않는 refid 로 modify → missed', () => {
311
+ const board = make([{ refid: 1, type: 'rect' }])
312
+ const r = applyBoardEditPatchVerbose(board, {
313
+ ops: [{ op: 'modify', refid: 999, patch: { left: 100 } as any }],
314
+ summary: '',
315
+ confidence: 1
316
+ })
317
+ expect(r.applied).toHaveLength(0)
318
+ expect(r.missed).toHaveLength(1)
319
+ expect(r.board).toBe(board)
320
+ })
321
+
322
+ test('존재하지 않는 refid 로 remove → missed', () => {
323
+ const board = make([{ refid: 1, type: 'rect' }])
324
+ const r = applyBoardEditPatchVerbose(board, {
325
+ ops: [{ op: 'remove', refid: 999 }],
326
+ summary: '',
327
+ confidence: 1
328
+ })
329
+ expect(r.applied).toHaveLength(0)
330
+ expect(r.missed).toHaveLength(1)
331
+ expect(r.board).toBe(board)
332
+ })
333
+
334
+ test('정상 modify → applied', () => {
335
+ const board = make([{ refid: 1, type: 'rect', left: 0 }])
336
+ const r = applyBoardEditPatchVerbose(board, {
337
+ ops: [{ op: 'modify', refid: 1, patch: { left: 100 } as any }],
338
+ summary: '',
339
+ confidence: 1
340
+ })
341
+ expect(r.applied).toHaveLength(1)
342
+ expect(r.missed).toHaveLength(0)
343
+ expect((r.board.components[0] as any).left).toBe(100)
344
+ })
345
+
346
+ test('add 는 항상 applied (refid 의존 안 함 — 추가될 컴포넌트의 refid 는 scene 이 부여)', () => {
347
+ const board = make()
348
+ const r = applyBoardEditPatchVerbose(board, {
349
+ ops: [{ op: 'add', component: { refid: 9, type: 'rect' } as any }],
350
+ summary: '',
351
+ confidence: 1
352
+ })
353
+ expect(r.applied).toHaveLength(1)
354
+ expect(r.missed).toHaveLength(0)
355
+ })
356
+
357
+ test('replace 는 항상 applied', () => {
358
+ const board = make([{ refid: 1, type: 'rect' }])
359
+ const r = applyBoardEditPatchVerbose(board, {
360
+ ops: [
361
+ { op: 'replace', board: { width: 800, height: 600, components: [] } as any }
362
+ ],
363
+ summary: '',
364
+ confidence: 1
365
+ })
366
+ expect(r.applied).toHaveLength(1)
367
+ expect(r.missed).toHaveLength(0)
368
+ })
369
+
370
+ test('mixed: 일부 applied + 일부 missed', () => {
371
+ const board = make([{ refid: 1, type: 'rect' }])
372
+ const r = applyBoardEditPatchVerbose(board, {
373
+ ops: [
374
+ { op: 'modify', refid: 1, patch: { left: 1 } as any },
375
+ { op: 'modify', refid: 998, patch: { left: 2 } as any },
376
+ { op: 'remove', refid: 999 },
377
+ { op: 'add', component: { refid: 2, type: 'circle' } as any }
378
+ ],
379
+ summary: '',
380
+ confidence: 1
381
+ })
382
+ expect(r.applied).toHaveLength(2) // modify refid 1 + add
383
+ expect(r.missed).toHaveLength(2) // modify ghost + remove phantom
384
+ expect(r.board.components.map((c: any) => c.refid)).toEqual([1, 2])
385
+ })
386
+
387
+ test('빈 ops — applied/missed 모두 빈 배열', () => {
388
+ const board = make([{ refid: 1, type: 'rect' }])
389
+ const r = applyBoardEditPatchVerbose(board, { ops: [], summary: '', confidence: 1 })
390
+ expect(r.applied).toEqual([])
391
+ expect(r.missed).toEqual([])
392
+ expect(r.board).toBe(board)
393
+ })
394
+
395
+ test('applyBoardEditPatch (non-verbose) 는 board 만 반환 — 기존 호출자 호환', () => {
396
+ const board = make([{ refid: 1, type: 'rect', left: 0 }])
397
+ const r = applyBoardEditPatch(board, {
398
+ ops: [{ op: 'modify', refid: 1, patch: { left: 50 } as any }],
399
+ summary: '',
400
+ confidence: 1
401
+ })
402
+ expect((r.components[0] as any).left).toBe(50)
403
+ })
404
+ })
405
+
406
+ describe('mergeComponent — host 측 in-place 적용에 사용되는 deep merge', () => {
407
+ test('top-level 속성 추가/변경', () => {
408
+ const r = mergeComponent({ refid: 1, type: 'rect', left: 10 } as any, { left: 20, top: 30 } as any)
409
+ expect(r).toEqual({ refid: 1, type: 'rect', left: 20, top: 30 })
410
+ })
411
+
412
+ test('nested object deep merge (threeD)', () => {
413
+ const base = {
414
+ refid: 1,
415
+ type: 'rect',
416
+ threeD: {
417
+ enabled: true,
418
+ material: { color: '#ff0000', metalness: 0.5 },
419
+ geometry: { type: 'box', size: { x: 100 } }
420
+ }
421
+ } as any
422
+ const patch = {
423
+ threeD: { material: { color: '#00ff00' } }
424
+ } as any
425
+ const r: any = mergeComponent(base, patch)
426
+ expect(r.threeD.material.color).toBe('#00ff00')
427
+ expect(r.threeD.material.metalness).toBe(0.5) // 보존
428
+ expect(r.threeD.geometry).toEqual({ type: 'box', size: { x: 100 } }) // 보존
429
+ expect(r.threeD.enabled).toBe(true) // 보존
430
+ })
431
+
432
+ test('배열은 deep merge 안 함 (전체 교체)', () => {
433
+ const r: any = mergeComponent({ refid: 1, type: 'polyline', points: [[0, 0], [10, 10]] } as any, {
434
+ points: [[5, 5]]
435
+ } as any)
436
+ expect(r.points).toEqual([[5, 5]])
437
+ })
438
+
439
+ test('null 명시는 그대로 null 로 set (reset)', () => {
440
+ const r: any = mergeComponent({ refid: 1, type: 'rect', threeD: { enabled: true } } as any, {
441
+ threeD: null
442
+ } as any)
443
+ expect(r.threeD).toBeNull()
444
+ })
445
+
446
+ test('base 를 mutate 하지 않음', () => {
447
+ const base = { refid: 1, type: 'rect', threeD: { material: { color: '#f00' } } } as any
448
+ const before = JSON.stringify(base)
449
+ mergeComponent(base, { threeD: { material: { color: '#0f0' } } } as any)
450
+ expect(JSON.stringify(base)).toBe(before)
451
+ })
452
+ })
453
+
454
+ describe('applyBoardEditPatch — 입력 보존', () => {
455
+ test('패치 적용 후 원본 board 객체는 변경 없음', () => {
456
+ const board = make([{ refid: 1, type: 'rect', left: 10 }])
457
+ const before = JSON.stringify(board)
458
+ applyBoardEditPatch(board, {
459
+ ops: [
460
+ { op: 'add', component: { refid: 2, type: 'circle' } as any },
461
+ { op: 'modify', refid: 1, patch: { left: 999 } as any }
462
+ ],
463
+ summary: '',
464
+ confidence: 1
465
+ })
466
+ expect(JSON.stringify(board)).toBe(before)
467
+ })
468
+ })
469
+
470
+ describe('computeInverseOp — Revert 의 역연산 계산', () => {
471
+ // 핵심 보장: patch 적용 직전 board 상태에서 op 의 inverse 를 만들 수 있어야 한다.
472
+ // 호스트가 patch 시점에 누적해두면 나중에 역순 in-place 적용으로 복원 가능.
473
+
474
+ test('add: 모델 단계에서 inverse 계산 불가 → null (호스트가 scene refid 캡처 후 직접 만듦)', () => {
475
+ const board = make([])
476
+ const r = computeInverseOp(board, {
477
+ op: 'add',
478
+ component: { refid: 9, type: 'rect' } as any
479
+ })
480
+ expect(r).toBeNull()
481
+ })
482
+
483
+ test('remove: 삭제될 컴포넌트의 model 을 add inverse 로 보관', () => {
484
+ const board = make([
485
+ { refid: 1, type: 'rect', left: 10, top: 20, name: 'AGV-1' },
486
+ { refid: 2, type: 'circle' }
487
+ ])
488
+ const r = computeInverseOp(board, { op: 'remove', refid: 1 })
489
+ expect(r).toEqual({
490
+ op: 'add',
491
+ component: { refid: 1, type: 'rect', left: 10, top: 20, name: 'AGV-1' }
492
+ })
493
+ })
494
+
495
+ test('remove: 매칭 실패 → null (silent no-op 의 inverse 도 없음)', () => {
496
+ const board = make([{ refid: 1, type: 'rect' }])
497
+ const r = computeInverseOp(board, { op: 'remove', refid: 999 })
498
+ expect(r).toBeNull()
499
+ })
500
+
501
+ test('remove: 깊은 클론 — 원본 mutate 시에도 inverse 보존', () => {
502
+ const orig = { refid: 1, type: 'rect', threeD: { material: { color: '#ff0000' } } }
503
+ const board = make([orig as any])
504
+ const r: any = computeInverseOp(board, { op: 'remove', refid: 1 })
505
+ orig.threeD.material.color = '#00ff00'
506
+ expect(r.component.threeD.material.color).toBe('#ff0000')
507
+ })
508
+
509
+ test('modify: patch 가 건드린 키만 원본 값 보관 (전체 컴포넌트 X)', () => {
510
+ const board = make([
511
+ { refid: 1, type: 'rect', left: 10, top: 20, fillStyle: '#fff', height: 100 }
512
+ ])
513
+ const r = computeInverseOp(board, {
514
+ op: 'modify',
515
+ refid: 1,
516
+ patch: { left: 999, fillStyle: '#000' } as any
517
+ })
518
+ expect(r).toEqual({
519
+ op: 'modify',
520
+ refid: 1,
521
+ patch: { left: 10, fillStyle: '#fff' }
522
+ })
523
+ })
524
+
525
+ test('modify: nested 값은 통째로 deep-clone 으로 보관 (threeD.material 등)', () => {
526
+ const board = make([
527
+ {
528
+ refid: 1,
529
+ type: 'rect',
530
+ threeD: { enabled: true, material: { color: '#ff0000', metalness: 0.5 } }
531
+ }
532
+ ])
533
+ const r: any = computeInverseOp(board, {
534
+ op: 'modify',
535
+ refid: 1,
536
+ patch: { threeD: { material: { color: '#00ff00' } } } as any
537
+ })
538
+ expect(r.patch.threeD).toEqual({
539
+ enabled: true,
540
+ material: { color: '#ff0000', metalness: 0.5 }
541
+ })
542
+ })
543
+
544
+ test('modify: 원본에 없던 키는 null 보관 (revert 시 명시적 null reset)', () => {
545
+ const board = make([{ refid: 1, type: 'rect' } as any])
546
+ const r: any = computeInverseOp(board, {
547
+ op: 'modify',
548
+ refid: 1,
549
+ patch: { fillStyle: '#000' } as any
550
+ })
551
+ expect(r.patch.fillStyle).toBeNull()
552
+ })
553
+
554
+ test('modify: 매칭 실패 → null', () => {
555
+ const board = make([{ refid: 1, type: 'rect' }])
556
+ const r = computeInverseOp(board, {
557
+ op: 'modify',
558
+ refid: 999,
559
+ patch: { left: 100 } as any
560
+ })
561
+ expect(r).toBeNull()
562
+ })
563
+
564
+ test('modifyBoard: patch 가 건드린 root 키만 원본 보관', () => {
565
+ const board: any = {
566
+ width: 1920,
567
+ height: 1080,
568
+ fillStyle: '#fff',
569
+ name: 'Floor',
570
+ components: []
571
+ }
572
+ const r = computeInverseOp(board, {
573
+ op: 'modifyBoard',
574
+ patch: { fillStyle: '#0a1929', exposure: 0.7 } as any
575
+ })
576
+ expect(r).toEqual({
577
+ op: 'modifyBoard',
578
+ patch: { fillStyle: '#fff', exposure: null } // exposure 원본에 없음 → null
579
+ })
580
+ })
581
+
582
+ test("modifyBoard: patch 의 'components' 키는 무시 (자식은 별도 op)", () => {
583
+ const board = make([{ refid: 1, type: 'rect' }], 1920, 1080)
584
+ const r: any = computeInverseOp(board, {
585
+ op: 'modifyBoard',
586
+ patch: { fillStyle: '#000', components: [] } as any
587
+ })
588
+ expect(Object.keys(r.patch).sort()).toEqual(['fillStyle'])
589
+ })
590
+
591
+ test('replace: 이전 보드 통째로 deep-clone 해서 inverse replace', () => {
592
+ const board = make([{ refid: 1, type: 'rect' }], 1000, 600)
593
+ const r: any = computeInverseOp(board, {
594
+ op: 'replace',
595
+ board: { width: 800, height: 600, components: [] } as any
596
+ })
597
+ expect(r.op).toBe('replace')
598
+ expect(r.board).toEqual(board)
599
+ ;(board.components as any).push({ refid: 999, type: 'circle' })
600
+ expect(r.board.components).toHaveLength(1)
601
+ })
602
+
603
+ test('undefined board → null (방어)', () => {
604
+ expect(computeInverseOp(undefined, { op: 'remove', refid: 1 })).toBeNull()
605
+ })
606
+
607
+ test('round-trip: applyOp 후 inverse 적용 → 원본 복원 (modify)', () => {
608
+ const board = make([{ refid: 1, type: 'rect', left: 10, top: 20 }])
609
+ const op: BoardEditOp = { op: 'modify', refid: 1, patch: { left: 100 } as any }
610
+ const inverse = computeInverseOp(board, op)!
611
+ const after = applyOp(board, op)
612
+ const restored = applyOp(after, inverse)
613
+ expect((restored.components[0] as any).left).toBe(10)
614
+ expect((restored.components[0] as any).top).toBe(20)
615
+ })
616
+
617
+ test('round-trip: applyOp 후 inverse 적용 → 원본 복원 (remove)', () => {
618
+ const board = make([
619
+ { refid: 1, type: 'rect', name: 'A' },
620
+ { refid: 2, type: 'circle' }
621
+ ])
622
+ const op: BoardEditOp = { op: 'remove', refid: 1 }
623
+ const inverse = computeInverseOp(board, op)!
624
+ const after = applyOp(board, op)
625
+ expect(after.components).toHaveLength(1)
626
+ const restored = applyOp(after, inverse)
627
+ expect(restored.components).toHaveLength(2)
628
+ expect(restored.components.map(c => (c as any).refid).sort()).toEqual([1, 2])
629
+ })
630
+
631
+ test('round-trip: modifyBoard 의 inverse 로 원본 fillStyle 복원', () => {
632
+ const board: any = { width: 1000, height: 600, fillStyle: '#fff', components: [] }
633
+ const op: BoardEditOp = { op: 'modifyBoard', patch: { fillStyle: '#0a1929' } as any }
634
+ const inverse = computeInverseOp(board, op)!
635
+ const after: any = applyOp(board, op)
636
+ expect(after.fillStyle).toBe('#0a1929')
637
+ const restored: any = applyOp(after, inverse)
638
+ expect(restored.fillStyle).toBe('#fff')
639
+ })
640
+ })