@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,48 @@
1
+ /**
2
+ * 협업 초대 알림 — 순수 payload 빌더.
3
+ *
4
+ * 부수효과(실제 발송)는 resolver 의 thin caller 가 `context.state.notify` 로 수행.
5
+ * 여기엔 "무엇을 보낼지"(payload) 결정 로직만 둔다 — DB·pubsub·notify 의존 없이 테스트 가능.
6
+ * (patch spine 의 board-patch-broadcast.ts 와 동일한 순수/부수효과 분리 컨벤션.)
7
+ */
8
+
9
+ /** notification-middleware 의 notifier 가 받는 인자 형태 (필요한 키만). */
10
+ export interface InviteNotification {
11
+ receivers: string[]
12
+ type: string
13
+ subject: string
14
+ title: string
15
+ body: string
16
+ url?: string
17
+ mode: 'background' | 'foreground'
18
+ }
19
+
20
+ /** 초대자 식별용 — User 엔티티에서 쓰는 필드만 (duck-typed). */
21
+ export interface Inviter {
22
+ name?: string | null
23
+ email?: string | null
24
+ }
25
+
26
+ /**
27
+ * 초대된 사용자들에게 보낼 협업 초대 알림 payload 를 만든다.
28
+ * 받는 사람이 없으면 null (= 보낼 것 없음).
29
+ */
30
+ export function buildInviteNotification(
31
+ userIds: string[],
32
+ boardId: string | undefined,
33
+ inviter: Inviter | undefined | null
34
+ ): InviteNotification | null {
35
+ if (!Array.isArray(userIds) || userIds.length === 0) return null
36
+
37
+ const who = inviter?.name || inviter?.email || 'someone'
38
+ return {
39
+ receivers: userIds,
40
+ type: 'board-collab-invite',
41
+ subject: 'info',
42
+ title: 'Board collaboration invite',
43
+ body: `${who} invited you to collaborate on a board.`,
44
+ // board 가 연결돼 있으면 모델러로 바로 가는 링크. 없으면 링크 생략.
45
+ url: boardId ? `board-modeller/${boardId}` : undefined,
46
+ mode: 'background'
47
+ }
48
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * presence.isOnline — 단위 테스트. lastSeenAt 기반 online 판정(시각 주입, 순수).
3
+ */
4
+ import { isOnline, PRESENCE_TTL_MS } from './presence'
5
+
6
+ const NOW = 1_000_000_000_000 // 고정 기준 시각(ms)
7
+
8
+ describe('isOnline', () => {
9
+ it('ttl 이내 → online', () => {
10
+ expect(isOnline(new Date(NOW - 10_000), NOW)).toBe(true)
11
+ })
12
+
13
+ it('ttl 경계 — 정확히 ttl 은 online, 초과는 offline', () => {
14
+ expect(isOnline(new Date(NOW - PRESENCE_TTL_MS), NOW)).toBe(true)
15
+ expect(isOnline(new Date(NOW - PRESENCE_TTL_MS - 1), NOW)).toBe(false)
16
+ })
17
+
18
+ it('lastSeenAt 없음 → offline', () => {
19
+ expect(isOnline(null, NOW)).toBe(false)
20
+ expect(isOnline(undefined, NOW)).toBe(false)
21
+ })
22
+
23
+ it('잘못된 값 → offline', () => {
24
+ expect(isOnline('not-a-date', NOW)).toBe(false)
25
+ })
26
+
27
+ it('ISO 문자열도 수용', () => {
28
+ expect(isOnline(new Date(NOW - 5_000).toISOString(), NOW)).toBe(true)
29
+ })
30
+
31
+ it('미래 시각(클럭 스큐) → online 으로 관대', () => {
32
+ expect(isOnline(new Date(NOW + 60_000), NOW)).toBe(true)
33
+ })
34
+
35
+ it('커스텀 ttl', () => {
36
+ expect(isOnline(new Date(NOW - 20_000), NOW, 10_000)).toBe(false)
37
+ expect(isOnline(new Date(NOW - 5_000), NOW, 10_000)).toBe(true)
38
+ })
39
+ })
@@ -0,0 +1,21 @@
1
+ /*
2
+ * presence — lastSeenAt 기반 online 판정 (Phase Board-AI C4). 순수·결정적 → 테스트 가능.
3
+ * 클라이언트가 주기적 heartbeat(touchPresence)로 lastSeenAt 을 갱신하고, roster 는
4
+ * 이 판정으로 online/offline 표시. 시각(now)을 주입받아 시간 의존성을 제거.
5
+ */
6
+
7
+ /** online 으로 간주할 기본 유예(ms) — heartbeat 주기의 ~2.5배 권장(주기 30s → 75s). */
8
+ export const PRESENCE_TTL_MS = 75_000
9
+
10
+ /** lastSeenAt 이 now 기준 ttl 이내면 online. lastSeenAt 없으면 offline. */
11
+ export function isOnline(
12
+ lastSeenAt: Date | string | null | undefined,
13
+ now: number,
14
+ ttlMs: number = PRESENCE_TTL_MS
15
+ ): boolean {
16
+ if (!lastSeenAt) return false
17
+ const t = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime()
18
+ if (!Number.isFinite(t)) return false
19
+ const age = now - t
20
+ return age >= 0 ? age <= ttlMs : true // 미래 시각(클럭 스큐)은 online 으로 관대 처리
21
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * ChatSession lifecycle resolver — start/get/recordDirectPatch/revertPatch + 메시지·패치 페이징 query.
3
+ *
4
+ * 핵심: `recordDirectPatch` — 사용자가 모델러에서 직접 편집 시 클라이언트가 호출.
5
+ * 이게 PatchEntry(source:'user-direct') + system 메시지 자동 추가 → AI 가 모든 변경을 인지.
6
+ */
7
+ import { Resolver, Query, Mutation, Arg, Ctx, Directive, Int } from 'type-graphql'
8
+ import { GraphQLJSON } from 'graphql-scalars'
9
+ import { User } from '@things-factory/auth-base'
10
+ import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
11
+
12
+ import { getRepository } from '@things-factory/shell'
13
+
14
+ import { ChatSession } from './chat-session/chat-session.js'
15
+ import { ChatMessage } from './chat-message/chat-message.js'
16
+ import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
17
+ import { PatchEntry } from './patch-entry/patch-entry.js'
18
+ import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
19
+ import { publishChatMessage } from './chat-message/chat-message-publish.js'
20
+
21
+ @Resolver()
22
+ export class ChatSessionResolver {
23
+ @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
24
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
25
+ async chatSession(
26
+ @Arg('id') id: string,
27
+ @Ctx() context: ResolverContext
28
+ ): Promise<ChatSession | null> {
29
+ const { domain } = context.state
30
+ return (await getRepository(ChatSession).findOneBy({ id, domain: { id: domain.id } })) ?? null
31
+ }
32
+
33
+ @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by board id (returns first match — backward compat single-session lookup).' })
34
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
35
+ async chatSessionByBoard(
36
+ @Arg('boardId') boardId: string,
37
+ @Ctx() context: ResolverContext
38
+ ): Promise<ChatSession | null> {
39
+ const { domain } = context.state
40
+ return (
41
+ (await getRepository(ChatSession).findOneBy({ boardId, domain: { id: domain.id } })) ?? null
42
+ )
43
+ }
44
+
45
+ @Query(() => [ChatSession], {
46
+ description: 'List all AI chat sessions for a board (oldest first). Multi-session support — UI 탭으로 표시.'
47
+ })
48
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
49
+ async chatSessionsByBoard(
50
+ @Arg('boardId') boardId: string,
51
+ @Ctx() context: ResolverContext
52
+ ): Promise<ChatSession[]> {
53
+ const { domain } = context.state
54
+ return await getRepository(ChatSession).find({
55
+ where: { boardId, domain: { id: domain.id } },
56
+ order: { createdAt: 'ASC' }
57
+ })
58
+ }
59
+
60
+ @Query(() => [User], {
61
+ description:
62
+ 'List users in the current domain for `@` mention popup. board-ai 권한이면 누구나 멘션용 검색 가능 (관리자 전용 users() 와 별도).'
63
+ })
64
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
65
+ async domainUsersForMention(
66
+ @Arg('query', { nullable: true, description: 'Substring to match against name/email (case-insensitive). Empty → all.' }) query: string | undefined,
67
+ @Arg('limit', () => Int, { nullable: true, defaultValue: 50 }) limit: number,
68
+ @Ctx() context: ResolverContext
69
+ ): Promise<User[]> {
70
+ const { domain } = context.state
71
+ const qb = buildDomainUsersQueryBuilder(domain.id, 'USER')
72
+ if (query?.trim()) {
73
+ const q = `%${query.trim().toLowerCase()}%`
74
+ qb.andWhere('(LOWER(USER.name) LIKE :q OR LOWER(USER.email) LIKE :q)', { q })
75
+ }
76
+ qb.orderBy('USER.name', 'ASC').limit(limit)
77
+ return await qb.getMany()
78
+ }
79
+
80
+ @Query(() => [ChatSessionParticipant], {
81
+ description: 'List participants of a ChatSession (members / owner).'
82
+ })
83
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
84
+ async chatSessionParticipants(
85
+ @Arg('sessionId') sessionId: string,
86
+ @Ctx() context: ResolverContext
87
+ ): Promise<ChatSessionParticipant[]> {
88
+ const { domain } = context.state
89
+ const session = await getRepository(ChatSession).findOneBy({
90
+ id: sessionId,
91
+ domain: { id: domain.id }
92
+ })
93
+ if (!session) return []
94
+ return await getRepository(ChatSessionParticipant).find({
95
+ where: { session: { id: sessionId } as any, domain: { id: domain.id } },
96
+ order: { createdAt: 'ASC' }
97
+ })
98
+ }
99
+
100
+ @Mutation(() => Boolean, {
101
+ description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
102
+ })
103
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
104
+ async touchPresence(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
105
+ const { domain, user } = context.state
106
+ const repo = getRepository(ChatSessionParticipant)
107
+ const part = await repo.findOne({
108
+ where: { session: { id: sessionId } as any, user: { id: user.id } as any, domain: { id: domain.id } as any }
109
+ })
110
+ if (!part) return false // 참여자가 아니면 heartbeat 무시 (초대 전)
111
+ await repo.update(part.id!, { lastSeenAt: new Date(), updater: user } as any)
112
+ return true
113
+ }
114
+
115
+ @Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
116
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
117
+ async chatMessages(
118
+ @Arg('sessionId') sessionId: string,
119
+ @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
120
+ @Arg('offset', () => Int, { nullable: true, defaultValue: 0 }) offset: number,
121
+ @Ctx() context: ResolverContext
122
+ ): Promise<ChatMessage[]> {
123
+ const { domain } = context.state
124
+ const session = await getRepository(ChatSession).findOneBy({
125
+ id: sessionId,
126
+ domain: { id: domain.id }
127
+ })
128
+ if (!session) return []
129
+ return await getRepository(ChatMessage).find({
130
+ where: { session: { id: sessionId } as any },
131
+ // creator: 그룹챗 발신자 표시용 (id/name). 다른 참여자 메시지에 보낸이를 보여준다.
132
+ relations: { creator: true },
133
+ order: { createdAt: 'ASC' },
134
+ take: limit,
135
+ skip: offset
136
+ })
137
+ }
138
+
139
+ @Query(() => [PatchEntry], { description: 'List patch entries of a session, newest first.' })
140
+ @Directive('@privilege(category: "board-ai", privilege: "query")')
141
+ async chatPatches(
142
+ @Arg('sessionId') sessionId: string,
143
+ @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
144
+ @Ctx() context: ResolverContext
145
+ ): Promise<PatchEntry[]> {
146
+ const { domain } = context.state
147
+ const session = await getRepository(ChatSession).findOneBy({
148
+ id: sessionId,
149
+ domain: { id: domain.id }
150
+ })
151
+ if (!session) return []
152
+ return await getRepository(PatchEntry).find({
153
+ where: { session: { id: sessionId } as any },
154
+ order: { createdAt: 'DESC' },
155
+ take: limit
156
+ })
157
+ }
158
+
159
+ @Directive('@transaction')
160
+ @Mutation(() => ChatSession, {
161
+ description: 'Start (or get existing) AI chat session for a board. Idempotent — returns first existing or creates one.'
162
+ })
163
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
164
+ async startBoardAISession(
165
+ @Arg('boardId') boardId: string,
166
+ @Ctx() context: ResolverContext
167
+ ): Promise<ChatSession> {
168
+ const { domain, user, tx } = context.state
169
+ const repo = getRepository(ChatSession, tx)
170
+
171
+ const existing = await repo.findOneBy({ boardId, domain: { id: domain.id } })
172
+ if (existing) return existing
173
+
174
+ const session = await repo.save({
175
+ domain,
176
+ boardId,
177
+ creator: user,
178
+ updater: user
179
+ } as any)
180
+ // creator 를 owner participant 로 자동 등록 — 다자간 채팅 기반.
181
+ await getRepository(ChatSessionParticipant, tx).save({
182
+ domain,
183
+ session: { id: session.id } as any,
184
+ user,
185
+ role: 'owner',
186
+ creator: user,
187
+ updater: user
188
+ } as any)
189
+ return session
190
+ }
191
+
192
+ @Directive('@transaction')
193
+ @Mutation(() => ChatSession, {
194
+ description: 'Always create a new AI chat session for a board (no idempotent reuse). For multi-session UX — 새 탭 열기.'
195
+ })
196
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
197
+ async createChatSession(
198
+ @Arg('boardId') boardId: string,
199
+ @Arg('name', { nullable: true, description: 'Optional name (defaults to auto-generated `세션 N`).' }) name: string | undefined,
200
+ @Ctx() context: ResolverContext
201
+ ): Promise<ChatSession> {
202
+ const { domain, user, tx } = context.state
203
+ const repo = getRepository(ChatSession, tx)
204
+
205
+ // 이름 미지정 시 자동 생성 — 보드의 기존 세션 수 + 1.
206
+ let finalName = name?.trim()
207
+ if (!finalName) {
208
+ const count = await repo.count({
209
+ where: { boardId, domain: { id: domain.id } }
210
+ })
211
+ finalName = `세션 ${count + 1}`
212
+ }
213
+
214
+ const session = await repo.save({
215
+ domain,
216
+ boardId,
217
+ creator: user,
218
+ updater: user,
219
+ name: finalName
220
+ } as any)
221
+ // creator 를 owner participant 로 자동 등록.
222
+ await getRepository(ChatSessionParticipant, tx).save({
223
+ domain,
224
+ session: { id: session.id } as any,
225
+ user,
226
+ role: 'owner',
227
+ creator: user,
228
+ updater: user
229
+ } as any)
230
+ return session
231
+ }
232
+
233
+ @Directive('@transaction')
234
+ @Mutation(() => Boolean, {
235
+ description: 'Rename a ChatSession (tab label).'
236
+ })
237
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
238
+ async renameChatSession(
239
+ @Arg('sessionId') sessionId: string,
240
+ @Arg('name') name: string,
241
+ @Ctx() context: ResolverContext
242
+ ): Promise<boolean> {
243
+ const { domain, user, tx } = context.state
244
+ const repo = getRepository(ChatSession, tx)
245
+ const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } })
246
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
247
+ await repo.update(sessionId, { name: name.trim() || null, updater: user } as any)
248
+ return true
249
+ }
250
+
251
+ @Directive('@transaction')
252
+ @Mutation(() => PatchEntry, {
253
+ description: 'Record a patch from user direct edit. Adds a system message so AI sees the change next turn.'
254
+ })
255
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
256
+ async recordDirectPatch(
257
+ @Arg('sessionId') sessionId: string,
258
+ @Arg('ops', () => GraphQLJSON) ops: any[],
259
+ @Arg('summary', { nullable: true }) summary: string | undefined,
260
+ @Ctx() context: ResolverContext
261
+ ): Promise<PatchEntry> {
262
+ const { domain, tx, user } = context.state
263
+ const session = await getRepository(ChatSession, tx).findOneBy({
264
+ id: sessionId,
265
+ domain: { id: domain.id }
266
+ })
267
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
268
+
269
+ const entry = await getRepository(PatchEntry, tx).save({
270
+ session: { id: session.id } as any,
271
+ source: 'user-direct',
272
+ ops: JSON.stringify(ops || []),
273
+ summary,
274
+ reverted: false
275
+ } as any)
276
+
277
+ // AI 컨텍스트 흐름 — system 메시지 자동 추가. creator = 직접 편집한 사용자.
278
+ // parentMessage = 직전 in-session 메시지 (timeline 연속성 유지 — system 도 chain 에 합류).
279
+ const previousLast = await getRepository(ChatMessage, tx).findOne({
280
+ where: { session: { id: session.id } as any },
281
+ order: { createdAt: 'DESC' }
282
+ })
283
+ const sysMsg = await getRepository(ChatMessage, tx).save({
284
+ session: { id: session.id } as any,
285
+ role: 'system',
286
+ content: `User directly edited the board: ${summary ?? `${(ops || []).length} op(s)`}`,
287
+ relatedPatchId: entry.id,
288
+ creator: user,
289
+ updater: user,
290
+ parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
291
+ } as any)
292
+
293
+ // C1: user-direct 편집도 같은 board 구독자에게 방송 (AI·타참여자 실시간 반영). best-effort.
294
+ publishBoardPatch({ domainId: domain.id, boardId: session.boardId, patch: entry })
295
+ // C4: 직접편집 system 메시지를 같은 세션 구독자에게 방송 (transcript 실시간 일관성). best-effort.
296
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
297
+
298
+ return entry
299
+ }
300
+
301
+ @Directive('@transaction')
302
+ @Mutation(() => Boolean, { description: 'Mark a patch as reverted (does not undo, only flags).' })
303
+ @Directive('@privilege(category: "board-ai", privilege: "mutation")')
304
+ async revertPatch(
305
+ @Arg('patchId') patchId: string,
306
+ @Ctx() context: ResolverContext
307
+ ): Promise<boolean> {
308
+ const { tx } = context.state
309
+ await getRepository(PatchEntry, tx).update(patchId, { reverted: true })
310
+ return true
311
+ }
312
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * component-tree — collectAllComponents / findComponentByRefid 단위 테스트.
3
+ */
4
+ import { collectAllComponents, findComponentByRefid } from './component-tree'
5
+
6
+ const flat = (board: any) =>
7
+ collectAllComponents(board).map(f => ({
8
+ refid: f.refid,
9
+ parent: f.parentRefid,
10
+ depth: f.depth,
11
+ type: (f.component as any).type
12
+ }))
13
+
14
+ describe('collectAllComponents — 평면화', () => {
15
+ test('빈 보드 — 빈 배열', () => {
16
+ expect(collectAllComponents({ width: 100, height: 100, components: [] })).toEqual([])
17
+ })
18
+
19
+ test('null/undefined 입력 — 빈 배열', () => {
20
+ expect(collectAllComponents(undefined)).toEqual([])
21
+ expect(collectAllComponents(null as any)).toEqual([])
22
+ })
23
+
24
+ test('components 키 없음 — 빈 배열', () => {
25
+ expect(collectAllComponents({ width: 100, height: 100 } as any)).toEqual([])
26
+ })
27
+
28
+ test('top-level 만 — 그대로 평면', () => {
29
+ const board = {
30
+ width: 100,
31
+ height: 100,
32
+ components: [
33
+ { type: 'rect', refid: 1 },
34
+ { type: 'circle', refid: 2 },
35
+ { type: 'text', refid: 3 }
36
+ ]
37
+ }
38
+ expect(flat(board)).toEqual([
39
+ { refid: 1, parent: undefined, depth: 0, type: 'rect' },
40
+ { refid: 2, parent: undefined, depth: 0, type: 'circle' },
41
+ { refid: 3, parent: undefined, depth: 0, type: 'text' }
42
+ ])
43
+ })
44
+
45
+ test('group 1단계 — 자식 노출 + parentRefid 표시', () => {
46
+ const board = {
47
+ width: 100,
48
+ height: 100,
49
+ components: [
50
+ { type: 'rect', refid: 1 },
51
+ {
52
+ type: 'group',
53
+ refid: 2,
54
+ components: [
55
+ { type: 'rect', refid: 3 },
56
+ { type: 'circle', refid: 4 }
57
+ ]
58
+ }
59
+ ]
60
+ }
61
+ expect(flat(board)).toEqual([
62
+ { refid: 1, parent: undefined, depth: 0, type: 'rect' },
63
+ { refid: 2, parent: undefined, depth: 0, type: 'group' },
64
+ { refid: 3, parent: 2, depth: 1, type: 'rect' },
65
+ { refid: 4, parent: 2, depth: 1, type: 'circle' }
66
+ ])
67
+ })
68
+
69
+ test('group 안 group (2단계) — 깊은 재귀', () => {
70
+ const board = {
71
+ width: 100,
72
+ height: 100,
73
+ components: [
74
+ {
75
+ type: 'group',
76
+ refid: 1,
77
+ components: [
78
+ { type: 'rect', refid: 2 },
79
+ {
80
+ type: 'group',
81
+ refid: 3,
82
+ components: [{ type: 'circle', refid: 4 }]
83
+ }
84
+ ]
85
+ }
86
+ ]
87
+ }
88
+ expect(flat(board)).toEqual([
89
+ { refid: 1, parent: undefined, depth: 0, type: 'group' },
90
+ { refid: 2, parent: 1, depth: 1, type: 'rect' },
91
+ { refid: 3, parent: 1, depth: 1, type: 'group' },
92
+ { refid: 4, parent: 3, depth: 2, type: 'circle' }
93
+ ])
94
+ })
95
+
96
+ test('빈 group — group 자체만 + 자식 0', () => {
97
+ const board = {
98
+ width: 100,
99
+ height: 100,
100
+ components: [{ type: 'group', refid: 1, components: [] }]
101
+ }
102
+ expect(flat(board)).toEqual([
103
+ { refid: 1, parent: undefined, depth: 0, type: 'group' }
104
+ ])
105
+ })
106
+
107
+ test('refid 없는 컴포넌트 — undefined refid', () => {
108
+ const board = {
109
+ width: 100,
110
+ height: 100,
111
+ components: [{ type: 'rect' /* no refid */ }]
112
+ }
113
+ const r = collectAllComponents(board as any)
114
+ expect(r).toHaveLength(1)
115
+ expect(r[0].refid).toBeUndefined()
116
+ })
117
+
118
+ test('비-object 컴포넌트 (null) — 건너뜀', () => {
119
+ const board = {
120
+ width: 100,
121
+ height: 100,
122
+ components: [{ type: 'rect', refid: 1 }, null, { type: 'circle', refid: 2 }]
123
+ }
124
+ expect(flat(board as any)).toEqual([
125
+ { refid: 1, parent: undefined, depth: 0, type: 'rect' },
126
+ { refid: 2, parent: undefined, depth: 0, type: 'circle' }
127
+ ])
128
+ })
129
+
130
+ test('path 정보 — root 부터 본 컴포넌트까지', () => {
131
+ const board = {
132
+ width: 100,
133
+ height: 100,
134
+ components: [
135
+ {
136
+ type: 'group',
137
+ refid: 1,
138
+ components: [
139
+ {
140
+ type: 'group',
141
+ refid: 2,
142
+ components: [{ type: 'rect', refid: 3 }]
143
+ }
144
+ ]
145
+ }
146
+ ]
147
+ }
148
+ const r = collectAllComponents(board)
149
+ expect(r.find(f => f.refid === 3)?.path).toEqual([1, 2, 3])
150
+ expect(r.find(f => f.refid === 1)?.path).toEqual([1])
151
+ })
152
+
153
+ test('maxDepth — 깊이 cap', () => {
154
+ // 4단계 중첩
155
+ const board: any = {
156
+ width: 100,
157
+ height: 100,
158
+ components: [
159
+ {
160
+ refid: 1,
161
+ components: [
162
+ {
163
+ refid: 2,
164
+ components: [
165
+ {
166
+ refid: 3,
167
+ components: [{ refid: 4 }]
168
+ }
169
+ ]
170
+ }
171
+ ]
172
+ }
173
+ ]
174
+ }
175
+ // depth 2 cap → refid 1, 2, 3 까지만 (depth 0, 1, 2)
176
+ const capped = collectAllComponents(board, { maxDepth: 2 })
177
+ expect(capped.map(f => f.refid)).toEqual([1, 2, 3])
178
+ })
179
+ })
180
+
181
+ describe('findComponentByRefid — deep search', () => {
182
+ const board = {
183
+ width: 100,
184
+ height: 100,
185
+ components: [
186
+ { type: 'rect', refid: 1 },
187
+ {
188
+ type: 'group',
189
+ refid: 2,
190
+ components: [
191
+ { type: 'rect', refid: 3 },
192
+ {
193
+ type: 'group',
194
+ refid: 4,
195
+ components: [{ type: 'circle', refid: 5 }]
196
+ }
197
+ ]
198
+ }
199
+ ]
200
+ }
201
+
202
+ test('top-level refid — 찾음', () => {
203
+ const r = findComponentByRefid(board, 1)
204
+ expect(r).toBeDefined()
205
+ expect(r!.component.refid).toBe(1)
206
+ expect(r!.parentArray).toBe(board.components)
207
+ expect(r!.index).toBe(0)
208
+ })
209
+
210
+ test('group 안 자식 (1단계) — 찾음', () => {
211
+ const r = findComponentByRefid(board, 3)
212
+ expect(r).toBeDefined()
213
+ expect(r!.component.refid).toBe(3)
214
+ expect(r!.parentArray).toBe(board.components[1].components)
215
+ expect(r!.index).toBe(0)
216
+ })
217
+
218
+ test('group 안 group 안 자식 (2단계) — 찾음', () => {
219
+ const r = findComponentByRefid(board, 5)
220
+ expect(r).toBeDefined()
221
+ expect(r!.component.refid).toBe(5)
222
+ expect(r!.parentArray).toBe(board.components[1].components![1].components)
223
+ })
224
+
225
+ test('group 자체 refid — 찾음', () => {
226
+ const r = findComponentByRefid(board, 4)
227
+ expect(r).toBeDefined()
228
+ expect(r!.component.type).toBe('group')
229
+ })
230
+
231
+ test('없는 refid — undefined', () => {
232
+ expect(findComponentByRefid(board, 999)).toBeUndefined()
233
+ })
234
+
235
+ test('null/undefined board — undefined', () => {
236
+ expect(findComponentByRefid(undefined, 1)).toBeUndefined()
237
+ expect(findComponentByRefid(null as any, 1)).toBeUndefined()
238
+ })
239
+
240
+ test('parent array 참조로 in-place mutation 가능', () => {
241
+ const local = JSON.parse(JSON.stringify(board))
242
+ const found = findComponentByRefid(local, 3)!
243
+ // index 위치를 새 컴포넌트로 교체
244
+ found.parentArray[found.index] = { type: 'rect', refid: 3, mutated: true } as any
245
+ // 실제 보드의 group 자식이 바뀌어야
246
+ expect(local.components[1].components[0].mutated).toBe(true)
247
+ })
248
+ })