@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,2034 @@
1
+ /**
2
+ * assistant.ts 의 pure helper 회귀 방지.
3
+ *
4
+ * 핵심 보호:
5
+ * - LLM tool call → BoardEditOp 매핑이 잘못된 인자에서도 안전
6
+ * - JSON fallback 응답 정규화: 망가진 ops, 빠진 confidence, 잘못된 타입에서도 적절히 무시
7
+ * - summarizeBoard: 토큰 절감 — 컴포넌트 수 제한, 필요 속성만 노출
8
+ */
9
+ import {
10
+ toolCallToBoardEditOp,
11
+ toolCallToBoardActionOp,
12
+ toolResultToChatResponse,
13
+ isReadTool,
14
+ isWriteTool,
15
+ isActionTool,
16
+ executeReadTool,
17
+ summarizeBoard,
18
+ summarizeToolResult,
19
+ buildBoardEditTools,
20
+ DefaultBoardAIAssistant
21
+ } from './assistant'
22
+
23
+ describe('toolCallToBoardEditOp', () => {
24
+ test('addComponent: 정상 인자', () => {
25
+ const r = toolCallToBoardEditOp({
26
+ id: '1',
27
+ name: 'addComponent',
28
+ arguments: { id: 'a', type: 'rect', left: 0, top: 0 }
29
+ } as any)
30
+ expect(r).toEqual({ op: 'add', component: { id: 'a', type: 'rect', left: 0, top: 0 } })
31
+ })
32
+
33
+ test('addComponent: type 누락 → null', () => {
34
+ const r = toolCallToBoardEditOp({ id: '1', name: 'addComponent', arguments: { id: 'a' } } as any)
35
+ expect(r).toBeNull()
36
+ })
37
+
38
+ test('removeComponentByRefid: 정상', () => {
39
+ const r = toolCallToBoardEditOp({
40
+ id: '1',
41
+ name: 'removeComponentByRefid',
42
+ arguments: { refid: 35 }
43
+ } as any)
44
+ expect(r).toEqual({ op: 'remove', refid: 35 })
45
+ })
46
+
47
+ test('removeComponentByRefid: refid 누락 → null', () => {
48
+ const r = toolCallToBoardEditOp({
49
+ id: '1',
50
+ name: 'removeComponentByRefid',
51
+ arguments: {}
52
+ } as any)
53
+ expect(r).toBeNull()
54
+ })
55
+
56
+ test('removeComponentByRefid: refid 가 number 아니면 null (id 로는 targeting 안 함)', () => {
57
+ const r = toolCallToBoardEditOp({
58
+ id: '1',
59
+ name: 'removeComponentByRefid',
60
+ arguments: { refid: 'AGV-1' }
61
+ } as any)
62
+ expect(r).toBeNull()
63
+ })
64
+
65
+ test('modifyComponentByRefid: 정상', () => {
66
+ const r = toolCallToBoardEditOp({
67
+ id: '1',
68
+ name: 'modifyComponentByRefid',
69
+ arguments: { refid: 35, patch: { left: 100 } }
70
+ } as any)
71
+ expect(r).toEqual({ op: 'modify', refid: 35, patch: { left: 100 } })
72
+ })
73
+
74
+ test('modifyComponentByRefid: patch 가 객체가 아니면 null', () => {
75
+ const r = toolCallToBoardEditOp({
76
+ id: '1',
77
+ name: 'modifyComponentByRefid',
78
+ arguments: { refid: 35, patch: 'string' }
79
+ } as any)
80
+ expect(r).toBeNull()
81
+ })
82
+
83
+ test('modifyComponentByRefid: refid 누락 → null', () => {
84
+ const r = toolCallToBoardEditOp({
85
+ id: '1',
86
+ name: 'modifyComponentByRefid',
87
+ arguments: { patch: { left: 100 } }
88
+ } as any)
89
+ expect(r).toBeNull()
90
+ })
91
+
92
+ test('legacy 이름 (removeComponent / modifyComponent / getComponent) → null', () => {
93
+ // 이름이 refid 기반으로 명시 변경됐다 — legacy 이름은 invalid 로 거부.
94
+ expect(toolCallToBoardEditOp({ id: '1', name: 'removeComponent', arguments: { id: 'a' } } as any)).toBeNull()
95
+ expect(
96
+ toolCallToBoardEditOp({ id: '1', name: 'modifyComponent', arguments: { id: 'a', patch: {} } } as any)
97
+ ).toBeNull()
98
+ })
99
+
100
+ test('modifyBoard: patch 객체 정상 — root 속성 변경', () => {
101
+ const r = toolCallToBoardEditOp({
102
+ id: '1',
103
+ name: 'modifyBoard',
104
+ arguments: { patch: { fillStyle: '#0a1929' } }
105
+ } as any)
106
+ expect(r).toEqual({ op: 'modifyBoard', patch: { fillStyle: '#0a1929' } })
107
+ })
108
+
109
+ test('modifyBoard: patch 누락 → null', () => {
110
+ expect(
111
+ toolCallToBoardEditOp({ id: '1', name: 'modifyBoard', arguments: {} } as any)
112
+ ).toBeNull()
113
+ })
114
+
115
+ test('modifyBoard: patch 가 array 면 null (object 만 허용)', () => {
116
+ expect(
117
+ toolCallToBoardEditOp({
118
+ id: '1',
119
+ name: 'modifyBoard',
120
+ arguments: { patch: [1, 2] }
121
+ } as any)
122
+ ).toBeNull()
123
+ })
124
+
125
+ test('modifyBoard: 다중 root 속성 변경', () => {
126
+ const r = toolCallToBoardEditOp({
127
+ id: '1',
128
+ name: 'modifyBoard',
129
+ arguments: { patch: { fillStyle: '#0a1929', name: 'Production', width: 1920 } }
130
+ } as any)
131
+ expect(r).toEqual({
132
+ op: 'modifyBoard',
133
+ patch: { fillStyle: '#0a1929', name: 'Production', width: 1920 }
134
+ })
135
+ })
136
+
137
+ // Phase 2 — Scene 조작 tool dispatcher
138
+ test('alignComponents: 정상', () => {
139
+ const r = toolCallToBoardEditOp({
140
+ id: '1',
141
+ name: 'alignComponents',
142
+ arguments: { refids: [1, 2, 3], direction: 'left' }
143
+ } as any)
144
+ expect(r).toEqual({ op: 'align', refids: [1, 2, 3], direction: 'left' })
145
+ })
146
+
147
+ test('alignComponents: 잘못된 direction → null', () => {
148
+ expect(
149
+ toolCallToBoardEditOp({
150
+ id: '1',
151
+ name: 'alignComponents',
152
+ arguments: { refids: [1, 2], direction: 'sideways' }
153
+ } as any)
154
+ ).toBeNull()
155
+ })
156
+
157
+ test('alignComponents: refids 비어있음 → null', () => {
158
+ expect(
159
+ toolCallToBoardEditOp({
160
+ id: '1',
161
+ name: 'alignComponents',
162
+ arguments: { refids: [], direction: 'left' }
163
+ } as any)
164
+ ).toBeNull()
165
+ })
166
+
167
+ test('alignComponents: refids 안의 non-number 무시', () => {
168
+ const r = toolCallToBoardEditOp({
169
+ id: '1',
170
+ name: 'alignComponents',
171
+ arguments: { refids: [1, 'foo', 2, null], direction: 'center' }
172
+ } as any)
173
+ expect(r).toEqual({ op: 'align', refids: [1, 2], direction: 'center' })
174
+ })
175
+
176
+ test('distributeComponents: 정상', () => {
177
+ const r = toolCallToBoardEditOp({
178
+ id: '1',
179
+ name: 'distributeComponents',
180
+ arguments: { refids: [1, 2, 3], axis: 'horizontal' }
181
+ } as any)
182
+ expect(r).toEqual({ op: 'distribute', refids: [1, 2, 3], axis: 'horizontal' })
183
+ })
184
+
185
+ test('distributeComponents: 잘못된 axis → null', () => {
186
+ expect(
187
+ toolCallToBoardEditOp({
188
+ id: '1',
189
+ name: 'distributeComponents',
190
+ arguments: { refids: [1, 2, 3], axis: 'diagonal' }
191
+ } as any)
192
+ ).toBeNull()
193
+ })
194
+
195
+ test('groupComponents: 2개 이상 필수', () => {
196
+ expect(
197
+ toolCallToBoardEditOp({
198
+ id: '1',
199
+ name: 'groupComponents',
200
+ arguments: { refids: [1] }
201
+ } as any)
202
+ ).toBeNull()
203
+ expect(
204
+ toolCallToBoardEditOp({
205
+ id: '1',
206
+ name: 'groupComponents',
207
+ arguments: { refids: [1, 2] }
208
+ } as any)
209
+ ).toEqual({ op: 'group', refids: [1, 2] })
210
+ })
211
+
212
+ test('ungroupComponent: refid number 필수', () => {
213
+ expect(
214
+ toolCallToBoardEditOp({
215
+ id: '1',
216
+ name: 'ungroupComponent',
217
+ arguments: { refid: 5 }
218
+ } as any)
219
+ ).toEqual({ op: 'ungroup', refid: 5 })
220
+ expect(
221
+ toolCallToBoardEditOp({
222
+ id: '1',
223
+ name: 'ungroupComponent',
224
+ arguments: {}
225
+ } as any)
226
+ ).toBeNull()
227
+ })
228
+
229
+ test('changeZorder: 4 direction 모두', () => {
230
+ for (const d of ['front', 'back', 'forward', 'backward'] as const) {
231
+ const r = toolCallToBoardEditOp({
232
+ id: '1',
233
+ name: 'changeZorder',
234
+ arguments: { refid: 1, direction: d }
235
+ } as any)
236
+ expect(r).toEqual({ op: 'zorder', refid: 1, direction: d })
237
+ }
238
+ })
239
+
240
+ test('changeZorder: 잘못된 direction → null', () => {
241
+ expect(
242
+ toolCallToBoardEditOp({
243
+ id: '1',
244
+ name: 'changeZorder',
245
+ arguments: { refid: 1, direction: 'sideways' }
246
+ } as any)
247
+ ).toBeNull()
248
+ })
249
+
250
+ // C-1 — ephemeral action tool dispatcher (toolCallToBoardActionOp 별도)
251
+ test('toolCallToBoardActionOp — selectComponents 정상', () => {
252
+ const r = toolCallToBoardActionOp({
253
+ id: '1',
254
+ name: 'selectComponents',
255
+ arguments: { refids: [1, 2, 3] }
256
+ } as any)
257
+ expect(r).toEqual({ action: 'selectComponents', refids: [1, 2, 3] })
258
+ })
259
+
260
+ test('toolCallToBoardActionOp — selectComponents 빈 배열도 OK (deselect)', () => {
261
+ const r = toolCallToBoardActionOp({
262
+ id: '1',
263
+ name: 'selectComponents',
264
+ arguments: { refids: [] }
265
+ } as any)
266
+ expect(r).toEqual({ action: 'selectComponents', refids: [] })
267
+ })
268
+
269
+ test('toolCallToBoardActionOp — selectComponents non-number 무시', () => {
270
+ const r = toolCallToBoardActionOp({
271
+ id: '1',
272
+ name: 'selectComponents',
273
+ arguments: { refids: [1, 'foo', 2] }
274
+ } as any)
275
+ expect(r).toEqual({ action: 'selectComponents', refids: [1, 2] })
276
+ })
277
+
278
+ test('toolCallToBoardActionOp — centerToComponent + animated', () => {
279
+ expect(
280
+ toolCallToBoardActionOp({
281
+ id: '1',
282
+ name: 'centerToComponent',
283
+ arguments: { refid: 7, animated: false }
284
+ } as any)
285
+ ).toEqual({ action: 'centerToComponent', refid: 7, animated: false })
286
+ })
287
+
288
+ test('toolCallToBoardActionOp — centerToComponent refid 누락 → null', () => {
289
+ expect(
290
+ toolCallToBoardActionOp({
291
+ id: '1',
292
+ name: 'centerToComponent',
293
+ arguments: {}
294
+ } as any)
295
+ ).toBeNull()
296
+ })
297
+
298
+ test('toolCallToBoardActionOp — fitToView 인자 없음 OK', () => {
299
+ expect(
300
+ toolCallToBoardActionOp({ id: '1', name: 'fitToView', arguments: {} } as any)
301
+ ).toEqual({ action: 'fitToView' })
302
+ })
303
+
304
+ test('toolCallToBoardActionOp — fitToView 잘못된 mode 는 무시', () => {
305
+ expect(
306
+ toolCallToBoardActionOp({
307
+ id: '1',
308
+ name: 'fitToView',
309
+ arguments: { mode: 'diagonal' }
310
+ } as any)
311
+ ).toEqual({ action: 'fitToView' }) // mode 키 자체가 빠짐
312
+ })
313
+
314
+ test('toolCallToBoardActionOp — setSceneMode edit/view 만', () => {
315
+ expect(
316
+ toolCallToBoardActionOp({
317
+ id: '1',
318
+ name: 'setSceneMode',
319
+ arguments: { mode: 'edit' }
320
+ } as any)
321
+ ).toEqual({ action: 'setSceneMode', mode: 'edit' })
322
+ expect(
323
+ toolCallToBoardActionOp({
324
+ id: '1',
325
+ name: 'setSceneMode',
326
+ arguments: { mode: 'something' }
327
+ } as any)
328
+ ).toBeNull()
329
+ })
330
+
331
+ test('replaceBoard: 정상', () => {
332
+ const r = toolCallToBoardEditOp({
333
+ id: '1',
334
+ name: 'replaceBoard',
335
+ arguments: { width: 1920, height: 1080, components: [{ type: 'rect' }] }
336
+ } as any)
337
+ expect(r?.op).toBe('replace')
338
+ if (r && r.op === 'replace') {
339
+ expect(r.board.width).toBe(1920)
340
+ expect(r.board.components).toHaveLength(1)
341
+ }
342
+ })
343
+
344
+ test('replaceBoard: components 누락 → null', () => {
345
+ const r = toolCallToBoardEditOp({ id: '1', name: 'replaceBoard', arguments: {} } as any)
346
+ expect(r).toBeNull()
347
+ })
348
+
349
+ // Sugar layout — arrangeInGrid/Row/Column dispatcher
350
+ test('arrangeInGrid: 정상 — refids + cols + 기본값', () => {
351
+ const r = toolCallToBoardEditOp({
352
+ id: '1',
353
+ name: 'arrangeInGrid',
354
+ arguments: { refids: [1, 2, 3, 4], cols: 2 }
355
+ } as any)
356
+ expect(r?.op).toBe('arrange')
357
+ if (r && r.op === 'arrange') {
358
+ expect(r.refids).toEqual([1, 2, 3, 4])
359
+ expect(r.layout).toEqual({ type: 'grid', cols: 2 })
360
+ }
361
+ })
362
+
363
+ test('arrangeInGrid: gap + anchor 반영', () => {
364
+ const r = toolCallToBoardEditOp({
365
+ id: '1',
366
+ name: 'arrangeInGrid',
367
+ arguments: {
368
+ refids: [1, 2, 3],
369
+ cols: 3,
370
+ gap: 20,
371
+ anchor: { left: 100, top: 50 }
372
+ }
373
+ } as any)
374
+ if (r && r.op === 'arrange' && r.layout.type === 'grid') {
375
+ expect(r.layout.gap).toBe(20)
376
+ expect(r.layout.anchor).toEqual({ left: 100, top: 50 })
377
+ }
378
+ })
379
+
380
+ test('arrangeInGrid: cols 누락 / refids 부족 → null', () => {
381
+ expect(
382
+ toolCallToBoardEditOp({
383
+ id: '1',
384
+ name: 'arrangeInGrid',
385
+ arguments: { refids: [1, 2] }
386
+ } as any)
387
+ ).toBeNull()
388
+ expect(
389
+ toolCallToBoardEditOp({
390
+ id: '1',
391
+ name: 'arrangeInGrid',
392
+ arguments: { refids: [1], cols: 2 }
393
+ } as any)
394
+ ).toBeNull()
395
+ })
396
+
397
+ test('arrangeInGrid: cols 가 1 미만 → null', () => {
398
+ expect(
399
+ toolCallToBoardEditOp({
400
+ id: '1',
401
+ name: 'arrangeInGrid',
402
+ arguments: { refids: [1, 2], cols: 0 }
403
+ } as any)
404
+ ).toBeNull()
405
+ })
406
+
407
+ test('arrangeInRow: 정상 — refids 만 있어도 OK', () => {
408
+ const r = toolCallToBoardEditOp({
409
+ id: '1',
410
+ name: 'arrangeInRow',
411
+ arguments: { refids: [1, 2, 3] }
412
+ } as any)
413
+ expect(r?.op).toBe('arrange')
414
+ if (r && r.op === 'arrange') {
415
+ expect(r.layout).toEqual({ type: 'row' })
416
+ }
417
+ })
418
+
419
+ test('arrangeInRow: align + gap', () => {
420
+ const r = toolCallToBoardEditOp({
421
+ id: '1',
422
+ name: 'arrangeInRow',
423
+ arguments: { refids: [1, 2], align: 'center', gap: 30 }
424
+ } as any)
425
+ if (r && r.op === 'arrange' && r.layout.type === 'row') {
426
+ expect(r.layout.align).toBe('center')
427
+ expect(r.layout.gap).toBe(30)
428
+ }
429
+ })
430
+
431
+ test('arrangeInRow: 잘못된 align 은 무시 (drop)', () => {
432
+ const r = toolCallToBoardEditOp({
433
+ id: '1',
434
+ name: 'arrangeInRow',
435
+ arguments: { refids: [1, 2], align: 'top' }
436
+ } as any)
437
+ if (r && r.op === 'arrange' && r.layout.type === 'row') {
438
+ expect(r.layout.align).toBeUndefined()
439
+ }
440
+ })
441
+
442
+ test('arrangeInColumn: 정상', () => {
443
+ const r = toolCallToBoardEditOp({
444
+ id: '1',
445
+ name: 'arrangeInColumn',
446
+ arguments: { refids: [1, 2], align: 'end', gap: 5 }
447
+ } as any)
448
+ if (r && r.op === 'arrange') {
449
+ expect(r.layout).toEqual({ type: 'column', gap: 5, align: 'end' })
450
+ }
451
+ })
452
+
453
+ test('arrangeInRow/Column: refids < 2 → null', () => {
454
+ expect(
455
+ toolCallToBoardEditOp({
456
+ id: '1',
457
+ name: 'arrangeInRow',
458
+ arguments: { refids: [1] }
459
+ } as any)
460
+ ).toBeNull()
461
+ expect(
462
+ toolCallToBoardEditOp({
463
+ id: '1',
464
+ name: 'arrangeInColumn',
465
+ arguments: { refids: [] }
466
+ } as any)
467
+ ).toBeNull()
468
+ })
469
+
470
+ test('arrange tool 들 모두 isWriteTool', () => {
471
+ expect(isWriteTool('arrangeInGrid')).toBe(true)
472
+ expect(isWriteTool('arrangeInRow')).toBe(true)
473
+ expect(isWriteTool('arrangeInColumn')).toBe(true)
474
+ })
475
+
476
+ // Prefab — addPrefab dispatcher
477
+ test('addPrefab: 정상 — registry 의 build 호출 + add op 으로 매핑', () => {
478
+ const r = toolCallToBoardEditOp({
479
+ id: '1',
480
+ name: 'addPrefab',
481
+ arguments: {
482
+ name: 'monitor-card',
483
+ left: 100,
484
+ top: 50,
485
+ options: { title: '온도', value: '23.5°C' }
486
+ }
487
+ } as any)
488
+ expect(r?.op).toBe('add')
489
+ if (r && r.op === 'add') {
490
+ expect((r.component as any).type).toBe('group')
491
+ expect((r.component as any).left).toBe(100)
492
+ expect((r.component as any).top).toBe(50)
493
+ // 옵션이 자식 텍스트로 반영
494
+ const texts = (r.component as any).components
495
+ .filter((c: any) => c.type === 'text')
496
+ .map((c: any) => c.text)
497
+ expect(texts).toContain('온도')
498
+ }
499
+ })
500
+
501
+ test('addPrefab: width/height override', () => {
502
+ const r = toolCallToBoardEditOp({
503
+ id: '1',
504
+ name: 'addPrefab',
505
+ arguments: { name: 'monitor-card', left: 0, top: 0, width: 300, height: 180 }
506
+ } as any)
507
+ if (r && r.op === 'add') {
508
+ expect((r.component as any).width).toBe(300)
509
+ expect((r.component as any).height).toBe(180)
510
+ }
511
+ })
512
+
513
+ test('addPrefab: 등록되지 않은 name → null', () => {
514
+ const r = toolCallToBoardEditOp({
515
+ id: '1',
516
+ name: 'addPrefab',
517
+ arguments: { name: 'no-such-prefab', left: 0, top: 0 }
518
+ } as any)
519
+ expect(r).toBeNull()
520
+ })
521
+
522
+ test('addPrefab: name 누락 → null', () => {
523
+ expect(
524
+ toolCallToBoardEditOp({
525
+ id: '1',
526
+ name: 'addPrefab',
527
+ arguments: { left: 0, top: 0 }
528
+ } as any)
529
+ ).toBeNull()
530
+ })
531
+
532
+ test('addPrefab: 좌표 누락 → null', () => {
533
+ expect(
534
+ toolCallToBoardEditOp({
535
+ id: '1',
536
+ name: 'addPrefab',
537
+ arguments: { name: 'monitor-card' }
538
+ } as any)
539
+ ).toBeNull()
540
+ expect(
541
+ toolCallToBoardEditOp({
542
+ id: '1',
543
+ name: 'addPrefab',
544
+ arguments: { name: 'monitor-card', left: 0 }
545
+ } as any)
546
+ ).toBeNull()
547
+ })
548
+
549
+ test('알 수 없는 tool name → null', () => {
550
+ const r = toolCallToBoardEditOp({ id: '1', name: 'unknownTool', arguments: {} } as any)
551
+ expect(r).toBeNull()
552
+ })
553
+
554
+ test('arguments 누락도 안전', () => {
555
+ const r = toolCallToBoardEditOp({ id: '1', name: 'addComponent' } as any)
556
+ expect(r).toBeNull()
557
+ })
558
+ })
559
+
560
+ describe('toolResultToChatResponse — followUp 중복 노출 회귀 방지', () => {
561
+ // 핵심: reply 가 자연어 질문 ("?" 로 끝남) 형태여도 절대 followUp 에 복제하지 않는다.
562
+ // 복제하면 클라이언트가 .md-body (assistant 메시지) 와 .followup 박스 두 곳에 같은 텍스트를
563
+ // 표시 → 사용자에게는 "동일 응답이 두 번 반복" 으로 보임. 사용자 보고된 실제 버그.
564
+ test('? 로 끝나는 reply 라도 followUp 은 항상 undefined', () => {
565
+ const r = toolResultToChatResponse({
566
+ text: '어떤 색상으로 변경할까요?',
567
+ toolCalls: [],
568
+ stopReason: 'stop'
569
+ } as any)
570
+ expect(r.reply).toBe('어떤 색상으로 변경할까요?')
571
+ expect(r.followUp).toBeUndefined()
572
+ })
573
+
574
+ test('느낌표/마침표로 끝나는 reply 도 followUp 없음', () => {
575
+ expect(
576
+ toolResultToChatResponse({ text: '완료했습니다.', toolCalls: [], stopReason: 'stop' } as any).followUp
577
+ ).toBeUndefined()
578
+ })
579
+
580
+ test('한국어 물음표 (?) 도 동일', () => {
581
+ expect(
582
+ toolResultToChatResponse({ text: '어떤 종류?', toolCalls: [], stopReason: 'stop' } as any).followUp
583
+ ).toBeUndefined()
584
+ })
585
+
586
+ test('tool calls 있을 때 reply fallback', () => {
587
+ const r = toolResultToChatResponse({
588
+ text: '',
589
+ toolCalls: [{ name: 'addComponent', arguments: { type: 'rect' } }],
590
+ stopReason: 'tool_use'
591
+ } as any)
592
+ expect(r.reply).toBe('변경했습니다.')
593
+ expect(r.patch?.ops).toHaveLength(1)
594
+ expect(r.followUp).toBeUndefined()
595
+ })
596
+
597
+ test('tool calls 와 text 동시', () => {
598
+ const r = toolResultToChatResponse({
599
+ text: 'AGV 3대 추가했습니다.',
600
+ toolCalls: [
601
+ { name: 'addComponent', arguments: { type: 'rect', id: 'a' } },
602
+ { name: 'addComponent', arguments: { type: 'rect', id: 'b' } }
603
+ ],
604
+ stopReason: 'tool_use'
605
+ } as any)
606
+ expect(r.reply).toBe('AGV 3대 추가했습니다.')
607
+ expect(r.patch?.ops).toHaveLength(2)
608
+ expect(r.patch?.summary).toBe('AGV 3대 추가했습니다.')
609
+ expect(r.patch?.confidence).toBe(0.9)
610
+ expect(r.followUp).toBeUndefined()
611
+ })
612
+
613
+ test('text 와 toolCalls 모두 비어있으면 빈 reply', () => {
614
+ const r = toolResultToChatResponse({ text: '', toolCalls: [], stopReason: 'stop' } as any)
615
+ expect(r.reply).toBe('')
616
+ expect(r.patch).toBeUndefined()
617
+ expect(r.followUp).toBeUndefined()
618
+ })
619
+
620
+ test('잘못된 tool call 는 필터링되고 patch 생성 안 함', () => {
621
+ const r = toolResultToChatResponse({
622
+ text: 'msg',
623
+ toolCalls: [{ name: 'addComponent', arguments: {} }], // type 누락
624
+ stopReason: 'stop'
625
+ } as any)
626
+ expect(r.patch).toBeUndefined()
627
+ })
628
+ })
629
+
630
+ describe('summarizeBoard', () => {
631
+ test('null 입력 → null', () => {
632
+ expect(summarizeBoard(null)).toBeNull()
633
+ expect(summarizeBoard(undefined)).toBeNull()
634
+ })
635
+
636
+ test('빈 보드 — boardRoot 에 root 속성 포함', () => {
637
+ const r = summarizeBoard({ width: 1000, height: 600, components: [] })
638
+ expect(r.boardRoot.type).toBe('model')
639
+ expect(r.boardRoot.width).toBe(1000)
640
+ expect(r.boardRoot.height).toBe(600)
641
+ expect(r.componentCount).toBe(0)
642
+ expect(r.truncated).toBe(false)
643
+ })
644
+
645
+ test('보드 root 의 fillStyle / name 등 모든 비-internal 속성 노출 (회귀 방지)', () => {
646
+ // 보드는 최상위 부모 — 자체 속성을 갖는다. AI 가 보드 자체를 컴포넌트와 별개의
647
+ // 노드로 인식하도록 root 속성을 평면 whitelist 가 아닌 통째 노출.
648
+ const r = summarizeBoard({
649
+ width: 1920,
650
+ height: 1080,
651
+ fillStyle: '#0a1929',
652
+ name: 'Production Floor',
653
+ description: '심해 컬러 대시보드',
654
+ components: []
655
+ } as any)
656
+ expect(r.boardRoot.type).toBe('model')
657
+ expect(r.boardRoot.fillStyle).toBe('#0a1929')
658
+ expect(r.boardRoot.name).toBe('Production Floor')
659
+ expect(r.boardRoot.description).toBe('심해 컬러 대시보드')
660
+ // boardRoot 는 components 를 포함하지 않음 (children 는 별도)
661
+ expect(r.boardRoot.components).toBeUndefined()
662
+ })
663
+
664
+ test('컴포넌트 요약은 모든 type-specific 속성 통과', () => {
665
+ // 기존 좁은 화이트리스트가 type-specific 키 (text/value/cx/cy/radius/points 등) 를
666
+ // 떨어뜨려서 AI 가 modify 대상을 정확히 판단할 수 없었음. 이제는 \_prefix 가 아닌
667
+ // 모든 키를 통과시켜야 한다.
668
+ const r = summarizeBoard({
669
+ width: 1000,
670
+ height: 600,
671
+ components: [
672
+ {
673
+ id: 'a',
674
+ type: 'rect',
675
+ left: 10,
676
+ top: 20,
677
+ width: 100,
678
+ height: 50,
679
+ rotation: 30,
680
+ fillStyle: '#fff',
681
+ strokeStyle: '#000',
682
+ opacity: 0.8,
683
+ customProp: 'visible to AI'
684
+ } as any
685
+ ]
686
+ })
687
+ const c = r.components[0]
688
+ expect(c.type).toBe('rect')
689
+ expect(c.left).toBe(10)
690
+ expect(c.fillStyle).toBe('#fff')
691
+ expect(c.customProp).toBe('visible to AI')
692
+ })
693
+
694
+ test('type-specific 속성 보존 (회귀 방지)', () => {
695
+ // gauge.value, text.text, circle.cx/cy/radius, polyline.points 등
696
+ // — 이들이 누락되면 AI 가 정확한 modify 를 못 한다.
697
+ const r = summarizeBoard({
698
+ width: 1000,
699
+ height: 600,
700
+ components: [
701
+ { id: 'g', type: 'gauge-circle', value: 42, min: 0, max: 100 } as any,
702
+ { id: 't', type: 'text', text: '온도', fontSize: 14 } as any,
703
+ { id: 'c', type: 'circle', cx: 50, cy: 50, radius: 20 } as any,
704
+ { id: 'p', type: 'polyline', points: [[0, 0], [10, 10]] } as any
705
+ ]
706
+ })
707
+ expect(r.components[0].value).toBe(42)
708
+ expect(r.components[0].min).toBe(0)
709
+ expect(r.components[0].max).toBe(100)
710
+ expect(r.components[1].text).toBe('온도')
711
+ expect(r.components[1].fontSize).toBe(14)
712
+ expect(r.components[2].cx).toBe(50)
713
+ expect(r.components[2].radius).toBe(20)
714
+ expect(r.components[3].points).toEqual([[0, 0], [10, 10]])
715
+ })
716
+
717
+ test('threeD 의 모든 (비-internal) 속성 통과', () => {
718
+ const r = summarizeBoard({
719
+ width: 1000,
720
+ height: 600,
721
+ components: [
722
+ {
723
+ id: 'a',
724
+ type: 'rect',
725
+ threeD: {
726
+ enabled: true,
727
+ material: { color: '#ff0000' },
728
+ geometry: { type: 'box' },
729
+ extrudeDepth: 50
730
+ }
731
+ } as any
732
+ ]
733
+ })
734
+ const c = r.components[0]
735
+ expect(c.threeD.material.color).toBe('#ff0000')
736
+ expect(c.threeD.geometry.type).toBe('box')
737
+ expect(c.threeD.enabled).toBe(true)
738
+ expect(c.threeD.extrudeDepth).toBe(50)
739
+ })
740
+
741
+ test('_underscore prefix 키와 transient 내부 키는 제외', () => {
742
+ const r = summarizeBoard({
743
+ width: 1000,
744
+ height: 600,
745
+ components: [
746
+ {
747
+ id: 'a',
748
+ type: 'rect',
749
+ _state: 'internal',
750
+ _cache: 'blob',
751
+ _layout: { stale: true },
752
+ normalProp: 'visible'
753
+ } as any
754
+ ]
755
+ })
756
+ const c = r.components[0]
757
+ expect(c.normalProp).toBe('visible')
758
+ expect(c._state).toBeUndefined()
759
+ expect(c._cache).toBeUndefined()
760
+ expect(c._layout).toBeUndefined()
761
+ })
762
+
763
+ test('긴 문자열은 truncate', () => {
764
+ const longText = 'x'.repeat(500)
765
+ const r = summarizeBoard({
766
+ width: 1000,
767
+ height: 600,
768
+ components: [{ id: 'a', type: 'text', text: longText } as any]
769
+ })
770
+ const c = r.components[0]
771
+ expect(c.text.length).toBeLessThan(longText.length)
772
+ expect(c.text).toMatch(/…\[\+\d+ chars\]$/)
773
+ })
774
+
775
+ test('큰 배열은 truncate', () => {
776
+ const bigPoints = Array.from({ length: 100 }, (_, i) => [i, i])
777
+ const r = summarizeBoard({
778
+ width: 1000,
779
+ height: 600,
780
+ components: [{ id: 'p', type: 'polyline', points: bigPoints } as any]
781
+ })
782
+ const c = r.components[0]
783
+ expect(c.points.length).toBeLessThan(bigPoints.length + 2)
784
+ // 마지막에 truncation 마커
785
+ expect(c.points[c.points.length - 1]).toMatch(/items\]/)
786
+ })
787
+
788
+ test('80개 초과 컴포넌트는 truncate', () => {
789
+ const components = Array.from({ length: 100 }, (_, i) => ({ id: `c${i}`, type: 'rect' } as any))
790
+ const r = summarizeBoard({ width: 1000, height: 600, components })
791
+ expect(r.componentCount).toBe(100)
792
+ expect(r.components).toHaveLength(80)
793
+ expect(r.truncated).toBe(true)
794
+ })
795
+
796
+ test('rotation/opacity 가 undefined 이면 출력에 없음', () => {
797
+ const r = summarizeBoard({
798
+ width: 1000,
799
+ height: 600,
800
+ components: [{ id: 'a', type: 'rect' } as any]
801
+ })
802
+ expect(r.components[0].rotation).toBeUndefined()
803
+ expect(r.components[0].opacity).toBeUndefined()
804
+ })
805
+ })
806
+
807
+ describe('Tool 분류 (read/write)', () => {
808
+ test('read tool 식별', () => {
809
+ expect(isReadTool('getSelection')).toBe(true)
810
+ expect(isReadTool('getComponentByRefid')).toBe(true)
811
+ expect(isReadTool('findComponents')).toBe(true)
812
+ expect(isReadTool('addComponent')).toBe(false)
813
+ })
814
+
815
+ test('write tool 식별', () => {
816
+ expect(isWriteTool('addComponent')).toBe(true)
817
+ expect(isWriteTool('removeComponentByRefid')).toBe(true)
818
+ expect(isWriteTool('modifyComponentByRefid')).toBe(true)
819
+ expect(isWriteTool('replaceBoard')).toBe(true)
820
+ expect(isWriteTool('getSelection')).toBe(false)
821
+ })
822
+
823
+ test('legacy 이름은 read/write 분류에서 제외 (id 기반 → refid 기반 마이그레이션)', () => {
824
+ expect(isReadTool('getComponent')).toBe(false)
825
+ expect(isWriteTool('removeComponent')).toBe(false)
826
+ expect(isWriteTool('modifyComponent')).toBe(false)
827
+ })
828
+
829
+ test('알 수 없는 이름은 둘 다 false', () => {
830
+ expect(isReadTool('foo')).toBe(false)
831
+ expect(isWriteTool('foo')).toBe(false)
832
+ })
833
+ })
834
+
835
+ describe('buildBoardEditTools — refid 기반 tool 이름 (식별자 정책 회귀 방지)', () => {
836
+ test('41개 tool 모두 정의 (25 write + 8 read + 4 ephemeral action + 4 import)', () => {
837
+ const tools = buildBoardEditTools(['rect', 'circle'])
838
+ const names = tools.map(t => t.name).sort()
839
+ expect(names).toEqual([
840
+ 'addComponent',
841
+ 'addPrefab',
842
+ 'alignComponents',
843
+ 'arrangeInColumn',
844
+ 'arrangeInGrid',
845
+ 'arrangeInRow',
846
+ 'centerToComponent',
847
+ 'changeZorder',
848
+ 'clearStyle',
849
+ 'copyStyle',
850
+ 'distributeComponents',
851
+ 'findComponents',
852
+ 'fitToView',
853
+ 'getAtmosphereGuide',
854
+ 'getAvailablePrefabs',
855
+ 'getColorPalettes',
856
+ 'getComponentByRefid',
857
+ 'getComponentStyle',
858
+ 'getImportSession',
859
+ 'getLayoutTemplates',
860
+ 'getSelection',
861
+ 'groupComponents',
862
+ 'importBoardAsync',
863
+ 'listImportAdapters',
864
+ 'materializeImportSession',
865
+ 'modifyBoard',
866
+ 'modifyComponentByRefid',
867
+ 'removeComponentByRefid',
868
+ 'replaceBoard',
869
+ 'selectComponents',
870
+ 'setFill',
871
+ 'setFont',
872
+ 'setMaterial',
873
+ 'setOpacity',
874
+ 'setSceneMode',
875
+ 'setShadow',
876
+ 'setShadowProperties',
877
+ 'setStroke',
878
+ 'setText',
879
+ 'setVisible',
880
+ 'ungroupComponent'
881
+ ])
882
+ })
883
+
884
+ test('alignComponents 는 refids + direction 필수', () => {
885
+ const tools = buildBoardEditTools([])
886
+ const t = tools.find(x => x.name === 'alignComponents')!
887
+ expect(t.parameters.required.sort()).toEqual(['direction', 'refids'])
888
+ expect(t.parameters.properties.direction.enum).toContain('left')
889
+ expect(t.parameters.properties.direction.enum).toContain('center')
890
+ expect(t.parameters.properties.direction.enum).toContain('bottom')
891
+ })
892
+
893
+ test('distributeComponents 는 axis 가 horizontal/vertical 만', () => {
894
+ const tools = buildBoardEditTools([])
895
+ const t = tools.find(x => x.name === 'distributeComponents')!
896
+ expect(t.parameters.properties.axis.enum).toEqual(['horizontal', 'vertical'])
897
+ })
898
+
899
+ test('changeZorder 는 direction 4종 (front/back/forward/backward)', () => {
900
+ const tools = buildBoardEditTools([])
901
+ const t = tools.find(x => x.name === 'changeZorder')!
902
+ expect(t.parameters.properties.direction.enum).toEqual([
903
+ 'front',
904
+ 'back',
905
+ 'forward',
906
+ 'backward'
907
+ ])
908
+ })
909
+
910
+ test('Phase 2 op 모두 isWriteTool', () => {
911
+ expect(isWriteTool('alignComponents')).toBe(true)
912
+ expect(isWriteTool('distributeComponents')).toBe(true)
913
+ expect(isWriteTool('groupComponents')).toBe(true)
914
+ expect(isWriteTool('ungroupComponent')).toBe(true)
915
+ expect(isWriteTool('changeZorder')).toBe(true)
916
+ })
917
+
918
+ test('C-1 ephemeral action tool — read 도 write 도 아닌 별개 분류', () => {
919
+ for (const n of ['selectComponents', 'centerToComponent', 'fitToView', 'setSceneMode']) {
920
+ expect(isReadTool(n)).toBe(false)
921
+ expect(isWriteTool(n)).toBe(false)
922
+ expect(isActionTool(n)).toBe(true)
923
+ }
924
+ })
925
+
926
+ test('selectComponents 는 refids 필수 (빈 배열은 deselect)', () => {
927
+ const tools = buildBoardEditTools([])
928
+ const t = tools.find(x => x.name === 'selectComponents')!
929
+ expect(t.parameters.required).toEqual(['refids'])
930
+ })
931
+
932
+ test('centerToComponent 는 refid 필수, animated 옵션', () => {
933
+ const tools = buildBoardEditTools([])
934
+ const t = tools.find(x => x.name === 'centerToComponent')!
935
+ expect(t.parameters.required).toEqual(['refid'])
936
+ expect(t.parameters.properties.animated.type).toBe('boolean')
937
+ })
938
+
939
+ test('fitToView 는 mode optional (fit/ratio/width/height)', () => {
940
+ const tools = buildBoardEditTools([])
941
+ const t = tools.find(x => x.name === 'fitToView')!
942
+ expect(t.parameters.required).toBeUndefined()
943
+ expect(t.parameters.properties.mode.enum).toEqual(['fit', 'ratio', 'width', 'height'])
944
+ })
945
+
946
+ test('setSceneMode 는 edit/view enum', () => {
947
+ const tools = buildBoardEditTools([])
948
+ const t = tools.find(x => x.name === 'setSceneMode')!
949
+ expect(t.parameters.required).toEqual(['mode'])
950
+ expect(t.parameters.properties.mode.enum).toEqual(['edit', 'view'])
951
+ })
952
+
953
+ test('modifyBoard 는 patch (object) 필수', () => {
954
+ const tools = buildBoardEditTools([])
955
+ const t = tools.find(x => x.name === 'modifyBoard')!
956
+ expect(t.parameters.required).toEqual(['patch'])
957
+ expect(t.parameters.properties.patch.type).toBe('object')
958
+ })
959
+
960
+ test('isWriteTool 가 modifyBoard 인식', () => {
961
+ expect(isWriteTool('modifyBoard')).toBe(true)
962
+ })
963
+
964
+ test('getSelection 은 인자 없음', () => {
965
+ const tools = buildBoardEditTools([])
966
+ const t = tools.find(x => x.name === 'getSelection')!
967
+ expect(t.parameters.properties).toEqual({})
968
+ })
969
+
970
+ test('getComponentByRefid 는 refid (number) 필수', () => {
971
+ const tools = buildBoardEditTools([])
972
+ const t = tools.find(x => x.name === 'getComponentByRefid')!
973
+ expect(t.parameters.required).toEqual(['refid'])
974
+ expect(t.parameters.properties.refid.type).toBe('number')
975
+ })
976
+
977
+ test('removeComponentByRefid 는 refid (number) 필수', () => {
978
+ const tools = buildBoardEditTools([])
979
+ const t = tools.find(x => x.name === 'removeComponentByRefid')!
980
+ expect(t.parameters.required).toEqual(['refid'])
981
+ expect(t.parameters.properties.refid.type).toBe('number')
982
+ })
983
+
984
+ test('modifyComponentByRefid 는 refid + patch 필수', () => {
985
+ const tools = buildBoardEditTools([])
986
+ const t = tools.find(x => x.name === 'modifyComponentByRefid')!
987
+ expect(t.parameters.required).toEqual(['refid', 'patch'])
988
+ expect(t.parameters.properties.refid.type).toBe('number')
989
+ })
990
+
991
+ test('findComponents 는 모든 필터 optional + id (data binding) 필터 포함', () => {
992
+ const tools = buildBoardEditTools([])
993
+ const t = tools.find(x => x.name === 'findComponents')!
994
+ expect(t.parameters.required).toBeUndefined()
995
+ expect(t.parameters.properties).toHaveProperty('type')
996
+ expect(t.parameters.properties).toHaveProperty('namePattern')
997
+ expect(t.parameters.properties).toHaveProperty('region')
998
+ expect(t.parameters.properties).toHaveProperty('id')
999
+ })
1000
+ })
1001
+
1002
+ describe('executeReadTool — getSelection', () => {
1003
+ const board = {
1004
+ width: 1000,
1005
+ height: 600,
1006
+ components: [
1007
+ { refid: 1, type: 'rect', left: 10, top: 20 },
1008
+ { refid: 2, type: 'circle', cx: 50, cy: 50, id: 'motor-1' }, // 데이터 바인딩 id 보유
1009
+ { refid: 3, type: 'text', text: 'hello' }
1010
+ ]
1011
+ } as any
1012
+
1013
+ test('selectedRefids 비어있으면 안내 메시지 반환', () => {
1014
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [])
1015
+ expect(r.selected).toEqual([])
1016
+ expect(r.message).toMatch(/no.*selected/i)
1017
+ })
1018
+
1019
+ test('refid 매칭되는 컴포넌트 전체 반환 (truncate 안 함)', () => {
1020
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [1, 3])
1021
+ expect(r.count).toBe(2)
1022
+ expect(r.selected).toHaveLength(2)
1023
+ expect(r.selected.find((c: any) => c.refid === 1)).toEqual({
1024
+ refid: 1,
1025
+ type: 'rect',
1026
+ left: 10,
1027
+ top: 20
1028
+ })
1029
+ expect(r.selected.find((c: any) => c.refid === 3)).toEqual({
1030
+ refid: 3,
1031
+ type: 'text',
1032
+ text: 'hello'
1033
+ })
1034
+ })
1035
+
1036
+ test('id (데이터 바인딩) 와 refid 가 별개 — selection 은 refid 만 사용', () => {
1037
+ // selectedRefids 가 컴포넌트의 id 가 아니라 refid 와 매칭됨을 검증.
1038
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [2])
1039
+ expect(r.count).toBe(1)
1040
+ expect(r.selected[0].refid).toBe(2)
1041
+ expect(r.selected[0].id).toBe('motor-1')
1042
+ })
1043
+
1044
+ test('존재하지 않는 refid 는 결과에서 빠짐 (에러 아님)', () => {
1045
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [999, 1])
1046
+ expect(r.count).toBe(1)
1047
+ expect(r.selected[0].refid).toBe(1)
1048
+ })
1049
+
1050
+ test('currentBoard null 이어도 안전', () => {
1051
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, null, [1])
1052
+ expect(r.selected).toEqual([])
1053
+ })
1054
+ })
1055
+
1056
+ describe('executeReadTool — getComponentByRefid', () => {
1057
+ const board = {
1058
+ width: 1000,
1059
+ height: 600,
1060
+ components: [{ refid: 35, type: 'rect', left: 10, customProp: 'value', id: 'motor-1' }]
1061
+ } as any
1062
+
1063
+ test('정상 조회 — 모든 필드 반환 (id 포함)', () => {
1064
+ const r = executeReadTool(
1065
+ { id: '1', name: 'getComponentByRefid', arguments: { refid: 35 } } as any,
1066
+ board,
1067
+ []
1068
+ )
1069
+ expect(r.component).toEqual({
1070
+ refid: 35,
1071
+ type: 'rect',
1072
+ left: 10,
1073
+ customProp: 'value',
1074
+ id: 'motor-1'
1075
+ })
1076
+ })
1077
+
1078
+ test('refid 누락 → error', () => {
1079
+ const r = executeReadTool(
1080
+ { id: '1', name: 'getComponentByRefid', arguments: {} } as any,
1081
+ board,
1082
+ []
1083
+ )
1084
+ expect(r.error).toMatch(/refid.*required/i)
1085
+ })
1086
+
1087
+ test('refid 가 number 가 아니면 error', () => {
1088
+ const r = executeReadTool(
1089
+ { id: '1', name: 'getComponentByRefid', arguments: { refid: 'AGV-1' } } as any,
1090
+ board,
1091
+ []
1092
+ )
1093
+ expect(r.error).toMatch(/refid.*required/i)
1094
+ })
1095
+
1096
+ test('없는 refid → not found error', () => {
1097
+ const r = executeReadTool(
1098
+ { id: '1', name: 'getComponentByRefid', arguments: { refid: 999 } } as any,
1099
+ board,
1100
+ []
1101
+ )
1102
+ expect(r.error).toMatch(/not found/i)
1103
+ })
1104
+ })
1105
+
1106
+ describe('executeReadTool — findComponents', () => {
1107
+ const board = {
1108
+ width: 1000,
1109
+ height: 600,
1110
+ components: [
1111
+ { refid: 1, type: 'rect', name: 'AGV-1', id: 'motor-1', left: 10, top: 20 },
1112
+ { refid: 2, type: 'rect', name: 'AGV-2', id: 'motor-1', left: 100, top: 200 }, // 같은 데이터 바인딩 id
1113
+ { refid: 3, type: 'circle', name: 'Sensor-1', cx: 50, cy: 50 },
1114
+ { refid: 4, type: 'text', name: '제어판', left: 500, top: 50 }
1115
+ ]
1116
+ } as any
1117
+
1118
+ test('필터 없음 — 모두 반환', () => {
1119
+ const r = executeReadTool({ id: '1', name: 'findComponents', arguments: {} } as any, board, [])
1120
+ expect(r.totalMatched).toBe(4)
1121
+ expect(r.components).toHaveLength(4)
1122
+ expect(r.truncated).toBe(false)
1123
+ })
1124
+
1125
+ test('type 필터', () => {
1126
+ const r = executeReadTool(
1127
+ { id: '1', name: 'findComponents', arguments: { type: 'rect' } } as any,
1128
+ board,
1129
+ []
1130
+ )
1131
+ expect(r.totalMatched).toBe(2)
1132
+ expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
1133
+ })
1134
+
1135
+ test('id 필터 — 데이터 바인딩 그룹 (unique 아님, 다중 매치)', () => {
1136
+ const r = executeReadTool(
1137
+ { id: '1', name: 'findComponents', arguments: { id: 'motor-1' } } as any,
1138
+ board,
1139
+ []
1140
+ )
1141
+ expect(r.totalMatched).toBe(2)
1142
+ expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
1143
+ })
1144
+
1145
+ test('namePattern 필터 (case-insensitive substring)', () => {
1146
+ const r = executeReadTool(
1147
+ { id: '1', name: 'findComponents', arguments: { namePattern: 'agv' } } as any,
1148
+ board,
1149
+ []
1150
+ )
1151
+ expect(r.totalMatched).toBe(2)
1152
+ expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
1153
+ })
1154
+
1155
+ test('한글 namePattern', () => {
1156
+ const r = executeReadTool(
1157
+ { id: '1', name: 'findComponents', arguments: { namePattern: '제어' } } as any,
1158
+ board,
1159
+ []
1160
+ )
1161
+ expect(r.totalMatched).toBe(1)
1162
+ expect(r.components[0].refid).toBe(4)
1163
+ })
1164
+
1165
+ test('region 필터 — bounding box', () => {
1166
+ const r = executeReadTool(
1167
+ {
1168
+ id: '1',
1169
+ name: 'findComponents',
1170
+ arguments: { region: { left: 0, top: 0, width: 50, height: 100 } }
1171
+ } as any,
1172
+ board,
1173
+ []
1174
+ )
1175
+ // refid 1 (10,20) ✓, 3 (cx 50, cy 50) ✓, 2 (100,200) ✗, 4 (500,50) ✗
1176
+ expect(r.totalMatched).toBe(2)
1177
+ })
1178
+
1179
+ test('필터 AND 결합 (type + namePattern)', () => {
1180
+ const r = executeReadTool(
1181
+ {
1182
+ id: '1',
1183
+ name: 'findComponents',
1184
+ arguments: { type: 'rect', namePattern: '2' }
1185
+ } as any,
1186
+ board,
1187
+ []
1188
+ )
1189
+ expect(r.totalMatched).toBe(1)
1190
+ expect(r.components[0].refid).toBe(2)
1191
+ })
1192
+
1193
+ test('필터 AND 결합 (type + id) — id 만으로는 multi 매치, type 으로 좁혀짐', () => {
1194
+ const r = executeReadTool(
1195
+ {
1196
+ id: '1',
1197
+ name: 'findComponents',
1198
+ arguments: { type: 'rect', id: 'motor-1' }
1199
+ } as any,
1200
+ board,
1201
+ []
1202
+ )
1203
+ expect(r.totalMatched).toBe(2) // 둘 다 type=rect 라 좁혀지지 않음
1204
+ })
1205
+
1206
+ test('50개 cap', () => {
1207
+ const big = {
1208
+ width: 1000,
1209
+ height: 600,
1210
+ components: Array.from({ length: 100 }, (_, i) => ({ refid: i + 1, type: 'rect' }))
1211
+ } as any
1212
+ const r = executeReadTool(
1213
+ { id: '1', name: 'findComponents', arguments: { type: 'rect' } } as any,
1214
+ big,
1215
+ []
1216
+ )
1217
+ expect(r.totalMatched).toBe(100)
1218
+ expect(r.components).toHaveLength(50)
1219
+ expect(r.truncated).toBe(true)
1220
+ })
1221
+
1222
+ test('잘못된 region (필드 누락) 은 무시', () => {
1223
+ const r = executeReadTool(
1224
+ {
1225
+ id: '1',
1226
+ name: 'findComponents',
1227
+ arguments: { region: { left: 0 } } // incomplete
1228
+ } as any,
1229
+ board,
1230
+ []
1231
+ )
1232
+ // region 필터 무시 → 전체 반환
1233
+ expect(r.totalMatched).toBe(4)
1234
+ })
1235
+ })
1236
+
1237
+ describe('executeReadTool — getAvailablePrefabs (Prefab 카탈로그)', () => {
1238
+ test('인자 없음 — 모든 prefab 반환', () => {
1239
+ const r = executeReadTool(
1240
+ { id: '1', name: 'getAvailablePrefabs', arguments: {} } as any,
1241
+ null,
1242
+ []
1243
+ )
1244
+ expect(r.count).toBeGreaterThan(0)
1245
+ expect(Array.isArray(r.prefabs)).toBe(true)
1246
+ // default 3종 노출
1247
+ const names = r.prefabs.map((p: any) => p.name).sort()
1248
+ expect(names).toContain('monitor-card')
1249
+ expect(names).toContain('gauge-panel')
1250
+ expect(names).toContain('stat-tile')
1251
+ })
1252
+
1253
+ test('category 필터', () => {
1254
+ const r = executeReadTool(
1255
+ {
1256
+ id: '1',
1257
+ name: 'getAvailablePrefabs',
1258
+ arguments: { category: 'monitoring' }
1259
+ } as any,
1260
+ null,
1261
+ []
1262
+ )
1263
+ expect(r.prefabs.every((p: any) => p.category === 'monitoring')).toBe(true)
1264
+ })
1265
+
1266
+ test('keywordPattern 매칭', () => {
1267
+ const r = executeReadTool(
1268
+ {
1269
+ id: '1',
1270
+ name: 'getAvailablePrefabs',
1271
+ arguments: { keywordPattern: '게이지' }
1272
+ } as any,
1273
+ null,
1274
+ []
1275
+ )
1276
+ expect(r.prefabs.find((p: any) => p.name === 'gauge-panel')).toBeDefined()
1277
+ })
1278
+
1279
+ test('build 함수는 결과에 포함되지 않음 (LLM 토큰 절감 + 직렬화)', () => {
1280
+ const r = executeReadTool(
1281
+ { id: '1', name: 'getAvailablePrefabs', arguments: {} } as any,
1282
+ null,
1283
+ []
1284
+ )
1285
+ for (const p of r.prefabs) {
1286
+ expect(p.build).toBeUndefined()
1287
+ }
1288
+ })
1289
+
1290
+ test('isReadTool 분류 — read 로', () => {
1291
+ expect(isReadTool('getAvailablePrefabs')).toBe(true)
1292
+ expect(isWriteTool('getAvailablePrefabs')).toBe(false)
1293
+ })
1294
+
1295
+ test('isWriteTool 분류 — addPrefab 은 write', () => {
1296
+ expect(isWriteTool('addPrefab')).toBe(true)
1297
+ expect(isReadTool('addPrefab')).toBe(false)
1298
+ })
1299
+ })
1300
+
1301
+ describe('executeReadTool — getColorPalettes (색 팔레트 카탈로그)', () => {
1302
+ test('카탈로그 객체 반환 — overview / palettes / recommendations / workflow / pitfalls', () => {
1303
+ const r = executeReadTool(
1304
+ { id: '1', name: 'getColorPalettes', arguments: {} } as any,
1305
+ null,
1306
+ []
1307
+ )
1308
+ expect(r).toHaveProperty('overview')
1309
+ expect(r).toHaveProperty('palettes')
1310
+ expect(r).toHaveProperty('recommendations')
1311
+ expect(r).toHaveProperty('workflow')
1312
+ expect(r).toHaveProperty('pitfalls')
1313
+ })
1314
+
1315
+ test('palettes 7종 모두 등록', () => {
1316
+ const r = executeReadTool(
1317
+ { id: '1', name: 'getColorPalettes', arguments: {} } as any,
1318
+ null,
1319
+ []
1320
+ )
1321
+ const names = Object.keys(r.palettes).sort()
1322
+ expect(names).toEqual([
1323
+ 'control-room-dark',
1324
+ 'financial-trader',
1325
+ 'industrial-steel',
1326
+ 'logistics-warm',
1327
+ 'medical-clean',
1328
+ 'neon-cyberpunk',
1329
+ 'paper-light'
1330
+ ])
1331
+ })
1332
+
1333
+ test('각 palette 가 표준 토큰 보유 (base/surface/border/text/muted/accent/4-status)', () => {
1334
+ const r = executeReadTool(
1335
+ { id: '1', name: 'getColorPalettes', arguments: {} } as any,
1336
+ null,
1337
+ []
1338
+ )
1339
+ const requiredKeys = [
1340
+ 'description',
1341
+ 'base',
1342
+ 'surface',
1343
+ 'border',
1344
+ 'text',
1345
+ 'muted',
1346
+ 'accent',
1347
+ 'success',
1348
+ 'warning',
1349
+ 'danger',
1350
+ 'info'
1351
+ ]
1352
+ for (const palette of Object.values(r.palettes)) {
1353
+ for (const k of requiredKeys) {
1354
+ expect(palette).toHaveProperty(k)
1355
+ }
1356
+ }
1357
+ })
1358
+
1359
+ test('palette 의 색상값이 hex 문자열 형식', () => {
1360
+ const r = executeReadTool(
1361
+ { id: '1', name: 'getColorPalettes', arguments: {} } as any,
1362
+ null,
1363
+ []
1364
+ )
1365
+ const colorKeys = ['base', 'surface', 'border', 'text', 'muted', 'accent']
1366
+ for (const palette of Object.values<any>(r.palettes)) {
1367
+ for (const k of colorKeys) {
1368
+ expect(palette[k]).toMatch(/^#[0-9a-fA-F]{6}$/)
1369
+ }
1370
+ }
1371
+ })
1372
+
1373
+ test('recommendations 가 모든 카테고리 → palette 이름 배열', () => {
1374
+ const r = executeReadTool(
1375
+ { id: '1', name: 'getColorPalettes', arguments: {} } as any,
1376
+ null,
1377
+ []
1378
+ )
1379
+ for (const candidates of Object.values<any>(r.recommendations)) {
1380
+ expect(Array.isArray(candidates)).toBe(true)
1381
+ expect(candidates.length).toBeGreaterThan(0)
1382
+ for (const name of candidates) {
1383
+ expect(r.palettes[name]).toBeDefined() // 실제 등록된 palette 만 추천
1384
+ }
1385
+ }
1386
+ })
1387
+
1388
+ test('isReadTool 분류', () => {
1389
+ expect(isReadTool('getColorPalettes')).toBe(true)
1390
+ expect(isWriteTool('getColorPalettes')).toBe(false)
1391
+ })
1392
+ })
1393
+
1394
+ describe('executeReadTool — getLayoutTemplates (Layout 템플릿 카탈로그)', () => {
1395
+ test('카탈로그 객체 반환 — overview / templates / workflow / pitfalls', () => {
1396
+ const r = executeReadTool(
1397
+ { id: '1', name: 'getLayoutTemplates', arguments: {} } as any,
1398
+ null,
1399
+ []
1400
+ )
1401
+ expect(r).toHaveProperty('overview')
1402
+ expect(r).toHaveProperty('templates')
1403
+ expect(r).toHaveProperty('workflow')
1404
+ expect(r).toHaveProperty('pitfalls')
1405
+ })
1406
+
1407
+ test('templates 6종 등록 (kpi-row / 3x3-grid / t-shape / side-rail / split-half / header-content-footer)', () => {
1408
+ const r = executeReadTool(
1409
+ { id: '1', name: 'getLayoutTemplates', arguments: {} } as any,
1410
+ null,
1411
+ []
1412
+ )
1413
+ const names = Object.keys(r.templates).sort()
1414
+ expect(names).toEqual([
1415
+ '3x3-grid',
1416
+ 'header-content-footer',
1417
+ 'kpi-row',
1418
+ 'side-rail',
1419
+ 'split-half',
1420
+ 't-shape-command'
1421
+ ])
1422
+ })
1423
+
1424
+ test('각 template 의 slots 가 실 좌표/크기 보유', () => {
1425
+ const r = executeReadTool(
1426
+ { id: '1', name: 'getLayoutTemplates', arguments: {} } as any,
1427
+ null,
1428
+ []
1429
+ )
1430
+ for (const tpl of Object.values<any>(r.templates)) {
1431
+ expect(Array.isArray(tpl.slots)).toBe(true)
1432
+ expect(tpl.slots.length).toBeGreaterThan(0)
1433
+ for (const slot of tpl.slots) {
1434
+ expect(typeof slot.name).toBe('string')
1435
+ expect(typeof slot.region.left).toBe('number')
1436
+ expect(typeof slot.region.top).toBe('number')
1437
+ expect(typeof slot.region.width).toBe('number')
1438
+ expect(typeof slot.region.height).toBe('number')
1439
+ expect(slot.region.width).toBeGreaterThan(0)
1440
+ expect(slot.region.height).toBeGreaterThan(0)
1441
+ }
1442
+ }
1443
+ })
1444
+
1445
+ test('3x3-grid 는 정확히 9개 slot', () => {
1446
+ const r = executeReadTool(
1447
+ { id: '1', name: 'getLayoutTemplates', arguments: {} } as any,
1448
+ null,
1449
+ []
1450
+ )
1451
+ expect(r.templates['3x3-grid'].slots).toHaveLength(9)
1452
+ })
1453
+
1454
+ test('kpi-row 는 4개 KPI slot + main-area', () => {
1455
+ const r = executeReadTool(
1456
+ { id: '1', name: 'getLayoutTemplates', arguments: {} } as any,
1457
+ null,
1458
+ []
1459
+ )
1460
+ const slots = r.templates['kpi-row'].slots
1461
+ const kpiSlots = slots.filter((s: any) => s.name.startsWith('kpi-'))
1462
+ expect(kpiSlots).toHaveLength(4)
1463
+ expect(slots.find((s: any) => s.name === 'main-area')).toBeDefined()
1464
+ })
1465
+
1466
+ test('slot region 이 1280×720 보드 내부에 있음', () => {
1467
+ const r = executeReadTool(
1468
+ { id: '1', name: 'getLayoutTemplates', arguments: {} } as any,
1469
+ null,
1470
+ []
1471
+ )
1472
+ for (const tpl of Object.values<any>(r.templates)) {
1473
+ const bw = tpl.boardSize.width
1474
+ const bh = tpl.boardSize.height
1475
+ for (const slot of tpl.slots) {
1476
+ expect(slot.region.left + slot.region.width).toBeLessThanOrEqual(bw)
1477
+ expect(slot.region.top + slot.region.height).toBeLessThanOrEqual(bh)
1478
+ }
1479
+ }
1480
+ })
1481
+
1482
+ test('isReadTool 분류', () => {
1483
+ expect(isReadTool('getLayoutTemplates')).toBe(true)
1484
+ expect(isWriteTool('getLayoutTemplates')).toBe(false)
1485
+ })
1486
+ })
1487
+
1488
+ describe('executeReadTool — 알 수 없는 read tool', () => {
1489
+ test('error 반환', () => {
1490
+ const r = executeReadTool({ id: '1', name: 'unknownTool', arguments: {} } as any, null, [])
1491
+ expect(r.error).toMatch(/unknown/i)
1492
+ })
1493
+ })
1494
+
1495
+ describe('executeReadTool — getAtmosphereGuide (3D 분위기 합성 primer)', () => {
1496
+ test('가이드 객체 반환 — overview / parameters / archetypes / modifiers / workflow / pitfalls', () => {
1497
+ const r = executeReadTool(
1498
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
1499
+ null,
1500
+ []
1501
+ )
1502
+ expect(r).toHaveProperty('overview')
1503
+ expect(r).toHaveProperty('parameters')
1504
+ expect(r).toHaveProperty('archetypes')
1505
+ expect(r).toHaveProperty('modifiers')
1506
+ expect(r).toHaveProperty('workflow')
1507
+ expect(r).toHaveProperty('pitfalls')
1508
+ })
1509
+
1510
+ test('parameters 섹션이 핵심 키 망라 (sky / exposure / hemisphere / directional / shadow / floor / camera)', () => {
1511
+ const r = executeReadTool(
1512
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
1513
+ null,
1514
+ []
1515
+ )
1516
+ expect(r.parameters).toHaveProperty('sky')
1517
+ expect(r.parameters).toHaveProperty('skyColor')
1518
+ expect(r.parameters).toHaveProperty('exposure')
1519
+ expect(r.parameters).toHaveProperty('hemisphere')
1520
+ expect(r.parameters).toHaveProperty('directional')
1521
+ expect(r.parameters).toHaveProperty('shadow')
1522
+ expect(r.parameters).toHaveProperty('floor')
1523
+ expect(r.parameters).toHaveProperty('camera')
1524
+ })
1525
+
1526
+ test('sky preset 카탈로그가 things-scene 의 8 개 모두 포함', () => {
1527
+ const r = executeReadTool(
1528
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
1529
+ null,
1530
+ []
1531
+ )
1532
+ const presetNames = Object.keys(r.parameters.sky.presets)
1533
+ expect(presetNames.sort()).toEqual([
1534
+ 'cloudy',
1535
+ 'factory',
1536
+ 'home',
1537
+ 'office',
1538
+ 'rainy',
1539
+ 'studio',
1540
+ 'sunny',
1541
+ 'warehouse'
1542
+ ])
1543
+ })
1544
+
1545
+ test('archetype 카테고리 — timeOfDay / indoor / surreal', () => {
1546
+ const r = executeReadTool(
1547
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
1548
+ null,
1549
+ []
1550
+ )
1551
+ expect(r.archetypes).toHaveProperty('timeOfDay')
1552
+ expect(r.archetypes).toHaveProperty('indoor')
1553
+ expect(r.archetypes).toHaveProperty('surreal')
1554
+ })
1555
+
1556
+ test('각 archetype 은 description + patch 보유 (modifyBoard patch 그대로 사용 가능)', () => {
1557
+ const r = executeReadTool(
1558
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
1559
+ null,
1560
+ []
1561
+ )
1562
+ for (const cat of ['timeOfDay', 'indoor', 'surreal'] as const) {
1563
+ const items = r.archetypes[cat]
1564
+ for (const name of Object.keys(items)) {
1565
+ const a = items[name]
1566
+ expect(a.description).toBeDefined()
1567
+ expect(a.patch).toBeDefined()
1568
+ // patch 가 sky / exposure 등 root 키를 갖는지
1569
+ expect(typeof a.patch.exposure).toBe('number')
1570
+ }
1571
+ }
1572
+ })
1573
+
1574
+ test('대표 archetype "deepOcean" — 심해 분위기 패치 검증', () => {
1575
+ const r = executeReadTool(
1576
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
1577
+ null,
1578
+ []
1579
+ )
1580
+ const deep = r.archetypes.surreal.deepOcean
1581
+ expect(deep.patch.sky).toBe('color')
1582
+ expect(deep.patch.skyColor).toMatch(/^#[0-9a-fA-F]{6}$/)
1583
+ expect(deep.patch.exposure).toBeLessThan(1.0) // dim
1584
+ expect(deep.patch.hemiIntensity).toBeLessThan(3)
1585
+ expect(deep.patch.dirLightEnabled).toBe(true)
1586
+ })
1587
+
1588
+ test('인자 무시 (인자 없음 — 빈 객체 OK)', () => {
1589
+ const r = executeReadTool(
1590
+ { id: '1', name: 'getAtmosphereGuide', arguments: { ignored: 'whatever' } } as any,
1591
+ null,
1592
+ []
1593
+ )
1594
+ expect(r).toHaveProperty('overview')
1595
+ })
1596
+
1597
+ test('isReadTool 가 getAtmosphereGuide 를 read 로 분류', () => {
1598
+ expect(isReadTool('getAtmosphereGuide')).toBe(true)
1599
+ expect(isWriteTool('getAtmosphereGuide')).toBe(false)
1600
+ })
1601
+
1602
+ test('getAtmosphereGuide tool 정의의 인자 schema 가 비어 있음 (무인자)', () => {
1603
+ const tools = buildBoardEditTools([])
1604
+ const t = tools.find(x => x.name === 'getAtmosphereGuide')!
1605
+ expect(t.parameters.properties).toEqual({})
1606
+ })
1607
+ })
1608
+
1609
+ describe('agentic loop (chatViaTools) — mock provider', () => {
1610
+ // Mock AIClient — chatWithTools 호출을 record + 미리 정의된 응답 큐에서 반환.
1611
+ function makeMockClient(turns: Array<{ text?: string; toolCalls: any[]; stopReason?: string }>) {
1612
+ let turnIdx = 0
1613
+ const callLog: any[] = []
1614
+ return {
1615
+ client: {
1616
+ id: 'mock:test',
1617
+ chat: async () => '',
1618
+ generateJSON: async () => ({}),
1619
+ chatWithTools: async (messages: any[], _tools: any[], _options: any) => {
1620
+ callLog.push({ messages: JSON.parse(JSON.stringify(messages)) })
1621
+ const turn = turns[turnIdx++] ?? { toolCalls: [], stopReason: 'end_turn' }
1622
+ return {
1623
+ text: turn.text,
1624
+ toolCalls: turn.toolCalls,
1625
+ stopReason: turn.stopReason ?? (turn.toolCalls.length > 0 ? 'tool_use' : 'end_turn')
1626
+ }
1627
+ }
1628
+ } as any,
1629
+ callLog,
1630
+ remainingTurns: () => turns.length - turnIdx
1631
+ }
1632
+ }
1633
+
1634
+ test('text-only 응답 — single turn 으로 종료', async () => {
1635
+ const { client, callLog } = makeMockClient([
1636
+ { text: '안녕하세요. 무엇을 도와드릴까요?', toolCalls: [] }
1637
+ ])
1638
+ const a = new DefaultBoardAIAssistant(client)
1639
+ const r = await a.chat([{ role: 'user', content: '안녕' }], undefined)
1640
+ expect(r.reply).toBe('안녕하세요. 무엇을 도와드릴까요?')
1641
+ expect(r.patch).toBeUndefined()
1642
+ expect(callLog).toHaveLength(1)
1643
+ })
1644
+
1645
+ test('write tool 호출 → patch 누적, 후속 turn 종료', async () => {
1646
+ const { client, callLog } = makeMockClient([
1647
+ {
1648
+ toolCalls: [
1649
+ {
1650
+ id: 'c1',
1651
+ name: 'addComponent',
1652
+ arguments: { refid: 9, type: 'rect', left: 10, top: 10, width: 50, height: 50 }
1653
+ }
1654
+ ]
1655
+ },
1656
+ { text: 'AGV 추가했습니다.', toolCalls: [] }
1657
+ ])
1658
+ const a = new DefaultBoardAIAssistant(client)
1659
+ const r = await a.chat([{ role: 'user', content: 'AGV 추가' }], undefined)
1660
+ expect(r.reply).toBe('AGV 추가했습니다.')
1661
+ expect(r.patch?.ops).toHaveLength(1)
1662
+ expect(r.patch?.ops[0]).toMatchObject({
1663
+ op: 'add',
1664
+ component: { refid: 9, type: 'rect', left: 10, top: 10 }
1665
+ })
1666
+ expect(callLog).toHaveLength(2)
1667
+ })
1668
+
1669
+ test('read tool 호출 → 서버 실행 결과 회신 → 후속 답변 (refid 기반)', async () => {
1670
+ const board = {
1671
+ width: 1000,
1672
+ height: 600,
1673
+ components: [{ refid: 35, type: 'rect', name: 'AGV-1', left: 10 }]
1674
+ }
1675
+ const { client, callLog } = makeMockClient([
1676
+ {
1677
+ toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }]
1678
+ },
1679
+ { text: '선택된 컴포넌트는 AGV-1 (rect, refid=35, left=10) 입니다.', toolCalls: [] }
1680
+ ])
1681
+ const a = new DefaultBoardAIAssistant(client)
1682
+ const r = await a.chat(
1683
+ [{ role: 'user', content: '선택된 게 뭐야?' }],
1684
+ board as any,
1685
+ { selectedRefids: [35] }
1686
+ )
1687
+ expect(r.reply).toContain('AGV-1')
1688
+ expect(r.patch).toBeUndefined()
1689
+ expect(callLog).toHaveLength(2)
1690
+ const secondTurnMessages = callLog[1].messages
1691
+ const lastMsg = secondTurnMessages[secondTurnMessages.length - 1]
1692
+ expect(lastMsg.role).toBe('user')
1693
+ const toolResultPart = (lastMsg.content as any[]).find((p: any) => p.type === 'tool_result')
1694
+ expect(toolResultPart).toBeDefined()
1695
+ expect(toolResultPart.toolUseId).toBe('c1')
1696
+ const parsed = JSON.parse(toolResultPart.content)
1697
+ expect(parsed.selected[0].name).toBe('AGV-1')
1698
+ expect(parsed.selected[0].refid).toBe(35)
1699
+ })
1700
+
1701
+ test('read + write 혼합 — refid 기반 tool 이름', async () => {
1702
+ const board = {
1703
+ width: 1000,
1704
+ height: 600,
1705
+ components: [{ refid: 1, type: 'rect' }]
1706
+ }
1707
+ const { client } = makeMockClient([
1708
+ {
1709
+ toolCalls: [
1710
+ { id: 'c1', name: 'getComponentByRefid', arguments: { refid: 1 } },
1711
+ {
1712
+ id: 'c2',
1713
+ name: 'modifyComponentByRefid',
1714
+ arguments: { refid: 1, patch: { left: 100 } }
1715
+ }
1716
+ ]
1717
+ },
1718
+ { text: '확인 후 수정했습니다.', toolCalls: [] }
1719
+ ])
1720
+ const a = new DefaultBoardAIAssistant(client)
1721
+ const r = await a.chat([{ role: 'user', content: '컴포넌트 left=100' }], board as any)
1722
+ expect(r.patch?.ops).toHaveLength(1) // modify 만 patch 로 (read 는 서버 실행만)
1723
+ expect(r.patch?.ops[0]).toEqual({ op: 'modify', refid: 1, patch: { left: 100 } })
1724
+ })
1725
+
1726
+ test('iteration cap (8회) 초과 시 종료', async () => {
1727
+ const turns = Array.from({ length: 20 }, (_, i) => ({
1728
+ toolCalls: [{ id: `c${i}`, name: 'getSelection', arguments: {} }]
1729
+ }))
1730
+ const { client, callLog } = makeMockClient(turns)
1731
+ const a = new DefaultBoardAIAssistant(client)
1732
+ const r = await a.chat([{ role: 'user', content: '?' }], undefined)
1733
+ // 8회 호출 후 강제 종료 (loop 의 MAX_ITERATIONS)
1734
+ expect(callLog.length).toBeLessThanOrEqual(8)
1735
+ expect(r).toBeDefined()
1736
+ })
1737
+
1738
+ test('selectedRefids 비어 있어도 selection 컨텍스트는 항상 포함', async () => {
1739
+ const { client, callLog } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1740
+ const a = new DefaultBoardAIAssistant(client)
1741
+ await a.chat([{ role: 'user', content: 'q' }], undefined)
1742
+ const firstUser = callLog[0].messages[0]
1743
+ expect(firstUser.role).toBe('user')
1744
+ expect(firstUser.content).toMatch(/No component is currently selected/i)
1745
+ })
1746
+
1747
+ test('selectedRefids 가 있을 때 selection 컨텍스트가 refid 명시', async () => {
1748
+ const { client, callLog } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1749
+ const a = new DefaultBoardAIAssistant(client)
1750
+ await a.chat([{ role: 'user', content: 'q' }], undefined, { selectedRefids: [35, 37] })
1751
+ const firstUser = callLog[0].messages[0]
1752
+ expect(firstUser.content).toMatch(/User-selected component refids/i)
1753
+ expect(firstUser.content).toContain('[35,37]')
1754
+ expect(firstUser.content).toMatch(/getSelection/i)
1755
+ })
1756
+
1757
+ test('system prompt 가 id vs refid 정책을 AI 한테 명시', async () => {
1758
+ const { client, callLog } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1759
+ const a = new DefaultBoardAIAssistant(client)
1760
+ await a.chat([{ role: 'user', content: 'q' }], undefined)
1761
+ const optionsArg = callLog[0].messages
1762
+ // system prompt 는 messages 에 직접 들어가지 않고 옵션으로 전달 — 우리 mock 은
1763
+ // chatWithTools options 인자를 무시하므로 직접 검증 어려움. 대신 firstUser
1764
+ // (board context) 가 refid 단어를 포함하는지로 우회 검증.
1765
+ expect(optionsArg).toBeDefined()
1766
+ })
1767
+
1768
+ test('Gemini thoughtSignature round-trip — provider meta 를 다음 turn 으로 echo (회귀 방지)', async () => {
1769
+ // 시나리오: Gemini 가 functionCall + thoughtSignature 를 emit → 우리 어댑터가 providerMeta 에 캐치
1770
+ // → assistant 가 tool_use part 에 그대로 round-trip → 다음 turn 호출의 conversation 에 보존
1771
+ // 안 하면: "Function call is missing a thought_signature in functionCall parts" 400.
1772
+ const { client, callLog } = makeMockClient([
1773
+ {
1774
+ toolCalls: [
1775
+ {
1776
+ id: 'g:replaceBoard:t1_0',
1777
+ name: 'replaceBoard',
1778
+ arguments: { components: [] },
1779
+ providerMeta: { thoughtSignature: 'SIG_ABC123' }
1780
+ }
1781
+ ]
1782
+ },
1783
+ { text: '교체했습니다.', toolCalls: [] }
1784
+ ])
1785
+ const a = new DefaultBoardAIAssistant(client)
1786
+ await a.chat([{ role: 'user', content: '보드 비워' }], undefined)
1787
+ // 두 번째 turn 의 messages 에서 assistant 메시지의 tool_use part 가 providerMeta 를 보존해야 함
1788
+ expect(callLog).toHaveLength(2)
1789
+ const secondTurnMessages = callLog[1].messages
1790
+ const assistantMsg = secondTurnMessages.find(
1791
+ (m: any) => m.role === 'assistant' && Array.isArray(m.content)
1792
+ )
1793
+ expect(assistantMsg).toBeDefined()
1794
+ const toolUsePart = (assistantMsg.content as any[]).find((p: any) => p.type === 'tool_use')
1795
+ expect(toolUsePart).toBeDefined()
1796
+ expect(toolUsePart.providerMeta).toEqual({ thoughtSignature: 'SIG_ABC123' })
1797
+ })
1798
+
1799
+ test('providerMeta 없는 tool_use 는 echo 시 providerMeta 키 자체가 없음', async () => {
1800
+ const { client, callLog } = makeMockClient([
1801
+ {
1802
+ toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }]
1803
+ },
1804
+ { text: 'ok', toolCalls: [] }
1805
+ ])
1806
+ const a = new DefaultBoardAIAssistant(client)
1807
+ await a.chat([{ role: 'user', content: 'q' }], undefined)
1808
+ const secondTurnMessages = callLog[1].messages
1809
+ const assistantMsg = secondTurnMessages.find(
1810
+ (m: any) => m.role === 'assistant' && Array.isArray(m.content)
1811
+ )
1812
+ const toolUsePart = (assistantMsg.content as any[]).find((p: any) => p.type === 'tool_use')
1813
+ expect(toolUsePart.providerMeta).toBeUndefined()
1814
+ })
1815
+
1816
+ test('toolUsages 수집 — text-only 응답에는 toolUsages undefined', async () => {
1817
+ const { client } = makeMockClient([{ text: '안녕하세요', toolCalls: [] }])
1818
+ const a = new DefaultBoardAIAssistant(client)
1819
+ const r = await a.chat([{ role: 'user', content: '안녕' }], undefined)
1820
+ expect(r.toolUsages).toBeUndefined()
1821
+ })
1822
+
1823
+ test('toolUsages 수집 — read tool 호출 시 시간순 trace + 결과 포함', async () => {
1824
+ const board = {
1825
+ width: 1000,
1826
+ height: 600,
1827
+ components: [{ refid: 35, type: 'rect', name: 'AGV-1' }]
1828
+ }
1829
+ const { client } = makeMockClient([
1830
+ {
1831
+ toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }]
1832
+ },
1833
+ { text: 'AGV-1 입니다', toolCalls: [] }
1834
+ ])
1835
+ const a = new DefaultBoardAIAssistant(client)
1836
+ const r = await a.chat([{ role: 'user', content: '뭐가 선택?' }], board as any, {
1837
+ selectedRefids: [35]
1838
+ })
1839
+ expect(r.toolUsages).toBeDefined()
1840
+ expect(r.toolUsages).toHaveLength(1)
1841
+ const u = r.toolUsages![0]
1842
+ expect(u.name).toBe('getSelection')
1843
+ expect(u.kind).toBe('read')
1844
+ expect(u.arguments).toEqual({})
1845
+ // result 는 summarized 객체
1846
+ expect(u.result.count).toBe(1)
1847
+ expect(u.result.selected[0].name).toBe('AGV-1')
1848
+ })
1849
+
1850
+ test('toolUsages 수집 — write tool 은 queued 마커', async () => {
1851
+ const { client } = makeMockClient([
1852
+ {
1853
+ toolCalls: [
1854
+ {
1855
+ id: 'c1',
1856
+ name: 'addComponent',
1857
+ arguments: { refid: 9, type: 'rect', left: 10, top: 10, width: 50, height: 50 }
1858
+ }
1859
+ ]
1860
+ },
1861
+ { text: '추가', toolCalls: [] }
1862
+ ])
1863
+ const a = new DefaultBoardAIAssistant(client)
1864
+ const r = await a.chat([{ role: 'user', content: 'rect' }], undefined)
1865
+ expect(r.toolUsages).toHaveLength(1)
1866
+ const u = r.toolUsages![0]
1867
+ expect(u.name).toBe('addComponent')
1868
+ expect(u.kind).toBe('write')
1869
+ expect(u.result.queued).toBe(true)
1870
+ })
1871
+
1872
+ test('toolUsages 수집 — read + write 혼합 시간순 보존', async () => {
1873
+ const board = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
1874
+ const { client } = makeMockClient([
1875
+ {
1876
+ toolCalls: [
1877
+ { id: 'c1', name: 'getComponentByRefid', arguments: { refid: 1 } },
1878
+ { id: 'c2', name: 'modifyComponentByRefid', arguments: { refid: 1, patch: { left: 100 } } }
1879
+ ]
1880
+ },
1881
+ { text: 'done', toolCalls: [] }
1882
+ ])
1883
+ const a = new DefaultBoardAIAssistant(client)
1884
+ const r = await a.chat([{ role: 'user', content: 'do' }], board as any)
1885
+ expect(r.toolUsages).toHaveLength(2)
1886
+ expect(r.toolUsages![0].name).toBe('getComponentByRefid')
1887
+ expect(r.toolUsages![0].kind).toBe('read')
1888
+ expect(r.toolUsages![1].name).toBe('modifyComponentByRefid')
1889
+ expect(r.toolUsages![1].kind).toBe('write')
1890
+ })
1891
+
1892
+ test('toolUsages 수집 — 여러 turn 에 걸친 호출 누적', async () => {
1893
+ const { client } = makeMockClient([
1894
+ { toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }] },
1895
+ { toolCalls: [{ id: 'c2', name: 'findComponents', arguments: { type: 'rect' } }] },
1896
+ { text: 'final', toolCalls: [] }
1897
+ ])
1898
+ const a = new DefaultBoardAIAssistant(client)
1899
+ const r = await a.chat([{ role: 'user', content: 'q' }], undefined)
1900
+ expect(r.toolUsages).toHaveLength(2)
1901
+ expect(r.toolUsages!.map(u => u.name)).toEqual(['getSelection', 'findComponents'])
1902
+ })
1903
+
1904
+ test('toolUsages 수집 — alkown tool 은 unknown 으로 분류 + isError', async () => {
1905
+ const { client } = makeMockClient([
1906
+ { toolCalls: [{ id: 'c1', name: 'foo', arguments: {} }] },
1907
+ { text: 'err', toolCalls: [] }
1908
+ ])
1909
+ const a = new DefaultBoardAIAssistant(client)
1910
+ const r = await a.chat([{ role: 'user', content: 'x' }], undefined)
1911
+ expect(r.toolUsages).toHaveLength(1)
1912
+ expect(r.toolUsages![0].kind).toBe('unknown')
1913
+ expect(r.toolUsages![0].result.error).toMatch(/unknown/i)
1914
+ })
1915
+
1916
+ test('actions 수집 — selectComponents 호출 시 ChatResponse.actions 누적', async () => {
1917
+ const { client } = makeMockClient([
1918
+ {
1919
+ toolCalls: [
1920
+ {
1921
+ id: 'c1',
1922
+ name: 'selectComponents',
1923
+ arguments: { refids: [3, 5] }
1924
+ }
1925
+ ]
1926
+ },
1927
+ { text: '선택했습니다', toolCalls: [] }
1928
+ ])
1929
+ const a = new DefaultBoardAIAssistant(client)
1930
+ const r = await a.chat([{ role: 'user', content: '3, 5 선택' }], undefined)
1931
+ expect(r.actions).toBeDefined()
1932
+ expect(r.actions).toHaveLength(1)
1933
+ expect(r.actions![0]).toEqual({ action: 'selectComponents', refids: [3, 5] })
1934
+ // patch 는 비어 있어야 — action 은 model 변경 X
1935
+ expect(r.patch).toBeUndefined()
1936
+ })
1937
+
1938
+ test('actions 수집 — 다중 action 시퀀스 (centerTo + select + fit)', async () => {
1939
+ const { client } = makeMockClient([
1940
+ {
1941
+ toolCalls: [
1942
+ { id: 'c1', name: 'centerToComponent', arguments: { refid: 7 } },
1943
+ { id: 'c2', name: 'selectComponents', arguments: { refids: [7] } },
1944
+ { id: 'c3', name: 'fitToView', arguments: {} }
1945
+ ]
1946
+ },
1947
+ { text: 'ok', toolCalls: [] }
1948
+ ])
1949
+ const a = new DefaultBoardAIAssistant(client)
1950
+ const r = await a.chat([{ role: 'user', content: '7 보여줘' }], undefined)
1951
+ expect(r.actions).toHaveLength(3)
1952
+ expect(r.actions!.map(a => a.action)).toEqual([
1953
+ 'centerToComponent',
1954
+ 'selectComponents',
1955
+ 'fitToView'
1956
+ ])
1957
+ })
1958
+
1959
+ test('actions 수집 — write 와 action 혼합 (modify + setSceneMode)', async () => {
1960
+ const board = {
1961
+ width: 1000,
1962
+ height: 600,
1963
+ components: [{ refid: 1, type: 'rect' }]
1964
+ }
1965
+ const { client } = makeMockClient([
1966
+ {
1967
+ toolCalls: [
1968
+ {
1969
+ id: 'c1',
1970
+ name: 'modifyComponentByRefid',
1971
+ arguments: { refid: 1, patch: { left: 100 } }
1972
+ },
1973
+ { id: 'c2', name: 'setSceneMode', arguments: { mode: 'view' } }
1974
+ ]
1975
+ },
1976
+ { text: 'done', toolCalls: [] }
1977
+ ])
1978
+ const a = new DefaultBoardAIAssistant(client)
1979
+ const r = await a.chat([{ role: 'user', content: '바꾸고 뷰모드로' }], board as any)
1980
+ // modify 는 patch.ops, setSceneMode 는 actions
1981
+ expect(r.patch?.ops).toHaveLength(1)
1982
+ expect(r.patch?.ops[0]).toEqual({ op: 'modify', refid: 1, patch: { left: 100 } })
1983
+ expect(r.actions).toHaveLength(1)
1984
+ expect(r.actions![0]).toEqual({ action: 'setSceneMode', mode: 'view' })
1985
+ })
1986
+
1987
+ test('actions 미발생 시 ChatResponse.actions 는 undefined', async () => {
1988
+ const { client } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1989
+ const a = new DefaultBoardAIAssistant(client)
1990
+ const r = await a.chat([{ role: 'user', content: '안녕' }], undefined)
1991
+ expect(r.actions).toBeUndefined()
1992
+ })
1993
+ })
1994
+
1995
+ describe('summarizeToolResult — UI 영속용 결과 압축', () => {
1996
+ test('짧은 string / number / boolean 그대로', () => {
1997
+ expect(summarizeToolResult('hello')).toBe('hello')
1998
+ expect(summarizeToolResult(42)).toBe(42)
1999
+ expect(summarizeToolResult(true)).toBe(true)
2000
+ expect(summarizeToolResult(null)).toBeNull()
2001
+ })
2002
+
2003
+ test('200자 초과 string truncate + 마커', () => {
2004
+ const long = 'x'.repeat(500)
2005
+ const r = summarizeToolResult(long)
2006
+ expect(r).toMatch(/…\[\+\d+ chars\]$/)
2007
+ expect(r.length).toBeLessThan(long.length)
2008
+ })
2009
+
2010
+ test('5개 초과 array truncate + 마커', () => {
2011
+ const big = Array.from({ length: 12 }, (_, i) => i)
2012
+ const r = summarizeToolResult(big)
2013
+ expect(r.length).toBe(6) // 5 + marker
2014
+ expect(r[5]).toMatch(/items\]/)
2015
+ })
2016
+
2017
+ test('object 의 nested 값 재귀 요약', () => {
2018
+ const o = { a: 1, b: { c: 'x'.repeat(500) } }
2019
+ const r = summarizeToolResult(o)
2020
+ expect(r.a).toBe(1)
2021
+ expect(r.b.c).toMatch(/…/)
2022
+ })
2023
+
2024
+ test('depth 3 초과 object 는 키 목록 string 으로 압축', () => {
2025
+ const deep = { l1: { l2: { l3: { l4: { x: 1, y: 2, z: 3 } } } } }
2026
+ const r = summarizeToolResult(deep)
2027
+ // l1 (d0) → l2 (d1) → l3 (d2) 까지 object 유지, l3 의 값은 d3 진입 → string
2028
+ expect(typeof r.l1).toBe('object')
2029
+ expect(typeof r.l1.l2).toBe('object')
2030
+ // l3 자체가 d3 에서 평가되어 키 목록 문자열로 압축됨
2031
+ expect(typeof r.l1.l2.l3).toBe('string')
2032
+ expect(r.l1.l2.l3).toMatch(/^\{l4/)
2033
+ })
2034
+ })