md-redline 0.1.0

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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
@@ -0,0 +1,773 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { renderHook, act } from '@testing-library/react';
5
+ import { useComments, type UseCommentsParams } from './useComments';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers: build raw markdown strings with embedded comment markers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function makeComment(overrides: Record<string, unknown> = {}): string {
12
+ const data = {
13
+ id: 'c1',
14
+ anchor: 'hello',
15
+ text: 'note',
16
+ author: 'User',
17
+ timestamp: '2024-01-01T00:00:00.000Z',
18
+ ...overrides,
19
+ };
20
+ return `<!-- @comment${JSON.stringify(data)} -->`;
21
+ }
22
+
23
+ function rawWithComments(
24
+ ...comments: { before?: string; marker: string; after?: string }[]
25
+ ): string {
26
+ return comments.map((c) => `${c.before ?? ''}${c.marker}${c.after ?? ''}`).join('');
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Default params factory
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function defaultParams(overrides: Partial<UseCommentsParams> = {}): UseCommentsParams {
34
+ return {
35
+ rawMarkdown: '',
36
+ rawMarkdownRef: { current: '' },
37
+ setRawMarkdown: vi.fn(),
38
+ saveFile: vi.fn(),
39
+ author: 'Tester',
40
+ enableResolve: false,
41
+ tabs: [],
42
+ activeFilePath: null,
43
+ viewerRef: {
44
+ current: { scrollToComment: vi.fn() },
45
+ } as unknown as UseCommentsParams['viewerRef'],
46
+ rawViewRef: {
47
+ current: { scrollToComment: vi.fn() },
48
+ } as unknown as UseCommentsParams['rawViewRef'],
49
+ showToast: vi.fn(),
50
+ clearSelection: vi.fn(),
51
+ setAutoExpandForm: vi.fn(),
52
+ requestCommentFocus: vi.fn(),
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tests
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('useComments', () => {
62
+ beforeEach(() => {
63
+ vi.restoreAllMocks();
64
+ // Stub crypto.randomUUID for deterministic IDs
65
+ vi.stubGlobal('crypto', {
66
+ ...globalThis.crypto,
67
+ randomUUID: vi.fn(() => 'new-uuid-1'),
68
+ });
69
+ });
70
+
71
+ // -----------------------------------------------------------------------
72
+ // 1. Comment parsing
73
+ // -----------------------------------------------------------------------
74
+ describe('comment parsing', () => {
75
+ it('populates comments array from raw markdown with comment markers', () => {
76
+ const raw = `Some text ${makeComment({ id: 'c1', anchor: 'hello', text: 'note one' })}hello world`;
77
+ const params = defaultParams({ rawMarkdown: raw });
78
+ const { result } = renderHook(() => useComments(params));
79
+
80
+ expect(result.current.comments).toHaveLength(1);
81
+ expect(result.current.comments[0].id).toBe('c1');
82
+ expect(result.current.comments[0].anchor).toBe('hello');
83
+ expect(result.current.comments[0].text).toBe('note one');
84
+ });
85
+
86
+ it('strips comment markers from cleanMarkdown', () => {
87
+ const raw = `Some text ${makeComment({ id: 'c1', anchor: 'hello' })}hello world`;
88
+ const params = defaultParams({ rawMarkdown: raw });
89
+ const { result } = renderHook(() => useComments(params));
90
+
91
+ expect(result.current.cleanMarkdown).toBe('Some text hello world');
92
+ expect(result.current.cleanMarkdown).not.toContain('@comment');
93
+ });
94
+
95
+ it('renders html from clean markdown', () => {
96
+ const raw = `# Title\n\n${makeComment({ id: 'c1', anchor: 'Title' })}`;
97
+ const params = defaultParams({ rawMarkdown: raw });
98
+ const { result } = renderHook(() => useComments(params));
99
+
100
+ expect(result.current.html).toContain('<h1');
101
+ expect(result.current.html).toContain('Title');
102
+ });
103
+
104
+ it('handles undefined rawMarkdown', () => {
105
+ const params = defaultParams({ rawMarkdown: undefined });
106
+ const { result } = renderHook(() => useComments(params));
107
+
108
+ expect(result.current.comments).toEqual([]);
109
+ expect(result.current.cleanMarkdown).toBe('');
110
+ expect(result.current.html).toBe('');
111
+ });
112
+ });
113
+
114
+ // -----------------------------------------------------------------------
115
+ // 2. Comment counts per tab
116
+ // -----------------------------------------------------------------------
117
+ describe('commentCounts', () => {
118
+ it('counts open comments for the active tab', () => {
119
+ const raw = rawWithComments(
120
+ { before: 'Text ', marker: makeComment({ id: 'c1', anchor: 'one' }), after: 'one ' },
121
+ { marker: makeComment({ id: 'c2', anchor: 'two' }), after: 'two' },
122
+ );
123
+ const params = defaultParams({
124
+ rawMarkdown: raw,
125
+ tabs: [{ filePath: 'file.md', rawMarkdown: raw }],
126
+ activeFilePath: 'file.md',
127
+ });
128
+ const { result } = renderHook(() => useComments(params));
129
+
130
+ expect(result.current.commentCounts.get('file.md')).toBe(2);
131
+ });
132
+
133
+ it('counts comments for non-active tabs by parsing their rawMarkdown', () => {
134
+ const activeRaw = `Active ${makeComment({ id: 'a1', anchor: 'Active' })}`;
135
+ const otherRaw = rawWithComments(
136
+ { before: 'X ', marker: makeComment({ id: 'o1', anchor: 'X' }), after: '' },
137
+ { before: ' Y ', marker: makeComment({ id: 'o2', anchor: 'Y' }), after: '' },
138
+ { before: ' Z ', marker: makeComment({ id: 'o3', anchor: 'Z' }), after: '' },
139
+ );
140
+ const params = defaultParams({
141
+ rawMarkdown: activeRaw,
142
+ tabs: [
143
+ { filePath: 'active.md', rawMarkdown: activeRaw },
144
+ { filePath: 'other.md', rawMarkdown: otherRaw },
145
+ ],
146
+ activeFilePath: 'active.md',
147
+ });
148
+ const { result } = renderHook(() => useComments(params));
149
+
150
+ expect(result.current.commentCounts.get('active.md')).toBe(1);
151
+ expect(result.current.commentCounts.get('other.md')).toBe(3);
152
+ });
153
+ });
154
+
155
+ // -----------------------------------------------------------------------
156
+ // 3. Resolved counts with enableResolve
157
+ // -----------------------------------------------------------------------
158
+ describe('resolvedCommentCounts', () => {
159
+ it('counts resolved comments when enableResolve is true', () => {
160
+ const raw = rawWithComments(
161
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A', status: 'open' }), after: '' },
162
+ {
163
+ before: ' B ',
164
+ marker: makeComment({ id: 'c2', anchor: 'B', status: 'resolved' }),
165
+ after: '',
166
+ },
167
+ {
168
+ before: ' C ',
169
+ marker: makeComment({ id: 'c3', anchor: 'C', status: 'open' }),
170
+ after: '',
171
+ },
172
+ );
173
+ const params = defaultParams({
174
+ rawMarkdown: raw,
175
+ enableResolve: true,
176
+ tabs: [{ filePath: 'file.md', rawMarkdown: raw }],
177
+ activeFilePath: 'file.md',
178
+ });
179
+ const { result } = renderHook(() => useComments(params));
180
+
181
+ // 2 open, 1 resolved
182
+ expect(result.current.commentCounts.get('file.md')).toBe(2);
183
+ expect(result.current.resolvedCommentCounts.get('file.md')).toBe(1);
184
+ });
185
+
186
+ it('does not populate resolvedCommentCounts when enableResolve is false', () => {
187
+ const raw = `Text ${makeComment({ id: 'c1', anchor: 'Text', status: 'resolved' })}`;
188
+ const params = defaultParams({
189
+ rawMarkdown: raw,
190
+ enableResolve: false,
191
+ tabs: [{ filePath: 'file.md', rawMarkdown: raw }],
192
+ activeFilePath: 'file.md',
193
+ });
194
+ const { result } = renderHook(() => useComments(params));
195
+
196
+ expect(result.current.resolvedCommentCounts.has('file.md')).toBe(false);
197
+ // All comments counted (resolved ones included)
198
+ expect(result.current.commentCounts.get('file.md')).toBe(1);
199
+ });
200
+ });
201
+
202
+ // -----------------------------------------------------------------------
203
+ // 4. commentCount
204
+ // -----------------------------------------------------------------------
205
+ describe('commentCount', () => {
206
+ it('returns total open comments when enableResolve is true', () => {
207
+ const raw = rawWithComments(
208
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A', status: 'open' }), after: '' },
209
+ {
210
+ before: ' B ',
211
+ marker: makeComment({ id: 'c2', anchor: 'B', status: 'resolved' }),
212
+ after: '',
213
+ },
214
+ {
215
+ before: ' C ',
216
+ marker: makeComment({ id: 'c3', anchor: 'C', status: 'open' }),
217
+ after: '',
218
+ },
219
+ );
220
+ const params = defaultParams({ rawMarkdown: raw, enableResolve: true });
221
+ const { result } = renderHook(() => useComments(params));
222
+
223
+ expect(result.current.commentCount).toBe(2);
224
+ });
225
+
226
+ it('returns total comments when enableResolve is false', () => {
227
+ const raw = rawWithComments(
228
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A', status: 'open' }), after: '' },
229
+ {
230
+ before: ' B ',
231
+ marker: makeComment({ id: 'c2', anchor: 'B', status: 'resolved' }),
232
+ after: '',
233
+ },
234
+ );
235
+ const params = defaultParams({ rawMarkdown: raw, enableResolve: false });
236
+ const { result } = renderHook(() => useComments(params));
237
+
238
+ expect(result.current.commentCount).toBe(2);
239
+ });
240
+ });
241
+
242
+ // -----------------------------------------------------------------------
243
+ // 5. handleJumpToNext — wraps from last to first
244
+ // -----------------------------------------------------------------------
245
+ describe('handleJumpToNext', () => {
246
+ it('wraps from last comment back to first', () => {
247
+ const raw = rawWithComments(
248
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A' }), after: '' },
249
+ { before: ' B ', marker: makeComment({ id: 'c2', anchor: 'B' }), after: '' },
250
+ { before: ' C ', marker: makeComment({ id: 'c3', anchor: 'C' }), after: '' },
251
+ );
252
+ const viewerRef = {
253
+ current: { scrollToComment: vi.fn() },
254
+ } as unknown as UseCommentsParams['viewerRef'];
255
+ const rawViewRef = {
256
+ current: { scrollToComment: vi.fn() },
257
+ } as unknown as UseCommentsParams['rawViewRef'];
258
+ const params = defaultParams({ rawMarkdown: raw, viewerRef, rawViewRef });
259
+ const { result } = renderHook(() => useComments(params));
260
+
261
+ // Navigate to c1 (first)
262
+ act(() => result.current.handleJumpToNext());
263
+ expect(result.current.activeCommentId).toBe('c1');
264
+
265
+ // Navigate to c2
266
+ act(() => result.current.handleJumpToNext());
267
+ expect(result.current.activeCommentId).toBe('c2');
268
+
269
+ // Navigate to c3
270
+ act(() => result.current.handleJumpToNext());
271
+ expect(result.current.activeCommentId).toBe('c3');
272
+
273
+ // Wrap to c1
274
+ act(() => result.current.handleJumpToNext());
275
+ expect(result.current.activeCommentId).toBe('c1');
276
+
277
+ expect(viewerRef.current!.scrollToComment).toHaveBeenCalledWith('c1');
278
+ expect(rawViewRef.current!.scrollToComment).toHaveBeenCalledWith('c1');
279
+ });
280
+ });
281
+
282
+ // -----------------------------------------------------------------------
283
+ // 6. handleJumpToPrev — wraps from first to last
284
+ // -----------------------------------------------------------------------
285
+ describe('handleJumpToPrev', () => {
286
+ it('wraps from first comment back to last', () => {
287
+ const raw = rawWithComments(
288
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A' }), after: '' },
289
+ { before: ' B ', marker: makeComment({ id: 'c2', anchor: 'B' }), after: '' },
290
+ { before: ' C ', marker: makeComment({ id: 'c3', anchor: 'C' }), after: '' },
291
+ );
292
+ const viewerRef = {
293
+ current: { scrollToComment: vi.fn() },
294
+ } as unknown as UseCommentsParams['viewerRef'];
295
+ const rawViewRef = {
296
+ current: { scrollToComment: vi.fn() },
297
+ } as unknown as UseCommentsParams['rawViewRef'];
298
+ const params = defaultParams({ rawMarkdown: raw, viewerRef, rawViewRef });
299
+ const { result } = renderHook(() => useComments(params));
300
+
301
+ // No active comment — jumping prev should go to last
302
+ act(() => result.current.handleJumpToPrev());
303
+ expect(result.current.activeCommentId).toBe('c3');
304
+
305
+ // Prev from c3 → c2
306
+ act(() => result.current.handleJumpToPrev());
307
+ expect(result.current.activeCommentId).toBe('c2');
308
+
309
+ // Prev from c2 → c1
310
+ act(() => result.current.handleJumpToPrev());
311
+ expect(result.current.activeCommentId).toBe('c1');
312
+
313
+ // Wrap from c1 → c3
314
+ act(() => result.current.handleJumpToPrev());
315
+ expect(result.current.activeCommentId).toBe('c3');
316
+ });
317
+ });
318
+
319
+ // -----------------------------------------------------------------------
320
+ // 7. handleJumpToNext with resolve mode — skips resolved comments
321
+ // -----------------------------------------------------------------------
322
+ describe('handleJumpToNext with enableResolve', () => {
323
+ it('skips resolved comments', () => {
324
+ const raw = rawWithComments(
325
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A', status: 'open' }), after: '' },
326
+ {
327
+ before: ' B ',
328
+ marker: makeComment({ id: 'c2', anchor: 'B', status: 'resolved' }),
329
+ after: '',
330
+ },
331
+ {
332
+ before: ' C ',
333
+ marker: makeComment({ id: 'c3', anchor: 'C', status: 'open' }),
334
+ after: '',
335
+ },
336
+ );
337
+ const params = defaultParams({ rawMarkdown: raw, enableResolve: true });
338
+ const { result } = renderHook(() => useComments(params));
339
+
340
+ // First jump → c1 (first open)
341
+ act(() => result.current.handleJumpToNext());
342
+ expect(result.current.activeCommentId).toBe('c1');
343
+
344
+ // Next → c3 (skip c2 which is resolved)
345
+ act(() => result.current.handleJumpToNext());
346
+ expect(result.current.activeCommentId).toBe('c3');
347
+
348
+ // Wrap back to c1
349
+ act(() => result.current.handleJumpToNext());
350
+ expect(result.current.activeCommentId).toBe('c1');
351
+ });
352
+ });
353
+
354
+ // -----------------------------------------------------------------------
355
+ // 8. handleAddComment — calls insertComment and updateAndSave
356
+ // -----------------------------------------------------------------------
357
+ describe('handleAddComment', () => {
358
+ it('calls setRawMarkdown, saveFile, and sets activeCommentId', () => {
359
+ const setRawMarkdown = vi.fn();
360
+ const saveFile = vi.fn();
361
+ const clearSelection = vi.fn();
362
+ const requestCommentFocus = vi.fn();
363
+ const setAutoExpandForm = vi.fn();
364
+ const raw = 'Hello world';
365
+ const rawMarkdownRef = { current: raw };
366
+ const params = defaultParams({
367
+ rawMarkdown: raw,
368
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
369
+ setRawMarkdown,
370
+ saveFile,
371
+ author: 'Tester',
372
+ clearSelection,
373
+ requestCommentFocus,
374
+ setAutoExpandForm,
375
+ });
376
+ const { result } = renderHook(() => useComments(params));
377
+
378
+ act(() => {
379
+ result.current.handleAddComment('Hello', 'Nice greeting');
380
+ });
381
+
382
+ // Should have called setRawMarkdown and saveFile with new content containing comment marker
383
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
384
+ expect(saveFile).toHaveBeenCalledTimes(1);
385
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
386
+ expect(savedContent).toContain('@comment');
387
+ expect(savedContent).toContain('Nice greeting');
388
+ expect(savedContent).toContain('new-uuid-1');
389
+
390
+ // Should set active comment id to the new UUID
391
+ expect(result.current.activeCommentId).toBe('new-uuid-1');
392
+
393
+ // Should call requestCommentFocus with the new id
394
+ expect(requestCommentFocus).toHaveBeenCalledWith('new-uuid-1');
395
+
396
+ // Should clear selection
397
+ expect(clearSelection).toHaveBeenCalledTimes(1);
398
+
399
+ // Should collapse form
400
+ expect(setAutoExpandForm).toHaveBeenCalledWith(false);
401
+ });
402
+ });
403
+
404
+ // -----------------------------------------------------------------------
405
+ // 9. handleDelete — removes comment and clears activeCommentId if deleted
406
+ // -----------------------------------------------------------------------
407
+ describe('handleDelete', () => {
408
+ it('calls setRawMarkdown and saveFile to remove the comment', () => {
409
+ const setRawMarkdown = vi.fn();
410
+ const saveFile = vi.fn();
411
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello' })}world`;
412
+ const rawMarkdownRef = { current: raw };
413
+ const params = defaultParams({
414
+ rawMarkdown: raw,
415
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
416
+ setRawMarkdown,
417
+ saveFile,
418
+ });
419
+ const { result } = renderHook(() => useComments(params));
420
+
421
+ // First set active comment to c1
422
+ act(() => result.current.setActiveCommentId('c1'));
423
+ expect(result.current.activeCommentId).toBe('c1');
424
+
425
+ // Delete c1
426
+ act(() => result.current.handleDelete('c1'));
427
+
428
+ expect(setRawMarkdown).toHaveBeenCalled();
429
+ expect(saveFile).toHaveBeenCalled();
430
+ // Active comment should be cleared because it was the deleted one
431
+ expect(result.current.activeCommentId).toBeNull();
432
+ });
433
+
434
+ it('does not clear activeCommentId if a different comment was deleted', () => {
435
+ const raw = rawWithComments(
436
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A' }), after: '' },
437
+ { before: ' B ', marker: makeComment({ id: 'c2', anchor: 'B' }), after: '' },
438
+ );
439
+ const rawMarkdownRef = { current: raw };
440
+ const params = defaultParams({
441
+ rawMarkdown: raw,
442
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
443
+ });
444
+ const { result } = renderHook(() => useComments(params));
445
+
446
+ // Set active to c1
447
+ act(() => result.current.setActiveCommentId('c1'));
448
+ expect(result.current.activeCommentId).toBe('c1');
449
+
450
+ // Delete c2 (different comment)
451
+ act(() => result.current.handleDelete('c2'));
452
+
453
+ // c1 should still be active
454
+ expect(result.current.activeCommentId).toBe('c1');
455
+ });
456
+ });
457
+
458
+ // -----------------------------------------------------------------------
459
+ // 10. handleResolve / handleUnresolve
460
+ // -----------------------------------------------------------------------
461
+ describe('handleResolve and handleUnresolve', () => {
462
+ it('handleResolve calls setRawMarkdown and saveFile with resolved content', () => {
463
+ const setRawMarkdown = vi.fn();
464
+ const saveFile = vi.fn();
465
+ const raw = `Text ${makeComment({ id: 'c1', anchor: 'Text', status: 'open' })}`;
466
+ const rawMarkdownRef = { current: raw };
467
+ const params = defaultParams({
468
+ rawMarkdown: raw,
469
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
470
+ setRawMarkdown,
471
+ saveFile,
472
+ enableResolve: true,
473
+ });
474
+ const { result } = renderHook(() => useComments(params));
475
+
476
+ act(() => result.current.handleResolve('c1'));
477
+
478
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
479
+ expect(saveFile).toHaveBeenCalledTimes(1);
480
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
481
+ expect(savedContent).toContain('"status":"resolved"');
482
+ });
483
+
484
+ it('handleUnresolve calls setRawMarkdown and saveFile with unresolve content', () => {
485
+ const setRawMarkdown = vi.fn();
486
+ const saveFile = vi.fn();
487
+ const raw = `Text ${makeComment({ id: 'c1', anchor: 'Text', status: 'resolved' })}`;
488
+ const rawMarkdownRef = { current: raw };
489
+ const params = defaultParams({
490
+ rawMarkdown: raw,
491
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
492
+ setRawMarkdown,
493
+ saveFile,
494
+ enableResolve: true,
495
+ });
496
+ const { result } = renderHook(() => useComments(params));
497
+
498
+ act(() => result.current.handleUnresolve('c1'));
499
+
500
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
501
+ expect(saveFile).toHaveBeenCalledTimes(1);
502
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
503
+ expect(savedContent).toContain('"status":"open"');
504
+ });
505
+ });
506
+
507
+ // -----------------------------------------------------------------------
508
+ // 11. handleBulkDelete — calls removeAllComments
509
+ // -----------------------------------------------------------------------
510
+ describe('handleBulkDelete', () => {
511
+ it('removes all comments from the markdown', () => {
512
+ const setRawMarkdown = vi.fn();
513
+ const saveFile = vi.fn();
514
+ const raw = rawWithComments(
515
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A' }), after: '' },
516
+ { before: ' B ', marker: makeComment({ id: 'c2', anchor: 'B' }), after: '' },
517
+ );
518
+ const rawMarkdownRef = { current: raw };
519
+ const params = defaultParams({
520
+ rawMarkdown: raw,
521
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
522
+ setRawMarkdown,
523
+ saveFile,
524
+ });
525
+ const { result } = renderHook(() => useComments(params));
526
+
527
+ act(() => result.current.handleBulkDelete());
528
+
529
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
530
+ expect(saveFile).toHaveBeenCalledTimes(1);
531
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
532
+ expect(savedContent).not.toContain('@comment');
533
+ });
534
+ });
535
+
536
+ // -----------------------------------------------------------------------
537
+ // 12. missingAnchors — detects comments whose anchor is not in clean markdown
538
+ // -----------------------------------------------------------------------
539
+ describe('missingAnchors', () => {
540
+ it('detects comments whose anchor text does not appear in clean markdown', () => {
541
+ // Anchor is "missing text" but clean markdown won't contain it
542
+ const raw = `Real content ${makeComment({ id: 'c1', anchor: 'missing text', status: 'open' })}here`;
543
+ const params = defaultParams({ rawMarkdown: raw });
544
+ const { result } = renderHook(() => useComments(params));
545
+
546
+ expect(result.current.missingAnchors.has('c1')).toBe(true);
547
+ });
548
+
549
+ it('does not flag comments whose anchor text is present', () => {
550
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello' })}world`;
551
+ const params = defaultParams({ rawMarkdown: raw });
552
+ const { result } = renderHook(() => useComments(params));
553
+
554
+ expect(result.current.missingAnchors.has('c1')).toBe(false);
555
+ });
556
+
557
+ it('does not flag resolved comments as missing', () => {
558
+ const raw = `Real content ${makeComment({ id: 'c1', anchor: 'missing text', status: 'resolved' })}here`;
559
+ const params = defaultParams({ rawMarkdown: raw, enableResolve: true });
560
+ const { result } = renderHook(() => useComments(params));
561
+
562
+ expect(result.current.missingAnchors.has('c1')).toBe(false);
563
+ });
564
+ });
565
+
566
+ // -----------------------------------------------------------------------
567
+ // Additional CRUD operations
568
+ // -----------------------------------------------------------------------
569
+ describe('handleEdit', () => {
570
+ it('calls setRawMarkdown and saveFile with edited comment text', () => {
571
+ const setRawMarkdown = vi.fn();
572
+ const saveFile = vi.fn();
573
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello', text: 'old note' })}world`;
574
+ const rawMarkdownRef = { current: raw };
575
+ const params = defaultParams({
576
+ rawMarkdown: raw,
577
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
578
+ setRawMarkdown,
579
+ saveFile,
580
+ });
581
+ const { result } = renderHook(() => useComments(params));
582
+
583
+ act(() => result.current.handleEdit('c1', 'new note'));
584
+
585
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
586
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
587
+ expect(savedContent).toContain('new note');
588
+ expect(savedContent).not.toContain('old note');
589
+ });
590
+ });
591
+
592
+ describe('handleReply', () => {
593
+ it('calls setRawMarkdown and saveFile with reply added', () => {
594
+ const setRawMarkdown = vi.fn();
595
+ const saveFile = vi.fn();
596
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello', text: 'note' })}world`;
597
+ const rawMarkdownRef = { current: raw };
598
+ const params = defaultParams({
599
+ rawMarkdown: raw,
600
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
601
+ setRawMarkdown,
602
+ saveFile,
603
+ author: 'Replier',
604
+ });
605
+ const { result } = renderHook(() => useComments(params));
606
+
607
+ act(() => result.current.handleReply('c1', 'reply text'));
608
+
609
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
610
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
611
+ expect(savedContent).toContain('reply text');
612
+ expect(savedContent).toContain('Replier');
613
+ expect(savedContent).toContain('"replies"');
614
+ });
615
+ });
616
+
617
+ describe('handleBulkResolve', () => {
618
+ it('resolves all comments', () => {
619
+ const setRawMarkdown = vi.fn();
620
+ const saveFile = vi.fn();
621
+ const raw = rawWithComments(
622
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A', status: 'open' }), after: '' },
623
+ {
624
+ before: ' B ',
625
+ marker: makeComment({ id: 'c2', anchor: 'B', status: 'open' }),
626
+ after: '',
627
+ },
628
+ );
629
+ const rawMarkdownRef = { current: raw };
630
+ const params = defaultParams({
631
+ rawMarkdown: raw,
632
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
633
+ setRawMarkdown,
634
+ saveFile,
635
+ enableResolve: true,
636
+ });
637
+ const { result } = renderHook(() => useComments(params));
638
+
639
+ act(() => result.current.handleBulkResolve());
640
+
641
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
642
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
643
+ expect(savedContent).toContain('"status":"resolved"');
644
+ // Both should be resolved
645
+ const matches = savedContent.match(/"status":"resolved"/g);
646
+ expect(matches).toHaveLength(2);
647
+ });
648
+ });
649
+
650
+ describe('handleBulkDeleteResolved', () => {
651
+ it('removes only resolved comments', () => {
652
+ const setRawMarkdown = vi.fn();
653
+ const saveFile = vi.fn();
654
+ const raw = rawWithComments(
655
+ { before: 'A ', marker: makeComment({ id: 'c1', anchor: 'A', status: 'open' }), after: '' },
656
+ {
657
+ before: ' B ',
658
+ marker: makeComment({ id: 'c2', anchor: 'B', status: 'resolved' }),
659
+ after: '',
660
+ },
661
+ );
662
+ const rawMarkdownRef = { current: raw };
663
+ const params = defaultParams({
664
+ rawMarkdown: raw,
665
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
666
+ setRawMarkdown,
667
+ saveFile,
668
+ enableResolve: true,
669
+ });
670
+ const { result } = renderHook(() => useComments(params));
671
+
672
+ act(() => result.current.handleBulkDeleteResolved());
673
+
674
+ expect(setRawMarkdown).toHaveBeenCalledTimes(1);
675
+ const savedContent = setRawMarkdown.mock.calls[0][0] as string;
676
+ // c1 (open) should remain
677
+ expect(savedContent).toContain('"id":"c1"');
678
+ // c2 (resolved) should be removed
679
+ expect(savedContent).not.toContain('"id":"c2"');
680
+ });
681
+ });
682
+
683
+ describe('handleHighlightClick', () => {
684
+ it('sets activeCommentId', () => {
685
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello' })}world`;
686
+ const params = defaultParams({ rawMarkdown: raw });
687
+ const { result } = renderHook(() => useComments(params));
688
+
689
+ act(() => result.current.handleHighlightClick('c1'));
690
+ expect(result.current.activeCommentId).toBe('c1');
691
+ });
692
+ });
693
+
694
+ describe('handleSidebarActivate', () => {
695
+ it('sets activeCommentId and scrolls both viewers', () => {
696
+ const viewerRef = {
697
+ current: { scrollToComment: vi.fn() },
698
+ } as unknown as UseCommentsParams['viewerRef'];
699
+ const rawViewRef = {
700
+ current: { scrollToComment: vi.fn() },
701
+ } as unknown as UseCommentsParams['rawViewRef'];
702
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello' })}world`;
703
+ const params = defaultParams({ rawMarkdown: raw, viewerRef, rawViewRef });
704
+ const { result } = renderHook(() => useComments(params));
705
+
706
+ act(() => result.current.handleSidebarActivate('c1'));
707
+
708
+ expect(result.current.activeCommentId).toBe('c1');
709
+ expect(viewerRef.current!.scrollToComment).toHaveBeenCalledWith('c1');
710
+ expect(rawViewRef.current!.scrollToComment).toHaveBeenCalledWith('c1');
711
+ });
712
+ });
713
+
714
+ // -----------------------------------------------------------------------
715
+ // updateAndSave — synchronous ref update for back-to-back mutations
716
+ // -----------------------------------------------------------------------
717
+ describe('back-to-back mutations', () => {
718
+ it('second mutation sees the first mutation via rawMarkdownRef', () => {
719
+ const setRawMarkdown = vi.fn();
720
+ const saveFile = vi.fn();
721
+ const raw = `Hello ${makeComment({ id: 'c1', anchor: 'Hello', status: 'open' })}${makeComment({ id: 'c2', anchor: 'Hello', status: 'open' })}world`;
722
+ const rawMarkdownRef = { current: raw };
723
+ const params = defaultParams({
724
+ rawMarkdown: raw,
725
+ rawMarkdownRef: rawMarkdownRef as unknown as UseCommentsParams['rawMarkdownRef'],
726
+ setRawMarkdown,
727
+ saveFile,
728
+ enableResolve: true,
729
+ });
730
+ const { result } = renderHook(() => useComments(params));
731
+
732
+ // Resolve c1, then immediately delete c2 in the same synchronous block.
733
+ // Without the ref fix, handleDelete would read the pre-resolve rawMarkdownRef
734
+ // and the resolve would be lost.
735
+ act(() => {
736
+ result.current.handleResolve('c1');
737
+ result.current.handleDelete('c2');
738
+ });
739
+
740
+ expect(setRawMarkdown).toHaveBeenCalledTimes(2);
741
+ expect(saveFile).toHaveBeenCalledTimes(2);
742
+
743
+ // The second call should contain the resolve from the first call
744
+ const secondContent = setRawMarkdown.mock.calls[1][0] as string;
745
+ // c1 should be resolved (still present with status resolved)
746
+ expect(secondContent).toContain('"id":"c1"');
747
+ expect(secondContent).toContain('"status":"resolved"');
748
+ // c2 should be deleted
749
+ expect(secondContent).not.toContain('"id":"c2"');
750
+
751
+ // rawMarkdownRef should reflect the final state
752
+ expect(rawMarkdownRef.current).toBe(secondContent);
753
+ });
754
+ });
755
+
756
+ describe('navigation with no comments', () => {
757
+ it('handleJumpToNext does nothing with no comments', () => {
758
+ const params = defaultParams({ rawMarkdown: 'No comments here' });
759
+ const { result } = renderHook(() => useComments(params));
760
+
761
+ act(() => result.current.handleJumpToNext());
762
+ expect(result.current.activeCommentId).toBeNull();
763
+ });
764
+
765
+ it('handleJumpToPrev does nothing with no comments', () => {
766
+ const params = defaultParams({ rawMarkdown: 'No comments here' });
767
+ const { result } = renderHook(() => useComments(params));
768
+
769
+ act(() => result.current.handleJumpToPrev());
770
+ expect(result.current.activeCommentId).toBeNull();
771
+ });
772
+ });
773
+ });