@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,273 @@
1
+ /**
2
+ * mention-resolver.ts 단위 테스트.
3
+ *
4
+ * 핵심 정책 (things-factory 식별자 컨벤션):
5
+ * - refid 는 사용자 답변 / 본문 노출 금지 — 시스템 내부 핸들로만
6
+ * - # 는 와이드 셀렉터 — name / id / type OR 매칭
7
+ * - 본문 #token 은 LITERAL 보존, 매칭 결과는 끝의 mention map 블록으로
8
+ */
9
+ import {
10
+ resolveMentions,
11
+ findMentions,
12
+ lookupComponents
13
+ } from './mention-resolver'
14
+
15
+ const make = (components: any[] = []) => ({ width: 1000, height: 600, components })
16
+
17
+ describe('lookupComponents — 와이드 검색 (name / id / type OR)', () => {
18
+ const components = [
19
+ { refid: 1, type: 'rect', name: '경광등1', id: 'motor-1' },
20
+ { refid: 2, type: 'rect', name: '경광등2', id: 'motor-2' },
21
+ { refid: 3, type: 'circle', name: '센서', id: 'sensor-A' },
22
+ { refid: 4, type: 'rect' } // name/id 없음
23
+ ]
24
+
25
+ test('name 정확 일치', () => {
26
+ const r = lookupComponents('경광등1', components)
27
+ expect(r).toHaveLength(1)
28
+ expect(r[0].refid).toBe(1)
29
+ })
30
+
31
+ test('id 정확 일치', () => {
32
+ const r = lookupComponents('motor-1', components)
33
+ expect(r).toHaveLength(1)
34
+ expect(r[0].refid).toBe(1)
35
+ expect(r[0].id).toBe('motor-1')
36
+ })
37
+
38
+ test('type 정확 일치 — 같은 type 의 모든 컴포넌트', () => {
39
+ const r = lookupComponents('rect', components)
40
+ expect(r).toHaveLength(3)
41
+ expect(r.map(c => c.refid).sort()).toEqual([1, 2, 4])
42
+ })
43
+
44
+ test('case-insensitive (영문)', () => {
45
+ expect(lookupComponents('MOTOR-1', components)).toHaveLength(1)
46
+ expect(lookupComponents('Rect', components)).toHaveLength(3)
47
+ })
48
+
49
+ test('한글 case-insensitive 안전 (대소문자 개념 없지만 동작 확인)', () => {
50
+ expect(lookupComponents('경광등1', components)).toHaveLength(1)
51
+ })
52
+
53
+ test('매칭 없음 → []', () => {
54
+ expect(lookupComponents('no-such', components)).toEqual([])
55
+ })
56
+
57
+ test('빈 토큰 / 빈 components → []', () => {
58
+ expect(lookupComponents('', components)).toEqual([])
59
+ expect(lookupComponents('rect', [])).toEqual([])
60
+ })
61
+
62
+ test('매칭된 컴포넌트의 핵심 식별자 반환 (refid + name + id + type)', () => {
63
+ const r = lookupComponents('motor-1', components)
64
+ expect(r[0]).toEqual({
65
+ refid: 1,
66
+ type: 'rect',
67
+ name: '경광등1',
68
+ id: 'motor-1'
69
+ })
70
+ })
71
+
72
+ test('동일 refid 두 기준 일치 시 중복 안 됨 (dedup)', () => {
73
+ // name 'rect' + type 'rect' — 인위적 케이스
74
+ const cs = [{ refid: 1, type: 'rect', name: 'rect' }]
75
+ const r = lookupComponents('rect', cs)
76
+ expect(r).toHaveLength(1) // 중복 X
77
+ })
78
+ })
79
+
80
+ describe('resolveMentions — 본문 보존 + mention map 첨부', () => {
81
+ test('본문은 LITERAL 그대로, 매핑은 끝에 블록으로', () => {
82
+ const board = make([
83
+ { refid: 35, type: 'rect', name: '경광등1', id: 'motor-1' }
84
+ ])
85
+ const r = resolveMentions('#경광등1 빨갛게', board)
86
+ // 본문은 그대로 유지 — refid 본문 노출 0
87
+ expect(r).toContain('#경광등1 빨갛게')
88
+ expect(r).not.toMatch(/^경광등1\s*\(/m) // '(refid' 같은 inline 변환 없음
89
+ // mention map 블록 첨부
90
+ expect(r).toContain('--- mention map')
91
+ expect(r).toContain('#경광등1 →')
92
+ expect(r).toContain("name='경광등1'")
93
+ expect(r).toContain("id='motor-1'")
94
+ expect(r).toContain("type='rect'")
95
+ expect(r).toContain('refid=35')
96
+ })
97
+
98
+ test('매칭 0 → 변경 없음 (본문 그대로)', () => {
99
+ const board = make([{ refid: 1, name: 'A' }])
100
+ expect(resolveMentions('#NoSuch 어쩌고', board)).toBe('#NoSuch 어쩌고')
101
+ })
102
+
103
+ test('빈 components → 변경 없음', () => {
104
+ expect(resolveMentions('#anything', make([]))).toBe('#anything')
105
+ expect(resolveMentions('#anything', null)).toBe('#anything')
106
+ })
107
+
108
+ test('빈 텍스트 안전', () => {
109
+ expect(resolveMentions('', make([{ refid: 1, name: 'A' }]))).toBe('')
110
+ })
111
+
112
+ test('id 매칭 — 본문 #motor-1 그대로 + map 에 매핑', () => {
113
+ const board = make([{ refid: 5, type: 'rect', id: 'motor-1' }])
114
+ const r = resolveMentions('#motor-1 stop', board)
115
+ expect(r).toContain('#motor-1 stop')
116
+ expect(r).toContain('#motor-1 →')
117
+ expect(r).toContain("id='motor-1'")
118
+ expect(r).toContain('refid=5')
119
+ })
120
+
121
+ test('type 매칭 — 같은 type 모든 컴포넌트가 map 에 나열', () => {
122
+ const board = make([
123
+ { refid: 1, type: 'rect', name: 'A' },
124
+ { refid: 2, type: 'rect', name: 'B' },
125
+ { refid: 3, type: 'circle' }
126
+ ])
127
+ const r = resolveMentions('#rect 모두', board)
128
+ expect(r).toContain('#rect 모두') // 본문 보존
129
+ expect(r).toContain('#rect →')
130
+ expect(r).toContain('refid=1')
131
+ expect(r).toContain('refid=2')
132
+ expect(r).not.toContain('refid=3') // circle 제외
133
+ })
134
+
135
+ test('다중 #token — 모두 map 에 등록, 본문 모두 보존', () => {
136
+ const board = make([
137
+ { refid: 1, name: 'A' },
138
+ { refid: 2, name: 'B' }
139
+ ])
140
+ const r = resolveMentions('#A 와 #B', board)
141
+ expect(r).toContain('#A 와 #B')
142
+ expect(r).toContain('#A →')
143
+ expect(r).toContain('#B →')
144
+ })
145
+
146
+ test('일부 매칭 일부 실패 — 매칭 성공만 map 에', () => {
147
+ const board = make([{ refid: 1, name: 'A' }])
148
+ const r = resolveMentions('#A and #X', board)
149
+ expect(r).toContain('#A and #X') // 본문 모두 보존
150
+ expect(r).toContain('#A →')
151
+ expect(r).not.toContain('#X →') // X 매칭 실패
152
+ })
153
+
154
+ test('refid 가 본문에 등장하는 다른 사용자 텍스트는 영향 없음 (token 외 #refid 표기)', () => {
155
+ // 사용자가 어쩌다 'refid' 라고 본문에 쓴 경우는 우리가 변환 안 함
156
+ const board = make([{ refid: 1, name: 'A' }])
157
+ const r = resolveMentions('#A 의 refid 가 뭐야?', board)
158
+ expect(r).toContain('#A 의 refid 가 뭐야?')
159
+ })
160
+ })
161
+
162
+ describe('findMentions — 매칭 메타 직접 노출 (UI / 디버그)', () => {
163
+ test('성공 + 실패 모두 반환, 위치 포함', () => {
164
+ const board = make([
165
+ { refid: 1, name: 'A' },
166
+ { refid: 2, name: 'B' }
167
+ ])
168
+ const r = findMentions('#A #X #B', board)
169
+ expect(r).toHaveLength(3)
170
+ expect(r[0].token).toBe('A')
171
+ expect(r[0].matched).toHaveLength(1)
172
+ expect(r[0].matched[0].refid).toBe(1)
173
+ expect(r[1].token).toBe('X')
174
+ expect(r[1].matched).toEqual([])
175
+ expect(r[2].token).toBe('B')
176
+ expect(r[2].matched[0].refid).toBe(2)
177
+ })
178
+
179
+ test('빈 텍스트 → []', () => {
180
+ expect(findMentions('', make([]))).toEqual([])
181
+ })
182
+
183
+ test('start/end 위치 정확', () => {
184
+ const board = make([{ refid: 1, name: 'A' }])
185
+ const r = findMentions('hello #A world', board)
186
+ expect(r[0].start).toBe(6)
187
+ expect(r[0].end).toBe(8)
188
+ })
189
+ })
190
+
191
+ describe('picks (popup 명시 선택) — 결정적 매칭', () => {
192
+ test('pick 의 token + refid → 그 컴포넌트 직접 매칭 (fallback 안 거침)', () => {
193
+ const board = make([
194
+ { refid: 35, name: '경광등1', type: 'rect' },
195
+ { refid: 42, name: '경광등1', type: 'rect' } // 동명이인
196
+ ])
197
+ // picks 없으면 둘 다 매칭 (다중)
198
+ const noPicks = lookupComponents('경광등1', board.components)
199
+ expect(noPicks).toHaveLength(2)
200
+ // picks 있으면 그 refid 만
201
+ const withPicks = lookupComponents('경광등1', board.components, [
202
+ { token: '경광등1', refid: 35 }
203
+ ])
204
+ expect(withPicks).toHaveLength(1)
205
+ expect(withPicks[0].refid).toBe(35)
206
+ })
207
+
208
+ test('pick 의 refid 가 stale (보드에 없음) → fallback 으로', () => {
209
+ const board = make([{ refid: 35, name: '경광등1' }])
210
+ const r = lookupComponents('경광등1', board.components, [
211
+ { token: '경광등1', refid: 999 } // stale
212
+ ])
213
+ expect(r).toHaveLength(1)
214
+ expect(r[0].refid).toBe(35) // fallback 으로 name 매칭
215
+ })
216
+
217
+ test('pick token 이 텍스트 token 과 다르면 무시 (다른 token 의 pick)', () => {
218
+ const board = make([
219
+ { refid: 1, name: 'A' },
220
+ { refid: 2, name: 'B' }
221
+ ])
222
+ const r = lookupComponents('A', board.components, [
223
+ { token: 'B', refid: 2 } // A 가 아니라 B 의 pick
224
+ ])
225
+ expect(r).toHaveLength(1)
226
+ expect(r[0].refid).toBe(1) // A name 매칭
227
+ })
228
+
229
+ test('resolveMentions options.picks 통과', () => {
230
+ const board = make([
231
+ { refid: 35, name: '경광등1' },
232
+ { refid: 42, name: '경광등1' }
233
+ ])
234
+ const r = resolveMentions('#경광등1 빨갛게', board, {
235
+ picks: [{ token: '경광등1', refid: 42 }]
236
+ })
237
+ expect(r).toContain('refid=42')
238
+ expect(r).not.toContain('refid=35') // pick 결정적 — 35 안 옴
239
+ })
240
+
241
+ test('mention map 에 position 정보 포함 (AI 좌표 인식 보조)', () => {
242
+ const board = make([
243
+ { refid: 1, name: 'A', type: 'rect', left: 100, top: 200, width: 80, height: 40 }
244
+ ])
245
+ const r = resolveMentions('#A', board)
246
+ // 중심 좌표 (140, 220)
247
+ expect(r).toContain('pos=(140,220)')
248
+ })
249
+ })
250
+
251
+ describe('refid 정책 회귀 방지', () => {
252
+ test('"#refidNN" 같은 직접 refid 참조는 더 이상 작동 안 함 (refid 사용자 노출 금지)', () => {
253
+ const board = make([{ refid: 35, name: '경광등' }])
254
+ // 과거에는 #refid35 가 직접 참조됐지만 이제는 일반 token 으로만 처리
255
+ // 'refid35' 라는 name/id/type 가 없으면 매칭 안 됨
256
+ const r = resolveMentions('#refid35 빨갛게', board)
257
+ expect(r).toBe('#refid35 빨갛게') // 매칭 0 → 변경 없음
258
+ expect(r).not.toContain('mention map')
259
+ })
260
+
261
+ test('mention map 블록 안에서만 refid 등장 (본문에는 없음)', () => {
262
+ const board = make([{ refid: 35, name: '경광등1' }])
263
+ const r = resolveMentions('#경광등1 빨갛게', board)
264
+ // 본문 영역 (mention map 블록 전) 에는 refid 단어가 없어야
265
+ const mapStart = r.indexOf('--- mention map')
266
+ const bodyPart = r.slice(0, mapStart)
267
+ expect(bodyPart).not.toMatch(/refid/i)
268
+ // map 블록 안에는 refid 가 있다 (AI 가 tool 호출에 사용)
269
+ const mapPart = r.slice(mapStart)
270
+ expect(mapPart).toMatch(/refid=35/)
271
+ })
272
+ })
273
+
@@ -0,0 +1,264 @@
1
+ /**
2
+ * `#token` 멘션 해석 (things-factory 식별자 컨벤션 준수).
3
+ *
4
+ * things-factory 정책:
5
+ * - `#` 은 사용자가 컴포넌트를 가리키는 SELECTOR. things-scene Finder 의
6
+ * CSS 셀렉터 컨셉과 같은 맥락 — 와이드 검색 (name / id / type 셋 다 OR).
7
+ * - **refid 는 시스템 내부 핸들** — 사용자에게 노출 금지. 사용자가 본 적도
8
+ * 없는 숫자라 `(refid 35)` 같은 표기는 혼란만 가중.
9
+ * - id 는 데이터 바인딩 식별자 (human-readable). unique 제약 없음.
10
+ * - name 은 사용자 라벨. 가장 친숙.
11
+ *
12
+ * Resolver 정책:
13
+ * - 본문의 `#token` 은 LITERAL 보존 (refid 본문 노출 0)
14
+ * - 매칭 결과는 본문 끝에 구조화된 "mention map" 블록으로 첨부
15
+ * - AI 는 시스템 프롬프트의 안내에 따라 mention map 의 refid 를 tool
16
+ * 호출 인자로만 사용. 사용자 답변 텍스트에서는 name/id 로 지칭.
17
+ *
18
+ * Token 매칭 (모두 시도, OR):
19
+ * - name 정확 일치 (case-insensitive)
20
+ * - id 정확 일치 (case-insensitive)
21
+ * - type 정확 일치 (case-insensitive) — `#rect` 같이 모든 type 가리키기
22
+ *
23
+ * 부분 / fuzzy 매칭 안 함 — popup 측에서 사용자가 정확한 식별자 선택.
24
+ *
25
+ * Token 정규식: /#([^\s#]+)/g (한글/영문/하이픈/언더스코어/숫자).
26
+ */
27
+ import type { BoardModel } from '@things-factory/board-import'
28
+
29
+ const MENTION_PATTERN = /#([^\s#]+)/g
30
+
31
+ /** 매칭된 컴포넌트의 핵심 정보 — refid 는 시스템 핸들, 나머지는 사용자 친화적 식별.
32
+ * things-scene F-Finder 와 동일한 검색 필드 (id/name/type/tag/class/text)
33
+ * + position (AI 가 식별 + 조작 좌표 판단에 활용). */
34
+ export interface MentionedComponent {
35
+ refid: number
36
+ name?: string
37
+ id?: string
38
+ type: string
39
+ tag?: string
40
+ class?: string
41
+ text?: string
42
+ /** 중심 좌표 (rect: left+w/2, top+h/2 / circle: cx, cy) — 정수 반올림 */
43
+ position?: { x: number; y: number }
44
+ }
45
+
46
+ /** popup 에서 사용자가 명시 선택한 컴포넌트 — server 가 정확한 매칭에 사용. */
47
+ export interface MentionPick {
48
+ /** 사용자 텍스트의 #token 부분 (예: '경광등1') */
49
+ token: string
50
+ /** popup 에서 선택된 정확한 refid */
51
+ refid: number
52
+ }
53
+
54
+ export interface ResolveMentionsOptions {
55
+ /** popup 에서 명시 선택된 mentions — 해당 token 은 fallback 매칭 안 거치고 정확한 refid 로 즉시 해석 */
56
+ picks?: MentionPick[]
57
+ }
58
+
59
+ export interface MentionMatch {
60
+ /** 사용자가 입력한 #token 의 token 부분 */
61
+ token: string
62
+ /** 토큰 위치 (start: '#' 위치, end: 마지막 글자 다음) */
63
+ start: number
64
+ end: number
65
+ /** name/id/type 매칭으로 발견한 컴포넌트들 */
66
+ matched: MentionedComponent[]
67
+ }
68
+
69
+ /**
70
+ * 텍스트의 모든 #token 을 보드에서 매칭. UI / 디버그 / 테스트용.
71
+ */
72
+ export function findMentions(
73
+ text: string,
74
+ currentBoard: BoardModel | null | undefined,
75
+ options: ResolveMentionsOptions = {}
76
+ ): MentionMatch[] {
77
+ if (!text || typeof text !== 'string') return []
78
+ const components = currentBoard?.components ?? []
79
+ return collectMatches(text, components, options.picks)
80
+ }
81
+
82
+
83
+ /**
84
+ * Token 으로 컴포넌트 매칭 — name/id/type/tag/class/text OR (things-scene F-Finder
85
+ * 동일 필드 셋). 여러 기준에서 동시 일치하더라도 같은 refid 는 한 번만 반환 (dedup).
86
+ *
87
+ * picks 에 해당 token 이 있으면 fallback 안 거치고 그 refid 로 직접 매칭 (popup 에서
88
+ * 사용자가 명시 선택한 결정적 매핑). 그 refid 가 보드에 없으면 fallback 으로.
89
+ */
90
+ export function lookupComponents(
91
+ token: string,
92
+ components: readonly any[],
93
+ picks?: readonly MentionPick[]
94
+ ): MentionedComponent[] {
95
+ if (!token || components.length === 0) return []
96
+
97
+ // picks 우선 — popup 으로 명시 선택된 token 은 그 refid 로 직접 매칭
98
+ if (picks) {
99
+ const pick = picks.find(p => p.token === token)
100
+ if (pick) {
101
+ const c = components.find(
102
+ (x: any) => x && typeof x.refid === 'number' && x.refid === pick.refid
103
+ )
104
+ if (c) {
105
+ const fields = readSearchFields(c)
106
+ return [
107
+ {
108
+ refid: pick.refid,
109
+ name: fields.name,
110
+ id: fields.id,
111
+ type: fields.type,
112
+ tag: fields.tag,
113
+ class: fields.cls,
114
+ text: fields.text,
115
+ position: extractPosition(c)
116
+ }
117
+ ]
118
+ }
119
+ // pick 이 stale (refid 가 더 이상 보드에 없음) — fallback 으로 진행
120
+ }
121
+ }
122
+
123
+ const lower = token.toLowerCase()
124
+ const out: MentionedComponent[] = []
125
+ const seen = new Set<number>()
126
+ for (const c of components) {
127
+ if (!c || typeof (c as any).refid !== 'number') continue
128
+ const refid = (c as any).refid
129
+ if (seen.has(refid)) continue
130
+ const fields = readSearchFields(c)
131
+ if (
132
+ (fields.name && fields.name.toLowerCase() === lower) ||
133
+ (fields.id && fields.id.toLowerCase() === lower) ||
134
+ fields.type.toLowerCase() === lower ||
135
+ (fields.tag && fields.tag.toLowerCase() === lower) ||
136
+ (fields.cls && fields.cls.toLowerCase() === lower) ||
137
+ (fields.text && fields.text.toLowerCase() === lower)
138
+ ) {
139
+ seen.add(refid)
140
+ out.push({
141
+ refid,
142
+ name: fields.name,
143
+ id: fields.id,
144
+ type: fields.type,
145
+ tag: fields.tag,
146
+ class: fields.cls,
147
+ text: fields.text,
148
+ position: extractPosition(c)
149
+ })
150
+ }
151
+ }
152
+ return out
153
+ }
154
+
155
+ /** 컴포넌트 중심 좌표 — rect: left+w/2 / circle: cx,cy. */
156
+ function extractPosition(c: any): { x: number; y: number } | undefined {
157
+ if (typeof c.cx === 'number' && typeof c.cy === 'number') {
158
+ return { x: Math.round(c.cx), y: Math.round(c.cy) }
159
+ }
160
+ if (typeof c.left === 'number' && typeof c.top === 'number') {
161
+ const w = typeof c.width === 'number' ? c.width : 0
162
+ const h = typeof c.height === 'number' ? c.height : 0
163
+ return { x: Math.round(c.left + w / 2), y: Math.round(c.top + h / 2) }
164
+ }
165
+ return undefined
166
+ }
167
+
168
+ function readSearchFields(c: any): {
169
+ name?: string
170
+ id?: string
171
+ type: string
172
+ tag?: string
173
+ cls?: string
174
+ text?: string
175
+ } {
176
+ const str = (v: any): string | undefined =>
177
+ typeof v === 'string' && v.length > 0 ? v : undefined
178
+ return {
179
+ name: str(c.name),
180
+ id: str(c.id),
181
+ type: typeof c.type === 'string' ? c.type : 'unknown',
182
+ tag: str(c.tag),
183
+ cls: str(c.class),
184
+ text: str(c.text)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * 텍스트의 #token 을 LLM 친화 형태로 변환.
190
+ *
191
+ * 본문은 그대로 + 매칭 성공한 토큰의 mention map 을 끝에 첨부:
192
+ *
193
+ * "[원본 본문]
194
+ *
195
+ * --- mention map (자동 첨부 — 사용자가 # 로 지칭한 컴포넌트) ---
196
+ * #경광등1 →
197
+ * { name='경광등1', id='motor-1', type='rect', refid=35 }
198
+ * #펌프 →
199
+ * { name='펌프A', id='pump-1', type='rect', refid=42 }
200
+ * { name='펌프B', id='pump-2', type='rect', refid=51 }"
201
+ *
202
+ * AI 는 system prompt 안내에 따라 refid 를 tool 호출 인자로만 사용. 답변
203
+ * 텍스트에서는 refid 직접 언급 금지.
204
+ *
205
+ * 매칭 0 → 변경 없음 (본문 그대로 반환).
206
+ */
207
+ export function resolveMentions(
208
+ text: string,
209
+ currentBoard: BoardModel | null | undefined,
210
+ options: ResolveMentionsOptions = {}
211
+ ): string {
212
+ if (!text || typeof text !== 'string') return text
213
+ const components = currentBoard?.components ?? []
214
+ if (components.length === 0) return text
215
+
216
+ const matches = collectMatches(text, components, options.picks)
217
+ const resolved = matches.filter(m => m.matched.length > 0)
218
+ if (resolved.length === 0) return text
219
+
220
+ const block: string[] = [
221
+ '',
222
+ '--- mention map (자동 첨부 — 사용자가 # 로 지칭한 컴포넌트) ---'
223
+ ]
224
+ for (const m of resolved) {
225
+ block.push(`#${m.token} →`)
226
+ for (const c of m.matched) {
227
+ block.push(` ${formatComponentRef(c)}`)
228
+ }
229
+ }
230
+ return text + '\n' + block.join('\n')
231
+ }
232
+
233
+ function collectMatches(
234
+ text: string,
235
+ components: readonly any[],
236
+ picks?: readonly MentionPick[]
237
+ ): MentionMatch[] {
238
+ const out: MentionMatch[] = []
239
+ const pattern = new RegExp(MENTION_PATTERN.source, 'g')
240
+ let m: RegExpExecArray | null
241
+ while ((m = pattern.exec(text)) !== null) {
242
+ const token = m[1]
243
+ out.push({
244
+ token,
245
+ start: m.index,
246
+ end: m.index + m[0].length,
247
+ matched: lookupComponents(token, components, picks)
248
+ })
249
+ }
250
+ return out
251
+ }
252
+
253
+ function formatComponentRef(c: MentionedComponent): string {
254
+ const parts: string[] = []
255
+ if (c.name) parts.push(`name='${c.name}'`)
256
+ if (c.id) parts.push(`id='${c.id}'`)
257
+ parts.push(`type='${c.type}'`)
258
+ if (c.tag) parts.push(`tag='${c.tag}'`)
259
+ if (c.class) parts.push(`class='${c.class}'`)
260
+ if (c.text) parts.push(`text='${c.text}'`)
261
+ if (c.position) parts.push(`pos=(${c.position.x},${c.position.y})`)
262
+ parts.push(`refid=${c.refid}`)
263
+ return `{ ${parts.join(', ')} }`
264
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ * board-patch 브로드캐스트 — 순수 로직 (토픽/페이로드/필터). 데코레이터·pubsub import 없음
3
+ * → 단위테스트 가능. 데코레이터 resolver(board-patch-subscription.ts)와 pubsub 의존
4
+ * publishBoardPatch 는 별도.
5
+ */
6
+ import type { PatchEntry } from './patch-entry.js' // type-only — 런타임 import 아님
7
+
8
+ /** board-patch 브로드캐스트 토픽. */
9
+ export const BOARD_PATCH_TOPIC = 'board-patch'
10
+
11
+ /** 방송 페이로드 — 구독 필터(domain+board)용 메타 + PatchEntry 본체. */
12
+ export interface BoardPatchPayload {
13
+ domainId: string
14
+ boardId?: string
15
+ patch: PatchEntry
16
+ }
17
+
18
+ /**
19
+ * 같은 domain + board 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).
20
+ */
21
+ export function boardPatchFilter(domainId: string, boardId: string) {
22
+ return (payload: BoardPatchPayload): boolean =>
23
+ !!payload && payload.domainId === domainId && payload.boardId === boardId
24
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * board-patch 브로드캐스트 — 단위 테스트 (순수 로직).
3
+ * - boardPatchFilter: domain+board 격리(보안 핵심) — 일치만 통과, 불일치/널/board없음 차단.
4
+ * (publishBoardPatch / 구독 resolver 는 pubsub·graphql-ws 통합 영역 — 별도 검증.)
5
+ */
6
+ import { boardPatchFilter, BOARD_PATCH_TOPIC } from './board-patch-broadcast'
7
+ import type { BoardPatchPayload } from './board-patch-broadcast'
8
+
9
+ const payload = (domainId: string, boardId?: string): BoardPatchPayload => ({
10
+ domainId,
11
+ boardId,
12
+ patch: { id: 'p1' } as any
13
+ })
14
+
15
+ describe('boardPatchFilter (domain+board 격리)', () => {
16
+ it('domain+board 모두 일치 → 통과', () => {
17
+ expect(boardPatchFilter('d1', 'b1')(payload('d1', 'b1'))).toBe(true)
18
+ })
19
+
20
+ it('domain 불일치 → 차단 (도메인 격리)', () => {
21
+ expect(boardPatchFilter('d1', 'b1')(payload('d2', 'b1'))).toBe(false)
22
+ })
23
+
24
+ it('board 불일치 → 차단', () => {
25
+ expect(boardPatchFilter('d1', 'b1')(payload('d1', 'b2'))).toBe(false)
26
+ })
27
+
28
+ it('boardId 없는 페이로드 → 차단', () => {
29
+ expect(boardPatchFilter('d1', 'b1')(payload('d1', undefined))).toBe(false)
30
+ })
31
+
32
+ it('null/undefined 페이로드 안전 → 차단', () => {
33
+ expect(boardPatchFilter('d1', 'b1')(null as any)).toBe(false)
34
+ expect(boardPatchFilter('d1', 'b1')(undefined as any)).toBe(false)
35
+ })
36
+
37
+ it('토픽 상수', () => {
38
+ expect(BOARD_PATCH_TOPIC).toBe('board-patch')
39
+ })
40
+ })
@@ -0,0 +1,50 @@
1
+ /*
2
+ * BoardPatchSubscription (Phase Board-AI C1 — op-브로드캐스트 spine).
3
+ *
4
+ * PatchEntry(AI 또는 user-direct 편집) 가 생성될 때마다 같은 board 구독자에게 방송 →
5
+ * 실시간 공동편집의 척추. 클라이언트(C2)가 구독해 원격 patch 를 applyBoardEditPatch 로 적용.
6
+ *
7
+ * 전송/패턴은 기존 board-service/board-subscription.ts 복제 (PubSub + graphql-ws, domain 격리).
8
+ * 순수 로직(토픽/필터)은 board-patch-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.
9
+ */
10
+ import { Resolver, Subscription, Root, Arg } from 'type-graphql'
11
+ import { filter, pipe } from 'graphql-yoga'
12
+ import { pubsub } from '@things-factory/shell'
13
+ import { PatchEntry } from './patch-entry.js'
14
+ import { BOARD_PATCH_TOPIC, boardPatchFilter, type BoardPatchPayload } from './board-patch-broadcast.js'
15
+ import { assertDomainSubscribeAllowed } from '../subscribe-auth.js'
16
+
17
+ export { BOARD_PATCH_TOPIC, boardPatchFilter } from './board-patch-broadcast.js'
18
+ export type { BoardPatchPayload } from './board-patch-broadcast.js'
19
+
20
+ /**
21
+ * PatchEntry 생성 직후 호출 — 같은 board 구독자에게 방송.
22
+ * boardId 없으면(세션 미연결) skip. pubsub 미가용/실패해도 본 흐름(영속 완료)을 막지 않는다.
23
+ */
24
+ export function publishBoardPatch(payload: BoardPatchPayload): void {
25
+ if (!payload.boardId) return
26
+ try {
27
+ pubsub.publish(BOARD_PATCH_TOPIC, payload)
28
+ } catch {
29
+ /* 방송 생략 — 영속은 이미 끝남 */
30
+ }
31
+ }
32
+
33
+ @Resolver(PatchEntry)
34
+ export class BoardPatchSubscription {
35
+ @Subscription({
36
+ description:
37
+ 'Subscribes to board edit patches (AI or user-direct) for real-time collaboration on a board.',
38
+ subscribe: async ({ args, context }) => {
39
+ const { domain, user } = context.state
40
+ const { boardId } = args
41
+
42
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
43
+
44
+ return pipe(pubsub.subscribe(BOARD_PATCH_TOPIC), filter(boardPatchFilter(domain.id, boardId)))
45
+ }
46
+ })
47
+ boardPatchActivity(@Root() payload: BoardPatchPayload, @Arg('boardId') boardId: string): PatchEntry {
48
+ return payload.patch
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ import { PatchEntry } from './patch-entry.js'
2
+
3
+ export { PatchEntry }
4
+ export {
5
+ BoardPatchSubscription,
6
+ publishBoardPatch,
7
+ boardPatchFilter,
8
+ BOARD_PATCH_TOPIC,
9
+ type BoardPatchPayload
10
+ } from './board-patch-subscription.js'
11
+
12
+ export const entities = [PatchEntry]