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

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