@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,831 +0,0 @@
1
- /**
2
- * apply-patch drift 검증 — server (apply-patch.ts) 와 client (board-edit-patch.ts) 의
3
- * 의도적 중복본이 동기 상태인지 자동 검증.
4
- *
5
- * 두 파일은 webpack 빌드 분리 (package.json browser/main 필드) 때문에 분리되어
6
- * 있고, 진정한 단일화 (shared 디렉토리) 는 tsconfig + emit 경로 + browser 진입점
7
- * 변경이 필요한 큰 구조 변경. 그 때까지 부채 부담을 자동 검증으로 대체:
8
- *
9
- * - export 이름이 동일한지
10
- * - 같은 입력에 대해 같은 출력을 내는지 (round-trip 동등성)
11
- *
12
- * 한쪽만 변경하면 즉시 깨짐 → drift 영구 차단.
13
- */
14
- import * as serverModule from './apply-patch'
15
- // 테스트 환경은 jest node — client 측 module 도 plain TS 라서 직접 import 가능
16
- // (Lit / @material/web 같은 ESM 의존 없음)
17
- /* 클라이언트 쪽 구현은 로 옮겼다(2026-09-10). 이 시험은 두 구현이
18
- * 어긋나는 것을 잡는 것이므로, 옮긴 자리를 그대로 가리킨다. */
19
- import * as clientModule from '../../../ai-assistant/client/utils/board-edit-patch'
20
-
21
- describe('apply-patch drift 자동 검증 — server / client 사본 동기 보장', () => {
22
- test('주요 함수 export 모두 양쪽에 존재', () => {
23
- const required = [
24
- 'applyBoardEditPatch',
25
- 'applyBoardEditPatchVerbose',
26
- 'computeInverseOp',
27
- 'mergeComponent'
28
- ]
29
- for (const name of required) {
30
- expect(typeof (serverModule as any)[name]).toBe('function')
31
- expect(typeof (clientModule as any)[name]).toBe('function')
32
- }
33
- })
34
-
35
- // 각 함수가 시그니처+동작 동일한지 — 같은 입력에 같은 출력.
36
-
37
- describe('applyBoardEditPatch — 동작 동등성', () => {
38
- test('add', () => {
39
- const board = { width: 1000, height: 600, components: [] }
40
- const patch: any = {
41
- ops: [{ op: 'add', component: { refid: 1, type: 'rect' } }],
42
- summary: '',
43
- confidence: 1
44
- }
45
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
46
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
47
- expect(r1).toEqual(r2)
48
- })
49
-
50
- test('remove', () => {
51
- const board = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
52
- const patch: any = { ops: [{ op: 'remove', refid: 1 }], summary: '', confidence: 1 }
53
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
54
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
55
- expect(r1).toEqual(r2)
56
- })
57
-
58
- test('modify (deep merge)', () => {
59
- const board = {
60
- width: 1000,
61
- height: 600,
62
- components: [
63
- {
64
- refid: 1,
65
- type: 'rect',
66
- threeD: { material: { color: '#ff0000', metalness: 0.5 } }
67
- }
68
- ]
69
- }
70
- const patch: any = {
71
- ops: [{ op: 'modify', refid: 1, patch: { threeD: { material: { color: '#00ff00' } } } }],
72
- summary: '',
73
- confidence: 1
74
- }
75
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
76
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
77
- expect(r1).toEqual(r2)
78
- })
79
-
80
- test('modifyBoard', () => {
81
- const board: any = {
82
- width: 1000,
83
- height: 600,
84
- fillStyle: '#fff',
85
- components: [{ refid: 1, type: 'rect' }]
86
- }
87
- const patch: any = {
88
- ops: [{ op: 'modifyBoard', patch: { fillStyle: '#0a1929' } }],
89
- summary: '',
90
- confidence: 1
91
- }
92
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
93
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
94
- expect(r1).toEqual(r2)
95
- })
96
-
97
- test('replace', () => {
98
- const board = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
99
- const patch: any = {
100
- ops: [
101
- { op: 'replace', board: { width: 800, height: 480, components: [] } }
102
- ],
103
- summary: '',
104
- confidence: 1
105
- }
106
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
107
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
108
- expect(r1).toEqual(r2)
109
- })
110
-
111
- test('arrange — scene-only noop (양쪽 board 동일 reference 보존)', () => {
112
- const board = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
113
- const patch: any = {
114
- ops: [
115
- { op: 'arrange', refids: [1, 2], layout: { type: 'grid', cols: 2 } }
116
- ],
117
- summary: '',
118
- confidence: 1
119
- }
120
- const r1 = serverModule.applyBoardEditPatch(board, patch)
121
- const r2 = clientModule.applyBoardEditPatch(board, patch)
122
- // verbose 동등 — applied/missed 분류
123
- const v1: any = serverModule.applyBoardEditPatchVerbose(board, patch)
124
- const v2: any = clientModule.applyBoardEditPatchVerbose(board, patch)
125
- expect(v1.applied.length).toBe(v2.applied.length)
126
- expect(v1.applied.length).toBe(1)
127
- expect(v1.missed.length).toBe(0)
128
- expect(r1).toEqual(r2)
129
- })
130
-
131
- test('modifyBoard 가 fillStyle 외 root 키 (sky 등) 만 → 양쪽 applied (회귀 방지)', () => {
132
- // server 측 componentsUnchanged 버그 fix 가 client 에도 sync 됐는지 검증.
133
- // client 만 안 고쳤으면 missed 분류돼서 board 갱신 안 됨.
134
- const board: any = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
135
- const patch: any = {
136
- ops: [{ op: 'modifyBoard', patch: { sky: 'color', exposure: 0.7 } }],
137
- summary: '',
138
- confidence: 1
139
- }
140
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
141
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
142
- expect((r1 as any).sky).toBe('color')
143
- expect((r2 as any).sky).toBe('color')
144
- expect(r1).toEqual(r2)
145
- })
146
-
147
- test('mixed multi-op', () => {
148
- const board = {
149
- width: 1000,
150
- height: 600,
151
- components: [
152
- { refid: 1, type: 'rect', left: 10 },
153
- { refid: 2, type: 'circle' }
154
- ]
155
- }
156
- const patch: any = {
157
- ops: [
158
- { op: 'add', component: { refid: 3, type: 'text' } },
159
- { op: 'modify', refid: 1, patch: { left: 100 } },
160
- { op: 'remove', refid: 2 }
161
- ],
162
- summary: '',
163
- confidence: 1
164
- }
165
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
166
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
167
- expect(r1).toEqual(r2)
168
- })
169
- })
170
-
171
- describe('applyBoardEditPatchVerbose — applied/missed 동일', () => {
172
- test('정상 + missed 혼합', () => {
173
- const board: any = {
174
- width: 1000,
175
- height: 600,
176
- components: [{ refid: 1, type: 'rect' }]
177
- }
178
- const patch: any = {
179
- ops: [
180
- { op: 'modify', refid: 1, patch: { left: 10 } },
181
- { op: 'modify', refid: 999, patch: { left: 99 } } // missed
182
- ],
183
- summary: '',
184
- confidence: 1
185
- }
186
- const r1: any = serverModule.applyBoardEditPatchVerbose(
187
- JSON.parse(JSON.stringify(board)),
188
- patch
189
- )
190
- const r2: any = clientModule.applyBoardEditPatchVerbose(
191
- JSON.parse(JSON.stringify(board)),
192
- patch
193
- )
194
- expect(r1.applied.length).toBe(r2.applied.length)
195
- expect(r1.missed.length).toBe(r2.missed.length)
196
- expect(r1.board).toEqual(r2.board)
197
- })
198
- })
199
-
200
- describe('computeInverseOp — 동일 inverse', () => {
201
- const cases: Array<{ name: string; board: any; op: any }> = [
202
- {
203
- name: 'remove',
204
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect', left: 10 }] },
205
- op: { op: 'remove', refid: 1 }
206
- },
207
- {
208
- name: 'modify',
209
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect', left: 10 }] },
210
- op: { op: 'modify', refid: 1, patch: { left: 999 } }
211
- },
212
- {
213
- name: 'modifyBoard',
214
- board: { width: 1000, height: 600, fillStyle: '#fff', components: [] },
215
- op: { op: 'modifyBoard', patch: { fillStyle: '#0a1929' } }
216
- },
217
- {
218
- name: 'replace',
219
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] },
220
- op: { op: 'replace', board: { width: 800, height: 480, components: [] } }
221
- },
222
- {
223
- name: 'add (양쪽 모두 null — 모델 단계 계산 불가)',
224
- board: { width: 1000, height: 600, components: [] },
225
- op: { op: 'add', component: { refid: 9, type: 'rect' } }
226
- },
227
- {
228
- name: 'remove 매칭 실패 → null',
229
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] },
230
- op: { op: 'remove', refid: 999 }
231
- },
232
- // Phase 2 — Scene 조작 op 의 inverse 동등성
233
- {
234
- name: 'zorder forward → 자동 backward',
235
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] },
236
- op: { op: 'zorder', refid: 1, direction: 'forward' }
237
- },
238
- {
239
- name: 'zorder front → 자동 back',
240
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] },
241
- op: { op: 'zorder', refid: 1, direction: 'front' }
242
- },
243
- {
244
- name: 'align → null (호스트 책임)',
245
- board: { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] },
246
- op: { op: 'align', refids: [1, 2], direction: 'left' }
247
- },
248
- {
249
- name: 'distribute → null',
250
- board: { width: 1000, height: 600, components: [] },
251
- op: { op: 'distribute', refids: [1, 2, 3], axis: 'horizontal' }
252
- },
253
- {
254
- name: 'group → null',
255
- board: { width: 1000, height: 600, components: [] },
256
- op: { op: 'group', refids: [1, 2] }
257
- },
258
- {
259
- name: 'ungroup → null',
260
- board: { width: 1000, height: 600, components: [] },
261
- op: { op: 'ungroup', refid: 1 }
262
- },
263
- // Sugar layout — arrange 도 host 책임 → 양쪽 null
264
- {
265
- name: 'arrange grid → null (호스트 책임)',
266
- board: { width: 1000, height: 600, components: [] },
267
- op: { op: 'arrange', refids: [1, 2], layout: { type: 'grid', cols: 2 } }
268
- },
269
- {
270
- name: 'arrange row → null',
271
- board: { width: 1000, height: 600, components: [] },
272
- op: { op: 'arrange', refids: [1, 2], layout: { type: 'row' } }
273
- }
274
- ]
275
- for (const c of cases) {
276
- test(c.name, () => {
277
- const r1 = serverModule.computeInverseOp(JSON.parse(JSON.stringify(c.board)), c.op)
278
- const r2 = clientModule.computeInverseOp(JSON.parse(JSON.stringify(c.board)), c.op)
279
- expect(r1).toEqual(r2)
280
- })
281
- }
282
- })
283
-
284
- describe('mergeComponent — deep merge 동등성', () => {
285
- test('top-level + nested + array 교체', () => {
286
- const base = {
287
- refid: 1,
288
- type: 'rect',
289
- left: 10,
290
- threeD: { material: { color: '#f00', metalness: 0.5 } },
291
- points: [
292
- [0, 0],
293
- [1, 1]
294
- ]
295
- }
296
- const patch = {
297
- left: 20,
298
- threeD: { material: { color: '#0f0' } },
299
- points: [[5, 5]]
300
- }
301
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
302
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
303
- expect(r1).toEqual(r2)
304
- })
305
-
306
- test('null = 키 제거 (서버 / 클라이언트 동일)', () => {
307
- const base = { refid: 1, type: 'rect', threeD: { enabled: true } }
308
- const patch = { threeD: null }
309
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
310
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
311
- expect(r1).toEqual(r2)
312
- expect('threeD' in r1).toBe(false)
313
- })
314
-
315
- test('nested null — deep merge 안에서도 키 제거 동일', () => {
316
- // threeD 는 유지하되 그 안의 material 만 제거
317
- const base = {
318
- refid: 1,
319
- type: 'rect',
320
- threeD: { enabled: true, material: { color: '#f00', metalness: 0.5 } }
321
- }
322
- const patch = { threeD: { material: null } }
323
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
324
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
325
- expect(r1).toEqual(r2)
326
- // material 만 제거, threeD.enabled 는 보존
327
- expect('material' in r1.threeD).toBe(false)
328
- expect(r1.threeD.enabled).toBe(true)
329
- })
330
-
331
- test('mode 전환 케이스 — fillStyle: object → string 통째 replace', () => {
332
- // 이번 세션의 실 발견 케이스 — gradient → solid 전환
333
- const base = {
334
- refid: 1,
335
- type: 'rect',
336
- fillStyle: { type: 'gradient', colorStops: [], rotation: 1.57 }
337
- }
338
- const patch = { fillStyle: '#ff0000' }
339
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
340
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
341
- expect(r1).toEqual(r2)
342
- expect(r1.fillStyle).toBe('#ff0000')
343
- })
344
-
345
- test('mode=none — fillStyle: null 으로 키 자체 제거', () => {
346
- const base = {
347
- refid: 1,
348
- type: 'rect',
349
- fillStyle: { type: 'pattern', image: 'x' }
350
- }
351
- const patch = { fillStyle: null }
352
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
353
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
354
- expect(r1).toEqual(r2)
355
- expect('fillStyle' in r1).toBe(false)
356
- })
357
-
358
- test('nested null + 다른 nested 변경 한 번에', () => {
359
- const base = {
360
- refid: 1,
361
- type: 'rect',
362
- threeD: {
363
- material: { color: '#f00', metalness: 0.5, roughness: 0.3 }
364
- }
365
- }
366
- const patch = {
367
- threeD: {
368
- material: { metalness: null, color: '#0f0' } // metalness 제거 + color 변경
369
- }
370
- }
371
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
372
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
373
- expect(r1).toEqual(r2)
374
- expect(r1.threeD.material).toEqual({ color: '#0f0', roughness: 0.3 })
375
- expect('metalness' in r1.threeD.material).toBe(false)
376
- })
377
-
378
- test('배열은 deep-merge 안 함 — 통째 replace 동일', () => {
379
- const base = {
380
- refid: 1,
381
- type: 'polyline',
382
- points: [[0, 0], [10, 10], [20, 20]]
383
- }
384
- const patch = { points: [[5, 5]] }
385
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
386
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
387
- expect(r1).toEqual(r2)
388
- expect(r1.points).toEqual([[5, 5]])
389
- })
390
-
391
- test('base 에 없던 키 추가 — 양쪽 동일', () => {
392
- const base = { refid: 1, type: 'rect' }
393
- const patch = { left: 100, top: 50 }
394
- const r1 = serverModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
395
- const r2 = clientModule.mergeComponent(JSON.parse(JSON.stringify(base)), patch)
396
- expect(r1).toEqual(r2)
397
- expect(r1).toEqual({ refid: 1, type: 'rect', left: 100, top: 50 })
398
- })
399
-
400
- test('input mutate 안 함 (양쪽 모두 pure)', () => {
401
- const base = { refid: 1, type: 'rect', threeD: { material: { color: '#f00' } } }
402
- const baseSnapshot = JSON.stringify(base)
403
- const patch = { threeD: { material: { color: '#0f0' } } }
404
- const patchSnapshot = JSON.stringify(patch)
405
-
406
- serverModule.mergeComponent(base, patch)
407
- expect(JSON.stringify(base)).toBe(baseSnapshot)
408
- expect(JSON.stringify(patch)).toBe(patchSnapshot)
409
-
410
- clientModule.mergeComponent(base, patch)
411
- expect(JSON.stringify(base)).toBe(baseSnapshot)
412
- expect(JSON.stringify(patch)).toBe(patchSnapshot)
413
- })
414
- })
415
-
416
- describe('modifyBoard root-level merge — server (mergeBoardRoot) / client (mergeComponent reuse) 동등성', () => {
417
- // 서버는 mergeBoardRoot/deepMergeRoot 별도 함수, 클라이언트는 mergeComponent 재사용.
418
- // 결과는 동일해야 함.
419
-
420
- test('root null = 키 제거', () => {
421
- const board: any = {
422
- width: 1000,
423
- height: 600,
424
- fillStyle: '#fff',
425
- sky: 'studio',
426
- components: []
427
- }
428
- const patch: any = {
429
- ops: [{ op: 'modifyBoard', patch: { sky: null } }],
430
- summary: '',
431
- confidence: 1
432
- }
433
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
434
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
435
- expect(r1).toEqual(r2)
436
- expect('sky' in r1).toBe(false)
437
- })
438
-
439
- test('root nested object deep merge', () => {
440
- const board: any = {
441
- width: 1000,
442
- height: 600,
443
- translate: { x: 0, y: 0 },
444
- components: []
445
- }
446
- const patch: any = {
447
- ops: [{ op: 'modifyBoard', patch: { translate: { y: 50 } } }],
448
- summary: '',
449
- confidence: 1
450
- }
451
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
452
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
453
- expect(r1).toEqual(r2)
454
- // x 보존 + y 갱신
455
- expect(r1.translate).toEqual({ x: 0, y: 50 })
456
- })
457
-
458
- test('root level fillStyle string replace', () => {
459
- const board: any = {
460
- width: 1000,
461
- height: 600,
462
- fillStyle: '#fff',
463
- components: []
464
- }
465
- const patch: any = {
466
- ops: [{ op: 'modifyBoard', patch: { fillStyle: '#0a1929' } }],
467
- summary: '',
468
- confidence: 1
469
- }
470
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
471
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
472
- expect(r1).toEqual(r2)
473
- expect(r1.fillStyle).toBe('#0a1929')
474
- })
475
-
476
- test('root multi-key 동시 변경', () => {
477
- const board: any = {
478
- width: 1000,
479
- height: 600,
480
- fillStyle: '#fff',
481
- sky: 'studio',
482
- exposure: 1,
483
- hemiIntensity: 4,
484
- components: []
485
- }
486
- const patch: any = {
487
- ops: [{
488
- op: 'modifyBoard',
489
- patch: {
490
- sky: 'sunny',
491
- exposure: 1.2,
492
- hemiIntensity: 5,
493
- dirLightColor: '#fff5e6'
494
- }
495
- }],
496
- summary: '',
497
- confidence: 1
498
- }
499
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
500
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
501
- expect(r1).toEqual(r2)
502
- })
503
-
504
- test('modifyBoard 의 patch.components 는 무시됨 (자식은 별도 op)', () => {
505
- const board: any = {
506
- width: 1000,
507
- height: 600,
508
- components: [{ refid: 1, type: 'rect' }]
509
- }
510
- const patch: any = {
511
- ops: [{
512
- op: 'modifyBoard',
513
- patch: { fillStyle: '#000', components: [] } // components 무시되어야
514
- }],
515
- summary: '',
516
- confidence: 1
517
- }
518
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
519
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
520
- expect(r1).toEqual(r2)
521
- // components 는 보존 (modifyBoard 의 components 무시)
522
- expect(r1.components).toHaveLength(1)
523
- })
524
- })
525
-
526
- describe('round-trip — applyOp ↔ computeInverseOp 으로 원본 복원', () => {
527
- test('modify round-trip — 양쪽 동일하게 복원', () => {
528
- const orig: any = {
529
- width: 1000,
530
- height: 600,
531
- components: [{ refid: 1, type: 'rect', left: 10, top: 20, fillStyle: '#fff' }]
532
- }
533
- const op: any = { op: 'modify', refid: 1, patch: { left: 100, fillStyle: '#000' } }
534
-
535
- // server
536
- const sInv = serverModule.computeInverseOp(JSON.parse(JSON.stringify(orig)), op)!
537
- const sAfter = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(orig)), {
538
- ops: [op],
539
- summary: '',
540
- confidence: 1
541
- })
542
- const sRestored = serverModule.applyBoardEditPatch(sAfter, {
543
- ops: [sInv],
544
- summary: '',
545
- confidence: 1
546
- })
547
-
548
- // client
549
- const cInv = clientModule.computeInverseOp(JSON.parse(JSON.stringify(orig)), op)!
550
- const cAfter = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(orig)), {
551
- ops: [op],
552
- summary: '',
553
- confidence: 1
554
- })
555
- const cRestored = clientModule.applyBoardEditPatch(cAfter, {
556
- ops: [cInv],
557
- summary: '',
558
- confidence: 1
559
- })
560
-
561
- // 원본과 일치
562
- expect(sRestored).toEqual(orig)
563
- expect(cRestored).toEqual(orig)
564
- // 양쪽 동일
565
- expect(sRestored).toEqual(cRestored)
566
- expect(sInv).toEqual(cInv)
567
- })
568
-
569
- test('modify with new key — inverse 가 null (키 제거 시맨틱) 양쪽 동일', () => {
570
- const orig: any = {
571
- width: 1000,
572
- height: 600,
573
- components: [{ refid: 1, type: 'rect' }]
574
- }
575
- // 원본에 없던 fillStyle 추가
576
- const op: any = { op: 'modify', refid: 1, patch: { fillStyle: '#ff0000' } }
577
-
578
- const sInv: any = serverModule.computeInverseOp(orig, op)!
579
- const cInv: any = clientModule.computeInverseOp(orig, op)!
580
-
581
- // inverse 의 patch.fillStyle 가 null (원본에 없던 키 → 복원 시 제거)
582
- expect(sInv.patch.fillStyle).toBeNull()
583
- expect(cInv.patch.fillStyle).toBeNull()
584
- expect(sInv).toEqual(cInv)
585
-
586
- // round-trip — 원본 복원
587
- const after = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(orig)), {
588
- ops: [op], summary: '', confidence: 1
589
- })
590
- const restored = serverModule.applyBoardEditPatch(after, {
591
- ops: [sInv], summary: '', confidence: 1
592
- })
593
- expect(restored).toEqual(orig)
594
- })
595
-
596
- test('remove → add inverse — 양쪽 동일 + round-trip 복원', () => {
597
- const orig: any = {
598
- width: 1000,
599
- height: 600,
600
- components: [
601
- { refid: 1, type: 'rect', left: 10 },
602
- { refid: 2, type: 'circle' }
603
- ]
604
- }
605
- const op: any = { op: 'remove', refid: 1 }
606
-
607
- const sInv = serverModule.computeInverseOp(JSON.parse(JSON.stringify(orig)), op)!
608
- const cInv = clientModule.computeInverseOp(JSON.parse(JSON.stringify(orig)), op)!
609
- expect(sInv).toEqual(cInv)
610
-
611
- // round-trip
612
- const after = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(orig)), {
613
- ops: [op], summary: '', confidence: 1
614
- })
615
- const restored = serverModule.applyBoardEditPatch(after, {
616
- ops: [sInv], summary: '', confidence: 1
617
- })
618
- // refid 1 컴포넌트 복원 (순서는 끝에 추가될 수 있음)
619
- expect(restored.components.find((c: any) => c.refid === 1)).toEqual({
620
- refid: 1,
621
- type: 'rect',
622
- left: 10
623
- })
624
- })
625
- })
626
-
627
- describe('nested components (group/container 자식) — deep search', () => {
628
- const nestedBoard = () => ({
629
- width: 1000,
630
- height: 600,
631
- components: [
632
- { refid: 1, type: 'rect', left: 10 },
633
- {
634
- refid: 2,
635
- type: 'group',
636
- components: [
637
- { refid: 3, type: 'rect', left: 20, fillStyle: '#fff' },
638
- {
639
- refid: 4,
640
- type: 'group',
641
- components: [{ refid: 5, type: 'circle', cx: 50 }]
642
- }
643
- ]
644
- }
645
- ]
646
- })
647
-
648
- test('modify 그룹 안 1단 자식 (refid 3) — 양쪽 동등', () => {
649
- const board = nestedBoard()
650
- const patch: any = {
651
- ops: [{ op: 'modify', refid: 3, patch: { fillStyle: '#000' } }],
652
- summary: '',
653
- confidence: 1
654
- }
655
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
656
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
657
- expect(r1).toEqual(r2)
658
- expect(r1.components[1].components[0].fillStyle).toBe('#000')
659
- // 다른 가지 보존
660
- expect(r1.components[0].refid).toBe(1)
661
- expect(r1.components[1].components[1].refid).toBe(4)
662
- })
663
-
664
- test('modify 그룹 안 그룹 안 자식 (refid 5, 2단) — 양쪽 동등', () => {
665
- const board = nestedBoard()
666
- const patch: any = {
667
- ops: [{ op: 'modify', refid: 5, patch: { cx: 999 } }],
668
- summary: '',
669
- confidence: 1
670
- }
671
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
672
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
673
- expect(r1).toEqual(r2)
674
- expect(r1.components[1].components[1].components[0].cx).toBe(999)
675
- })
676
-
677
- test('remove 그룹 안 자식 — 자식만 제거, 부모 group 보존', () => {
678
- const board = nestedBoard()
679
- const patch: any = {
680
- ops: [{ op: 'remove', refid: 3 }],
681
- summary: '',
682
- confidence: 1
683
- }
684
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
685
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
686
- expect(r1).toEqual(r2)
687
- // group 자체는 보존, 자식은 1개만 (refid 4)
688
- expect(r1.components[1].refid).toBe(2)
689
- expect(r1.components[1].components).toHaveLength(1)
690
- expect(r1.components[1].components[0].refid).toBe(4)
691
- })
692
-
693
- test('remove group 자체 — 그룹 + 자식들 모두 제거', () => {
694
- const board = nestedBoard()
695
- const patch: any = {
696
- ops: [{ op: 'remove', refid: 2 }],
697
- summary: '',
698
- confidence: 1
699
- }
700
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
701
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
702
- expect(r1).toEqual(r2)
703
- // top-level rect (refid 1) 만 남음
704
- expect(r1.components).toHaveLength(1)
705
- expect(r1.components[0].refid).toBe(1)
706
- })
707
-
708
- test('computeInverseOp modify nested — 양쪽 inverse 동등 + round-trip', () => {
709
- const orig = nestedBoard()
710
- const op: any = { op: 'modify', refid: 5, patch: { cx: 999 } }
711
- const sInv = serverModule.computeInverseOp(JSON.parse(JSON.stringify(orig)), op)!
712
- const cInv = clientModule.computeInverseOp(JSON.parse(JSON.stringify(orig)), op)!
713
- expect(sInv).toEqual(cInv)
714
- // 원래 cx 50 으로 복원
715
- expect((sInv as any).patch.cx).toBe(50)
716
-
717
- // round-trip
718
- const after = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(orig)), {
719
- ops: [op], summary: '', confidence: 1
720
- })
721
- const restored = serverModule.applyBoardEditPatch(after, {
722
- ops: [sInv], summary: '', confidence: 1
723
- })
724
- expect(restored).toEqual(orig)
725
- })
726
-
727
- test('존재 안 하는 refid (그룹 자식 어디에도) — silent no-op', () => {
728
- const board = nestedBoard()
729
- const patch: any = {
730
- ops: [{ op: 'modify', refid: 999, patch: { left: 0 } }],
731
- summary: '',
732
- confidence: 1
733
- }
734
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
735
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
736
- expect(r1).toEqual(r2)
737
- expect(r1).toEqual(board) // 변경 없음
738
- })
739
-
740
- test('빈 group — 그대로 보존', () => {
741
- const board: any = {
742
- width: 1000,
743
- height: 600,
744
- components: [{ refid: 1, type: 'group', components: [] }]
745
- }
746
- const patch: any = {
747
- ops: [{ op: 'modify', refid: 999, patch: { left: 0 } }],
748
- summary: '',
749
- confidence: 1
750
- }
751
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
752
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
753
- expect(r1).toEqual(r2)
754
- })
755
- })
756
-
757
- describe('edge cases — 양쪽 동일 동작 보장', () => {
758
- test('빈 ops 배열 — 보드 변경 없음', () => {
759
- const board: any = { width: 1000, height: 600, components: [] }
760
- const patch: any = { ops: [], summary: '', confidence: 1 }
761
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
762
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
763
- expect(r1).toEqual(r2)
764
- expect(r1).toEqual(board)
765
- })
766
-
767
- test('undefined board → 양쪽 동일 fallback', () => {
768
- const patch: any = {
769
- ops: [{ op: 'modifyBoard', patch: { fillStyle: '#000' } }],
770
- summary: '',
771
- confidence: 1
772
- }
773
- const r1 = serverModule.applyBoardEditPatch(undefined, patch)
774
- const r2 = clientModule.applyBoardEditPatch(undefined, patch)
775
- expect(r1).toEqual(r2)
776
- })
777
-
778
- test('존재 안 하는 refid 로 modify — 양쪽 silent no-op', () => {
779
- const board: any = {
780
- width: 1000,
781
- height: 600,
782
- components: [{ refid: 1, type: 'rect' }]
783
- }
784
- const patch: any = {
785
- ops: [{ op: 'modify', refid: 999, patch: { left: 100 } }],
786
- summary: '',
787
- confidence: 1
788
- }
789
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
790
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
791
- expect(r1).toEqual(r2)
792
- // 보드 변경 없음 (verbose 측에선 missed 분류)
793
- const v1: any = serverModule.applyBoardEditPatchVerbose(
794
- JSON.parse(JSON.stringify(board)),
795
- patch
796
- )
797
- const v2: any = clientModule.applyBoardEditPatchVerbose(
798
- JSON.parse(JSON.stringify(board)),
799
- patch
800
- )
801
- expect(v1.missed.length).toBe(1)
802
- expect(v2.missed.length).toBe(1)
803
- })
804
-
805
- test('동일 refid 에 대한 연속 modify — 누적 적용', () => {
806
- const board: any = {
807
- width: 1000,
808
- height: 600,
809
- components: [{ refid: 1, type: 'rect', left: 0, top: 0 }]
810
- }
811
- const patch: any = {
812
- ops: [
813
- { op: 'modify', refid: 1, patch: { left: 10 } },
814
- { op: 'modify', refid: 1, patch: { top: 20 } },
815
- { op: 'modify', refid: 1, patch: { left: 100 } }
816
- ],
817
- summary: '',
818
- confidence: 1
819
- }
820
- const r1 = serverModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
821
- const r2 = clientModule.applyBoardEditPatch(JSON.parse(JSON.stringify(board)), patch)
822
- expect(r1).toEqual(r2)
823
- expect(r1.components[0]).toEqual({
824
- refid: 1,
825
- type: 'rect',
826
- left: 100, // 마지막 값
827
- top: 20
828
- })
829
- })
830
- })
831
- })