@things-factory/ai-assistant 10.1.6

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 (164) hide show
  1. package/client/components/board-ai-chat.test.ts +149 -0
  2. package/client/components/board-ai-chat.ts +3228 -0
  3. package/client/components/chat-defaults.ts +27 -0
  4. package/client/components/chat-echo-dedup.test.ts +230 -0
  5. package/client/components/chat-echo-dedup.ts +155 -0
  6. package/client/components/chat-input-builder.ts +69 -0
  7. package/client/components/markdown.test.ts +518 -0
  8. package/client/components/markdown.ts +508 -0
  9. package/client/components/mention-popup-helpers.ts +433 -0
  10. package/client/components/mention-popup.test.ts +588 -0
  11. package/client/components/mention-popup.ts +238 -0
  12. package/client/index.ts +14 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/board-edit-patch.ts +374 -0
  15. package/dist-client/components/board-ai-chat.d.ts +423 -0
  16. package/dist-client/components/board-ai-chat.js +3034 -0
  17. package/dist-client/components/board-ai-chat.js.map +1 -0
  18. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  19. package/dist-client/components/board-ai-chat.test.js +138 -0
  20. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  21. package/dist-client/components/chat-defaults.d.ts +8 -0
  22. package/dist-client/components/chat-defaults.js +22 -0
  23. package/dist-client/components/chat-defaults.js.map +1 -0
  24. package/dist-client/components/chat-echo-dedup.d.ts +54 -0
  25. package/dist-client/components/chat-echo-dedup.js +115 -0
  26. package/dist-client/components/chat-echo-dedup.js.map +1 -0
  27. package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
  28. package/dist-client/components/chat-echo-dedup.test.js +203 -0
  29. package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
  30. package/dist-client/components/chat-input-builder.d.ts +55 -0
  31. package/dist-client/components/chat-input-builder.js +32 -0
  32. package/dist-client/components/chat-input-builder.js.map +1 -0
  33. package/dist-client/components/markdown.d.ts +60 -0
  34. package/dist-client/components/markdown.js +422 -0
  35. package/dist-client/components/markdown.js.map +1 -0
  36. package/dist-client/components/markdown.test.d.ts +1 -0
  37. package/dist-client/components/markdown.test.js +418 -0
  38. package/dist-client/components/markdown.test.js.map +1 -0
  39. package/dist-client/components/mention-popup-helpers.d.ts +165 -0
  40. package/dist-client/components/mention-popup-helpers.js +330 -0
  41. package/dist-client/components/mention-popup-helpers.js.map +1 -0
  42. package/dist-client/components/mention-popup.d.ts +35 -0
  43. package/dist-client/components/mention-popup.js +223 -0
  44. package/dist-client/components/mention-popup.js.map +1 -0
  45. package/dist-client/components/mention-popup.test.d.ts +1 -0
  46. package/dist-client/components/mention-popup.test.js +474 -0
  47. package/dist-client/components/mention-popup.test.js.map +1 -0
  48. package/dist-client/index.d.ts +14 -0
  49. package/dist-client/index.js +15 -0
  50. package/dist-client/index.js.map +1 -0
  51. package/dist-client/tsconfig.tsbuildinfo +1 -0
  52. package/dist-client/utils/board-edit-patch.d.ts +157 -0
  53. package/dist-client/utils/board-edit-patch.js +284 -0
  54. package/dist-client/utils/board-edit-patch.js.map +1 -0
  55. package/dist-server/index.d.ts +28 -0
  56. package/dist-server/index.js +32 -0
  57. package/dist-server/index.js.map +1 -0
  58. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  59. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  60. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  62. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  63. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  65. package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
  66. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  67. package/dist-server/service/chat-message/chat-message.d.ts +68 -0
  68. package/dist-server/service/chat-message/chat-message.js +172 -0
  69. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  70. package/dist-server/service/chat-message/fold-history.d.ts +43 -0
  71. package/dist-server/service/chat-message/fold-history.js +33 -0
  72. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  73. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  74. package/dist-server/service/chat-message/history-summary.js +77 -0
  75. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  76. package/dist-server/service/chat-message/index.d.ts +5 -0
  77. package/dist-server/service/chat-message/index.js +12 -0
  78. package/dist-server/service/chat-message/index.js.map +1 -0
  79. package/dist-server/service/chat-message/llm-history.d.ts +46 -0
  80. package/dist-server/service/chat-message/llm-history.js +87 -0
  81. package/dist-server/service/chat-message/llm-history.js.map +1 -0
  82. package/dist-server/service/chat-session/chat-session.d.ts +60 -0
  83. package/dist-server/service/chat-session/chat-session.js +159 -0
  84. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  85. package/dist-server/service/chat-session/index.d.ts +3 -0
  86. package/dist-server/service/chat-session/index.js +7 -0
  87. package/dist-server/service/chat-session/index.js.map +1 -0
  88. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
  89. package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
  90. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
  91. package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
  92. package/dist-server/service/chat-session/session-activity-publish.js +37 -0
  93. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
  94. package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
  95. package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
  96. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
  97. package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
  98. package/dist-server/service/chat-session/session-anchor.js +100 -0
  99. package/dist-server/service/chat-session/session-anchor.js.map +1 -0
  100. package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
  101. package/dist-server/service/chat-session/session-inbox.js +133 -0
  102. package/dist-server/service/chat-session/session-inbox.js.map +1 -0
  103. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
  104. package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
  105. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  106. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  107. package/dist-server/service/chat-session-participant/index.js +10 -0
  108. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  109. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  110. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  112. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  113. package/dist-server/service/chat-session-participant/presence.js +22 -0
  114. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  115. package/dist-server/service/chat-session-resolver.d.ts +93 -0
  116. package/dist-server/service/chat-session-resolver.js +706 -0
  117. package/dist-server/service/chat-session-resolver.js.map +1 -0
  118. package/dist-server/service/index.d.ts +24 -0
  119. package/dist-server/service/index.js +39 -0
  120. package/dist-server/service/index.js.map +1 -0
  121. package/dist-server/service/subscribe-auth.d.ts +14 -0
  122. package/dist-server/service/subscribe-auth.js +33 -0
  123. package/dist-server/service/subscribe-auth.js.map +1 -0
  124. package/dist-server/service/types.d.ts +5 -0
  125. package/dist-server/service/types.js +12 -0
  126. package/dist-server/service/types.js.map +1 -0
  127. package/dist-server/tsconfig.tsbuildinfo +1 -0
  128. package/package.json +48 -0
  129. package/server/index.ts +28 -0
  130. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  131. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  132. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  133. package/server/service/chat-message/chat-message-publish.ts +22 -0
  134. package/server/service/chat-message/chat-message-subscription.ts +45 -0
  135. package/server/service/chat-message/chat-message.ts +195 -0
  136. package/server/service/chat-message/fold-history.test.ts +98 -0
  137. package/server/service/chat-message/fold-history.ts +77 -0
  138. package/server/service/chat-message/history-summary.test.ts +127 -0
  139. package/server/service/chat-message/history-summary.ts +100 -0
  140. package/server/service/chat-message/index.ts +12 -0
  141. package/server/service/chat-message/llm-history.test.ts +194 -0
  142. package/server/service/chat-message/llm-history.ts +163 -0
  143. package/server/service/chat-session/chat-session.ts +193 -0
  144. package/server/service/chat-session/index.ts +5 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +57 -0
  146. package/server/service/chat-session/session-activity-publish.ts +44 -0
  147. package/server/service/chat-session/session-activity-subscription.ts +67 -0
  148. package/server/service/chat-session/session-anchor.ts +106 -0
  149. package/server/service/chat-session/session-inbox.test.ts +185 -0
  150. package/server/service/chat-session/session-inbox.ts +161 -0
  151. package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
  152. package/server/service/chat-session-participant/index.ts +6 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  154. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  155. package/server/service/chat-session-participant/presence.test.ts +39 -0
  156. package/server/service/chat-session-participant/presence.ts +21 -0
  157. package/server/service/chat-session-resolver.ts +644 -0
  158. package/server/service/index.ts +37 -0
  159. package/server/service/session-anchor.test.ts +133 -0
  160. package/server/service/subscribe-auth.test.ts +57 -0
  161. package/server/service/subscribe-auth.ts +34 -0
  162. package/server/service/types.ts +15 -0
  163. package/things-factory.config.js +1 -0
  164. package/tsconfig.json +9 -0
@@ -0,0 +1,518 @@
1
+ /**
2
+ * renderMarkdown 단위 테스트.
3
+ *
4
+ * 핵심 회귀 방지 영역:
5
+ * 1. 기본 markdown (bold, italic, list, code, link).
6
+ * 2. 보안 — raw HTML, javascript: 링크 차단.
7
+ * 3. CJK + 구두점 + ** 인접의 flanking 실패 케이스 (한국어 채팅 응답에서 빈번).
8
+ * 4. 코드 펜스/인라인 코드 안의 ** 는 건드리지 않음.
9
+ */
10
+ import {
11
+ renderMarkdown,
12
+ sanitizeHtml,
13
+ escapeHtml,
14
+ extractCjkStrongs,
15
+ extractMentions,
16
+ stripMentionRefids,
17
+ injectMentionRefids
18
+ } from './markdown'
19
+
20
+ describe('renderMarkdown — 기본 markdown', () => {
21
+ test('빈 문자열은 빈 문자열 반환', () => {
22
+ expect(renderMarkdown('')).toBe('')
23
+ })
24
+
25
+ test('일반 텍스트는 <p> 로 감쌈', () => {
26
+ expect(renderMarkdown('hello')).toContain('<p>hello</p>')
27
+ })
28
+
29
+ test('** 로 굵게', () => {
30
+ expect(renderMarkdown('**bold** text')).toContain('<strong>bold</strong>')
31
+ })
32
+
33
+ test('* 로 기울임', () => {
34
+ expect(renderMarkdown('an *italic* word')).toContain('<em>italic</em>')
35
+ })
36
+
37
+ test('- 로 unordered list', () => {
38
+ const out = renderMarkdown('- a\n- b\n- c')
39
+ expect(out).toContain('<ul>')
40
+ expect(out).toContain('<li>a</li>')
41
+ expect(out).toContain('<li>b</li>')
42
+ expect(out).toContain('<li>c</li>')
43
+ })
44
+
45
+ test('inline code', () => {
46
+ expect(renderMarkdown('use `foo` here')).toContain('<code>foo</code>')
47
+ })
48
+
49
+ test('fenced code block', () => {
50
+ const md = '```\nconst x = 1\n```'
51
+ expect(renderMarkdown(md)).toContain('<pre>')
52
+ expect(renderMarkdown(md)).toContain('<code>')
53
+ })
54
+
55
+ test('헤더', () => {
56
+ expect(renderMarkdown('# Title')).toContain('<h1>Title</h1>')
57
+ expect(renderMarkdown('## Sub')).toContain('<h2>Sub</h2>')
58
+ })
59
+
60
+ test('breaks: 단일 newline 은 <br>', () => {
61
+ expect(renderMarkdown('line1\nline2')).toContain('<br>')
62
+ })
63
+ })
64
+
65
+ describe('renderMarkdown — 보안', () => {
66
+ test('raw <script> 차단', () => {
67
+ const out = renderMarkdown("<script>alert('x')</script>")
68
+ expect(out).not.toContain('<script>')
69
+ expect(out).not.toContain('alert')
70
+ })
71
+
72
+ test('raw <iframe> 차단', () => {
73
+ const out = renderMarkdown('<iframe src=x></iframe>')
74
+ expect(out).not.toContain('<iframe')
75
+ })
76
+
77
+ test('inline HTML strong 차단 (renderer.html → 빈 문자열)', () => {
78
+ const out = renderMarkdown('Hello <strong>world</strong>')
79
+ // raw HTML 은 제거되고 텍스트만 남거나 비어야 함
80
+ expect(out).not.toContain('<strong>world</strong>')
81
+ })
82
+
83
+ test('javascript: 링크 차단', () => {
84
+ const out = renderMarkdown("[click](javascript:alert(1))")
85
+ expect(out).not.toMatch(/href="javascript:/i)
86
+ })
87
+
88
+ test('data: 링크 차단', () => {
89
+ const out = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)')
90
+ expect(out).not.toMatch(/href="data:/i)
91
+ })
92
+
93
+ test('정상 https 링크는 통과', () => {
94
+ const out = renderMarkdown('[ok](https://example.com)')
95
+ expect(out).toMatch(/href="https:\/\/example\.com"/)
96
+ })
97
+ })
98
+
99
+ describe('sanitizeHtml — DOMPurify defense-in-depth', () => {
100
+ // Node 환경 (jest) 에선 window 부재 → fallback 동작 검증.
101
+ // 브라우저에서의 실제 정화는 통합 테스트 영역.
102
+
103
+ test('window 부재 시 입력 그대로 통과 (fallback)', () => {
104
+ // 직접 호출 — window undefined 환경에서 DOMPurify 안 도는 게 정상
105
+ const html = '<p>safe</p><script>bad</script>'
106
+ const out = sanitizeHtml(html)
107
+ // 노드 환경에서 fallback 이라 그대로 통과 — 브라우저 환경에선 script 제거됨
108
+ expect(typeof out).toBe('string')
109
+ })
110
+
111
+ test('window 있을 때 (jsdom mock) — script 차단 확인', () => {
112
+ // jsdom 은 jest 의 testEnvironment 가 jsdom 일 때만 동작 — 본 setup 은 node.
113
+ // 본 test 는 sanitizeHtml 의 *contract* 만 확인 (string in → string out).
114
+ const out = sanitizeHtml('<p>hello</p>')
115
+ expect(out).toContain('hello')
116
+ })
117
+
118
+ test('빈 문자열 안전 처리', () => {
119
+ expect(sanitizeHtml('')).toBe('')
120
+ })
121
+
122
+ test('null/undefined fallback', () => {
123
+ // 타입상 string 만 받지만 방어적으로 — 빈 문자열로 처리
124
+ expect(sanitizeHtml('' as any)).toBe('')
125
+ })
126
+ })
127
+
128
+ describe('renderMarkdown — XSS 우회 시도 차단 (defense-in-depth)', () => {
129
+ // 본 테스트들은 marked 의 1차 차단 + DOMPurify 의 2차 차단이 모두 작동하는지
130
+ // 회귀 방지. node 환경에선 sanitizeHtml 이 fallback 이지만, marked 의 1차
131
+ // renderer.html → '' 차단만으로도 대부분 케이스 차단 검증 가능.
132
+
133
+ test('이미지 onerror 핸들러 시도 — img 태그 자체에 onerror attribute 없음', () => {
134
+ const out = renderMarkdown('![alt](x" onerror="alert(1))')
135
+ // 결과 텍스트에 escape 된 형태로 onerror= 라는 글자 자체는 남을 수 있지만,
136
+ // *실행 가능한 attribute* 형태 (속성 이름으로) 는 없어야 한다.
137
+ // 정밀 검사: <img 태그 안에 onerror=" 형태가 없는지
138
+ expect(out).not.toMatch(/<img\b[^>]*\bonerror\s*=/i)
139
+ // 또한 escape 안 된 따옴표 + onerror 조합 자체도 없어야
140
+ expect(out).not.toMatch(/"\s+onerror=/i)
141
+ })
142
+
143
+ test('img src=javascript: 차단', () => {
144
+ const out = renderMarkdown('![x](javascript:alert(1))')
145
+ expect(out).not.toMatch(/src="javascript:/i)
146
+ })
147
+
148
+ test('SVG xss 패턴 차단', () => {
149
+ const out = renderMarkdown('<svg onload=alert(1)>')
150
+ expect(out).not.toContain('<svg')
151
+ expect(out).not.toMatch(/onload/i)
152
+ })
153
+
154
+ test('style="javascript:..." 차단', () => {
155
+ const out = renderMarkdown('[click](#){:style="javascript:alert(1)"}')
156
+ expect(out).not.toMatch(/style="javascript:/i)
157
+ })
158
+
159
+ test('mention span 안의 사용자 token escape', () => {
160
+ const out = renderMarkdown('#"><script>alert(1)</script>')
161
+ expect(out).not.toMatch(/<script>/i)
162
+ })
163
+
164
+ test('event handler attribute 차단 (onmouseover 등)', () => {
165
+ // raw HTML 이 marked.renderer.html 에서 빈 문자열로 → onmouseover 자체가 출력 안됨
166
+ const out = renderMarkdown('<a href="#" onmouseover="alert(1)">x</a>')
167
+ expect(out).not.toMatch(/onmouseover/i)
168
+ })
169
+
170
+ test('vbscript: 스킴 차단 (legacy IE 잔재)', () => {
171
+ const out = renderMarkdown('[x](vbscript:msgbox(1))')
172
+ expect(out).not.toMatch(/href="vbscript:/i)
173
+ })
174
+ })
175
+
176
+ describe('renderMarkdown — CJK + 구두점 인접 (회귀 방지)', () => {
177
+ // 이 그룹은 사용자 보고 사례. CommonMark flanking 규칙으로 인해 ** 가
178
+ // 닫히지 않던 케이스들을 모두 회복해야 한다.
179
+ const cases: Array<[string, string, string]> = [
180
+ ["closing ** 이 punct + CJK 사이", "**'rect'**의", '<strong>&#039;rect&#039;</strong>의'],
181
+ ["키 **'fillStyle'**의", "키 **'fillStyle'**의", '<strong>&#039;fillStyle&#039;</strong>의'],
182
+ ["AGV 이름 + 조사", "**'AGV-1'**의 위치", '<strong>&#039;AGV-1&#039;</strong>의'],
183
+ ["다중 인접 한글", "사용자가 **'AGV-1'**컴포넌트를", '<strong>&#039;AGV-1&#039;</strong>컴포넌트를'],
184
+ ["bold 안 . 으로 끝", '**foo.**의', '<strong>foo.</strong>의'],
185
+ ["bold 안 ) 으로 끝", '**foo)**의', '<strong>foo)</strong>의'],
186
+ ["한 ** punct 시작 인접", "한**'foo'**", '한<strong>&#039;foo&#039;</strong>'],
187
+ ["양쪽 모두 CJK + punct", "한**'foo'**글", '한<strong>&#039;foo&#039;</strong>글']
188
+ ]
189
+
190
+ for (const [name, input, expected] of cases) {
191
+ test(name, () => {
192
+ const out = renderMarkdown(input)
193
+ expect(out).toContain(expected)
194
+ })
195
+ }
196
+
197
+ test('정상 케이스는 그대로 marked 가 처리', () => {
198
+ // 닫는 ** 가 공백 직전인 경우 — flanking 통과 (marked native; entity 표기는 &#39;)
199
+ const a = renderMarkdown("키 **'fillStyle'** 의")
200
+ expect(a).toMatch(/<strong>(&#0?39;|')fillStyle(&#0?39;|')<\/strong>/)
201
+ const b = renderMarkdown("Type **'rect'** here")
202
+ expect(b).toMatch(/<strong>(&#0?39;|')rect(&#0?39;|')<\/strong>/)
203
+ })
204
+
205
+ test('사용자 실제 케이스 — 산업용 다크 모드 대시보드', () => {
206
+ const md = "**공장 설비 및 생산 현황을 한눈에 파악하기 위한 '산업용 다크 모드 대시보드'** 도 확인해보라."
207
+ const out = renderMarkdown(md)
208
+ expect(out).toContain('<strong>')
209
+ expect(out).toContain('대시보드')
210
+ })
211
+
212
+ test('punct 가 끝/시작에 없으면 변경 없음', () => {
213
+ // marked 자체가 처리할 수 있는 케이스 — 우회 안 일어남
214
+ const out = renderMarkdown('한**foo**글')
215
+ expect(out).toContain('<strong>foo</strong>')
216
+ })
217
+
218
+ test('일본어 인접', () => {
219
+ expect(renderMarkdown("これは**'値'**です")).toContain('<strong>&#039;値&#039;</strong>')
220
+ })
221
+
222
+ test('중국어 인접', () => {
223
+ expect(renderMarkdown("这是**'值'**测试")).toContain('<strong>&#039;值&#039;</strong>')
224
+ })
225
+
226
+ test('인라인 코드 안의 ** 는 건드리지 않음', () => {
227
+ // 코드 안의 ** 는 그대로 텍스트로 남아야 함
228
+ const out = renderMarkdown('`**not bold**`')
229
+ expect(out).toContain('<code>**not bold**</code>')
230
+ })
231
+
232
+ test('코드 펜스 안의 ** 는 건드리지 않음', () => {
233
+ const md = '```\n**not bold**\n```'
234
+ const out = renderMarkdown(md)
235
+ expect(out).toContain('<code>')
236
+ // 펜스 안의 ** 는 marked 가 텍스트로 그대로 둠
237
+ expect(out).toMatch(/\*\*not bold\*\*/)
238
+ })
239
+
240
+ test('placeholder 토큰 (BAICJKSTRONG…) 이 출력에 노출되지 않음', () => {
241
+ const out = renderMarkdown("키 **'fillStyle'**의")
242
+ expect(out).not.toContain('BAICJKSTRONG')
243
+ expect(out).not.toContain('ENDMARK')
244
+ })
245
+
246
+ test('치환된 strong 내용은 HTML escape 됨', () => {
247
+ // CJK-fix 경로를 타는 케이스에 < > 가 섞여도 안전하게 escape 되어야 한다.
248
+ // before='', after='의' (CJK), innerStart=', innerEnd=' (둘 다 punct) → fix 트리거.
249
+ const out = renderMarkdown("**'<bad>'**의")
250
+ expect(out).not.toContain('<bad>')
251
+ expect(out).toContain('&lt;bad&gt;')
252
+ expect(out).toContain('<strong>')
253
+ })
254
+
255
+ test('여러 strong 가 한 줄에 있어도 모두 처리', () => {
256
+ const out = renderMarkdown("**'A'**과 **'B'**는 다르다")
257
+ // 둘 다 strong 으로 렌더되어야 함
258
+ const matches = out.match(/<strong>/g) ?? []
259
+ expect(matches.length).toBe(2)
260
+ })
261
+ })
262
+
263
+ describe('extractCjkStrongs — 내부 동작', () => {
264
+ test('필요 없는 케이스는 stored 비어 있음', () => {
265
+ const r = extractCjkStrongs('regular **bold** text')
266
+ expect(r.stored).toEqual([])
267
+ expect(r.processed).toBe('regular **bold** text')
268
+ })
269
+
270
+ test('필요한 케이스만 추출', () => {
271
+ const r = extractCjkStrongs("키 **'fillStyle'**의")
272
+ expect(r.stored).toEqual(["'fillStyle'"])
273
+ expect(r.processed).toContain('BAICJKSTRONG')
274
+ })
275
+
276
+ test('인라인 코드 안의 ** 는 추출 안 함', () => {
277
+ const r = extractCjkStrongs("`**'foo'**의` 와 텍스트")
278
+ expect(r.stored).toEqual([])
279
+ expect(r.processed).toContain("`**'foo'**의`")
280
+ })
281
+
282
+ test('코드 펜스 안의 ** 는 추출 안 함', () => {
283
+ const r = extractCjkStrongs("```\n**'foo'**의\n```")
284
+ expect(r.stored).toEqual([])
285
+ })
286
+
287
+ test('짝이 안 맞는 ** 는 무시', () => {
288
+ const r = extractCjkStrongs('한**foo and 글')
289
+ expect(r.stored).toEqual([])
290
+ })
291
+ })
292
+
293
+ describe('extractMentions — `#token` placeholder 추출', () => {
294
+ test('빈 문자열은 빈 결과', () => {
295
+ const r = extractMentions('')
296
+ expect(r.stored).toEqual([])
297
+ expect(r.processed).toBe('')
298
+ })
299
+
300
+ test('단일 mention — 영문', () => {
301
+ const r = extractMentions('보드의 #motor1 위치')
302
+ expect(r.stored).toEqual([{ trigger: '#', token: 'motor1' }])
303
+ expect(r.processed).toMatch(/보드의 BAIMENTION0ENDMENTION 위치/)
304
+ })
305
+
306
+ test('단일 mention — 한글', () => {
307
+ const r = extractMentions('#경광등1 빨갛게')
308
+ expect(r.stored).toEqual([{ trigger: '#', token: '경광등1' }])
309
+ })
310
+
311
+ test('다중 mention — 순서대로 인덱스', () => {
312
+ const r = extractMentions('#a 와 #b 비교')
313
+ expect(r.stored).toEqual([
314
+ { trigger: '#', token: 'a' },
315
+ { trigger: '#', token: 'b' }
316
+ ])
317
+ expect(r.processed).toMatch(/BAIMENTION0ENDMENTION 와 BAIMENTION1ENDMENTION/)
318
+ })
319
+
320
+ test('단어 중간 # 는 mention 아님', () => {
321
+ const r = extractMentions('foo#bar 는 무시')
322
+ expect(r.stored).toEqual([])
323
+ })
324
+
325
+ test('# 직후 공백 (markdown ATX 헤딩) 은 매칭 안 함', () => {
326
+ expect(extractMentions('# Title').stored).toEqual([])
327
+ expect(extractMentions('## Sub').stored).toEqual([])
328
+ expect(extractMentions('### 한글헤딩').stored).toEqual([])
329
+ })
330
+
331
+ test('인라인 코드 안의 # 는 건드리지 않음', () => {
332
+ const r = extractMentions('use `#define` here')
333
+ expect(r.stored).toEqual([])
334
+ expect(r.processed).toBe('use `#define` here')
335
+ })
336
+
337
+ test('코드 펜스 안의 # 는 건드리지 않음', () => {
338
+ const r = extractMentions('```\n#abc in code\n```\n그리고 #def 는 mention')
339
+ expect(r.stored).toEqual([{ trigger: '#', token: 'def' }])
340
+ })
341
+
342
+ test('구두점 직후 # — 매칭', () => {
343
+ const r = extractMentions('(#abc) [#def]')
344
+ expect(r.stored).toEqual([
345
+ { trigger: '#', token: 'abc' },
346
+ { trigger: '#', token: 'def' }
347
+ ])
348
+ })
349
+
350
+ test('@user 멘션도 추출 — trigger 보존', () => {
351
+ const r = extractMentions('@경철 도와주세요')
352
+ expect(r.stored).toEqual([{ trigger: '@', token: '경철' }])
353
+ })
354
+
355
+ test('# 와 @ 혼합 — 순서대로', () => {
356
+ const r = extractMentions('@경철 #motor1 같이 봐')
357
+ expect(r.stored).toEqual([
358
+ { trigger: '@', token: '경철' },
359
+ { trigger: '#', token: 'motor1' }
360
+ ])
361
+ })
362
+
363
+ test('이메일 (user@host) — mention 아님', () => {
364
+ const r = extractMentions('a@b.com 은 이메일')
365
+ expect(r.stored).toEqual([])
366
+ })
367
+
368
+ test('renderMarkdown 통합 — span 으로 복원', () => {
369
+ const html = renderMarkdown('보드의 #motor1 위치')
370
+ expect(html).toContain('<span class="mention">#motor1</span>')
371
+ })
372
+
373
+ test('renderMarkdown 통합 — 한글 mention', () => {
374
+ const html = renderMarkdown('#경광등1 빨갛게')
375
+ expect(html).toContain('<span class="mention">#경광등1</span>')
376
+ })
377
+
378
+ test('renderMarkdown 통합 — heading 과 mention 공존', () => {
379
+ const html = renderMarkdown('# Title\n그리고 #foo 참조')
380
+ expect(html).toContain('<h1>Title</h1>')
381
+ expect(html).toContain('<span class="mention">#foo</span>')
382
+ })
383
+
384
+ test('renderMarkdown 통합 — 코드 안 # 는 평범한 code', () => {
385
+ const html = renderMarkdown('use `#define`')
386
+ expect(html).toContain('<code>#define</code>')
387
+ expect(html).not.toContain('<span class="mention">')
388
+ })
389
+
390
+ test('renderMarkdown 통합 — XSS 안전 (token 의 < > escape)', () => {
391
+ const html = renderMarkdown('#<script>')
392
+ expect(html).not.toContain('<script>')
393
+ // < > 이전까지만 토큰 — 매칭 안 될 수도 있고 매칭되더라도 escape
394
+ if (html.includes('class="mention"')) {
395
+ expect(html).toContain('&lt;')
396
+ }
397
+ })
398
+ })
399
+
400
+ describe('renderMarkdown — `#token{refid:N}` inline marker → data-refid', () => {
401
+ test('marker 없으면 span 에 data-refid 없음', () => {
402
+ const html = renderMarkdown('보드의 #motor1 위치')
403
+ expect(html).toContain('<span class="mention">#motor1</span>')
404
+ expect(html).not.toContain('data-refid')
405
+ })
406
+
407
+ test('marker 있으면 data-refid embed 되고 marker 자체는 출력 안 됨', () => {
408
+ const html = renderMarkdown('#motor1{refid:35} 빨갛게')
409
+ expect(html).toContain('<span class="mention" data-refid="35">#motor1</span>')
410
+ expect(html).not.toContain('{refid:')
411
+ })
412
+
413
+ test('다중 mention — 각 token 별 refid 분리', () => {
414
+ const html = renderMarkdown('#a{refid:10} 와 #b{refid:20}')
415
+ expect(html).toContain('data-refid="10">#a</span>')
416
+ expect(html).toContain('data-refid="20">#b</span>')
417
+ })
418
+
419
+ test('marker 일부만 있을 때 — marker 없는 mention 은 data-refid 없음', () => {
420
+ const html = renderMarkdown('#a{refid:10} 와 #unknown')
421
+ expect(html).toContain('data-refid="10">#a</span>')
422
+ expect(html).toContain('<span class="mention">#unknown</span>')
423
+ })
424
+
425
+ test('한글 mention — data-refid 정상 embed', () => {
426
+ const html = renderMarkdown('#경광등1{refid:35} 빨갛게')
427
+ expect(html).toContain('data-refid="35">#경광등1</span>')
428
+ })
429
+
430
+ test('marker 안 본문 — escapeHtml 안전 (XSS)', () => {
431
+ const html = renderMarkdown('#motor1{refid:35} <script>')
432
+ expect(html).not.toContain('<script>')
433
+ expect(html).toContain('data-refid="35"')
434
+ })
435
+ })
436
+
437
+ describe('stripMentionRefids — marker 제거', () => {
438
+ test('단일 marker 제거', () => {
439
+ expect(stripMentionRefids('#motor1{refid:35} 빨갛게')).toBe('#motor1 빨갛게')
440
+ })
441
+
442
+ test('다중 marker 제거', () => {
443
+ expect(stripMentionRefids('#a{refid:1} 와 #b{refid:2}')).toBe('#a 와 #b')
444
+ })
445
+
446
+ test('marker 없으면 원본 그대로', () => {
447
+ expect(stripMentionRefids('#motor1 빨갛게')).toBe('#motor1 빨갛게')
448
+ expect(stripMentionRefids('아무 마커 없는 텍스트')).toBe('아무 마커 없는 텍스트')
449
+ })
450
+
451
+ test('빈/널 안전', () => {
452
+ expect(stripMentionRefids('')).toBe('')
453
+ expect(stripMentionRefids(null)).toBe('')
454
+ expect(stripMentionRefids(undefined)).toBe('')
455
+ })
456
+ })
457
+
458
+ describe('injectMentionRefids — popup picks 를 본문에 marker 주입', () => {
459
+ test('단일 token 주입', () => {
460
+ expect(
461
+ injectMentionRefids('#motor1 빨갛게', [{ token: 'motor1', refid: 35 }])
462
+ ).toBe('#motor1{refid:35} 빨갛게')
463
+ })
464
+
465
+ test('다중 token — 각각 주입', () => {
466
+ expect(
467
+ injectMentionRefids('#a 와 #b', [
468
+ { token: 'a', refid: 10 },
469
+ { token: 'b', refid: 20 }
470
+ ])
471
+ ).toBe('#a{refid:10} 와 #b{refid:20}')
472
+ })
473
+
474
+ test('matches 가 없는 token 은 건드리지 않음', () => {
475
+ expect(
476
+ injectMentionRefids('#motor1 와 #other', [{ token: 'motor1', refid: 35 }])
477
+ ).toBe('#motor1{refid:35} 와 #other')
478
+ })
479
+
480
+ test('이미 marker 있으면 덮어쓰기 안 함 (idempotent)', () => {
481
+ expect(
482
+ injectMentionRefids('#motor1{refid:99} 빨갛게', [{ token: 'motor1', refid: 35 }])
483
+ ).toBe('#motor1{refid:99} 빨갛게')
484
+ })
485
+
486
+ test('단어 중간 # 는 매칭 안 함', () => {
487
+ expect(
488
+ injectMentionRefids('foo#motor1 끝', [{ token: 'motor1', refid: 35 }])
489
+ ).toBe('foo#motor1 끝')
490
+ })
491
+
492
+ test('빈 picks / 빈 텍스트 안전', () => {
493
+ expect(injectMentionRefids('#a', [])).toBe('#a')
494
+ expect(injectMentionRefids('', [{ token: 'a', refid: 1 }])).toBe('')
495
+ expect(injectMentionRefids('#a', null)).toBe('#a')
496
+ expect(injectMentionRefids('#a', undefined)).toBe('#a')
497
+ })
498
+
499
+ test('한글 token 주입', () => {
500
+ expect(
501
+ injectMentionRefids('#경광등1 빨갛게', [{ token: '경광등1', refid: 42 }])
502
+ ).toBe('#경광등1{refid:42} 빨갛게')
503
+ })
504
+ })
505
+
506
+ describe('escapeHtml — HTML 이스케이프', () => {
507
+ test('< > & " \' 모두 escape', () => {
508
+ expect(escapeHtml('<script>')).toBe('&lt;script&gt;')
509
+ expect(escapeHtml('a & b')).toBe('a &amp; b')
510
+ expect(escapeHtml('"q"')).toBe('&quot;q&quot;')
511
+ expect(escapeHtml("'a'")).toBe('&#039;a&#039;')
512
+ })
513
+
514
+ test('일반 문자는 변경 없음', () => {
515
+ expect(escapeHtml('hello world')).toBe('hello world')
516
+ expect(escapeHtml('한글 テスト 中文')).toBe('한글 テスト 中文')
517
+ })
518
+ })