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