Coves frontend - a photon fork
1import { describe, it, expect, vi, beforeEach } from 'vitest'
2import type {
3 AtUri,
4 CID,
5 CommentRecord,
6 CommentRef,
7 CommentStats,
8 CommentView,
9 CommentViewerState,
10 AuthorView,
11 ThreadViewComment,
12} from '$lib/api/coves/types'
13import type { DID, Handle } from '$lib/types/atproto'
14import {
15 buildCommentsTree,
16 searchCommentTree,
17 insertCommentIntoTree,
18 type CommentNodeI,
19} from './comments.svelte'
20
21// ---------------------------------------------------------------------------
22// Mock i18n
23// ---------------------------------------------------------------------------
24
25vi.mock('$lib/app/i18n', () => ({
26 t: {
27 get: (key: string) => key,
28 },
29}))
30
31// ---------------------------------------------------------------------------
32// Test fixtures
33// ---------------------------------------------------------------------------
34
35const testDid = 'did:plc:testauthor1' as DID
36const testHandle = 'alice.coves.social' as Handle
37
38function makeAuthor(overrides?: Partial<AuthorView>): AuthorView {
39 return {
40 did: testDid,
41 handle: testHandle,
42 displayName: 'Alice',
43 ...overrides,
44 }
45}
46
47function makeCommentRecord(
48 content: string = 'Test comment content',
49): CommentRecord {
50 return {
51 $type: 'social.coves.community.comment',
52 content,
53 reply: {
54 root: {
55 uri: 'at://did:plc:post/social.coves.community.post/root1' as AtUri,
56 cid: 'bafyroot1' as CID,
57 },
58 parent: {
59 uri: 'at://did:plc:post/social.coves.community.post/root1' as AtUri,
60 cid: 'bafyroot1' as CID,
61 },
62 },
63 createdAt: '2024-06-01T12:00:00Z',
64 }
65}
66
67function makeStats(overrides?: Partial<CommentStats>): CommentStats {
68 return {
69 upvotes: 5,
70 downvotes: 1,
71 score: 4,
72 replyCount: 0,
73 ...overrides,
74 }
75}
76
77function makeCommentView(overrides?: Partial<CommentView>): CommentView {
78 return {
79 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c1' as AtUri,
80 cid: 'bafycomment1' as CID,
81 createdAt: '2024-06-01T12:00:00Z',
82 indexedAt: '2024-06-01T12:00:01Z',
83 record: makeCommentRecord(),
84 author: makeAuthor(),
85 post: {
86 uri: 'at://did:plc:post/social.coves.community.post/root1' as AtUri,
87 cid: 'bafypost1' as CID,
88 },
89 stats: makeStats(),
90 ...overrides,
91 }
92}
93
94function makeThreadComment(
95 cv: CommentView,
96 replies?: ThreadViewComment[],
97 hasMore?: boolean,
98): ThreadViewComment {
99 return {
100 comment: cv,
101 replies,
102 hasMore,
103 }
104}
105
106// ---------------------------------------------------------------------------
107// buildCommentsTree()
108// ---------------------------------------------------------------------------
109
110describe('buildCommentsTree', () => {
111 it('returns an empty array for empty input', () => {
112 const result = buildCommentsTree([])
113 expect(result).toEqual([])
114 })
115
116 it('converts a single ThreadViewComment into a single CommentNodeI', () => {
117 const cv = makeCommentView()
118 const thread = makeThreadComment(cv)
119
120 const result = buildCommentsTree([thread])
121
122 expect(result).toHaveLength(1)
123 expect(result[0].comment.uri).toBe(cv.uri)
124 expect(result[0].depth).toBe(0)
125 expect(result[0].children).toEqual([])
126 expect(result[0].expanded).toBe(true)
127 })
128
129 it('converts multiple top-level ThreadViewComments', () => {
130 const cv1 = makeCommentView({
131 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c1' as AtUri,
132 })
133 const cv2 = makeCommentView({
134 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c2' as AtUri,
135 })
136
137 const result = buildCommentsTree([
138 makeThreadComment(cv1),
139 makeThreadComment(cv2),
140 ])
141
142 expect(result).toHaveLength(2)
143 expect(result[0].comment.uri).toBe(cv1.uri)
144 expect(result[1].comment.uri).toBe(cv2.uri)
145 expect(result[0].depth).toBe(0)
146 expect(result[1].depth).toBe(0)
147 })
148
149 it('recursively builds children with increasing depth', () => {
150 const grandchild = makeCommentView({
151 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c3' as AtUri,
152 })
153 const child = makeCommentView({
154 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c2' as AtUri,
155 })
156 const root = makeCommentView({
157 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c1' as AtUri,
158 })
159
160 const thread = makeThreadComment(root, [
161 makeThreadComment(child, [makeThreadComment(grandchild)]),
162 ])
163
164 const result = buildCommentsTree([thread])
165
166 expect(result).toHaveLength(1)
167 expect(result[0].depth).toBe(0)
168 expect(result[0].children).toHaveLength(1)
169 expect(result[0].children[0].depth).toBe(1)
170 expect(result[0].children[0].children).toHaveLength(1)
171 expect(result[0].children[0].children[0].depth).toBe(2)
172 expect(result[0].children[0].children[0].comment.uri).toBe(grandchild.uri)
173 })
174
175 it('applies baseDepth offset to all nodes', () => {
176 const child = makeCommentView({
177 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c2' as AtUri,
178 })
179 const root = makeCommentView({
180 uri: 'at://did:plc:testauthor1/social.coves.community.comment/c1' as AtUri,
181 })
182
183 const thread = makeThreadComment(root, [makeThreadComment(child)])
184
185 const result = buildCommentsTree([thread], 3)
186
187 expect(result[0].depth).toBe(3)
188 expect(result[0].children[0].depth).toBe(4)
189 })
190
191 it('annotates deleted comments with empty content', () => {
192 const cv = makeCommentView({
193 isDeleted: true,
194 record: makeCommentRecord(''),
195 })
196 const thread = makeThreadComment(cv)
197
198 const result = buildCommentsTree([thread])
199
200 expect(result[0].comment.record.content).toBe('*post.badges.deleted*')
201 })
202
203 it('does not annotate deleted comments that have content', () => {
204 const cv = makeCommentView({
205 isDeleted: true,
206 record: makeCommentRecord('This comment was deleted but had content'),
207 })
208 const thread = makeThreadComment(cv)
209
210 const result = buildCommentsTree([thread])
211
212 expect(result[0].comment.record.content).toBe(
213 'This comment was deleted but had content',
214 )
215 })
216
217 it('does not annotate non-deleted comments with empty content', () => {
218 const cv = makeCommentView({
219 isDeleted: false,
220 record: makeCommentRecord(''),
221 })
222 const thread = makeThreadComment(cv)
223
224 const result = buildCommentsTree([thread])
225
226 expect(result[0].comment.record.content).toBe('')
227 })
228
229 it('handles ThreadViewComments with undefined replies', () => {
230 const cv = makeCommentView()
231 const thread: ThreadViewComment = {
232 comment: cv,
233 // replies is undefined
234 }
235
236 const result = buildCommentsTree([thread])
237
238 expect(result).toHaveLength(1)
239 expect(result[0].children).toEqual([])
240 })
241
242 it('handles ThreadViewComments with empty replies', () => {
243 const cv = makeCommentView()
244 const thread = makeThreadComment(cv, [])
245
246 const result = buildCommentsTree([thread])
247
248 expect(result).toHaveLength(1)
249 expect(result[0].children).toEqual([])
250 })
251
252 it('preserves hasMore on the source data without affecting CommentNodeI', () => {
253 const cv = makeCommentView()
254 const thread = makeThreadComment(cv, [], true)
255
256 const result = buildCommentsTree([thread])
257
258 expect(result).toHaveLength(1)
259 // CommentNodeI does not carry hasMore; that comes from ThreadViewComment
260 expect(result[0].children).toEqual([])
261 })
262
263 it('handles a complex tree with multiple branches', () => {
264 const c1 = makeCommentView({
265 uri: 'at://did:plc:a/social.coves.community.comment/c1' as AtUri,
266 })
267 const c1a = makeCommentView({
268 uri: 'at://did:plc:a/social.coves.community.comment/c1a' as AtUri,
269 })
270 const c1b = makeCommentView({
271 uri: 'at://did:plc:a/social.coves.community.comment/c1b' as AtUri,
272 })
273 const c2 = makeCommentView({
274 uri: 'at://did:plc:a/social.coves.community.comment/c2' as AtUri,
275 })
276 const c2a = makeCommentView({
277 uri: 'at://did:plc:a/social.coves.community.comment/c2a' as AtUri,
278 })
279
280 const threads: ThreadViewComment[] = [
281 makeThreadComment(c1, [makeThreadComment(c1a), makeThreadComment(c1b)]),
282 makeThreadComment(c2, [makeThreadComment(c2a)]),
283 ]
284
285 const result = buildCommentsTree(threads)
286
287 expect(result).toHaveLength(2)
288 expect(result[0].children).toHaveLength(2)
289 expect(result[0].children[0].comment.uri).toBe(c1a.uri)
290 expect(result[0].children[1].comment.uri).toBe(c1b.uri)
291 expect(result[1].children).toHaveLength(1)
292 expect(result[1].children[0].comment.uri).toBe(c2a.uri)
293 })
294
295 it('does not mutate the input ThreadViewComment data', () => {
296 const cv = makeCommentView({
297 isDeleted: true,
298 record: makeCommentRecord(''),
299 })
300 const thread = makeThreadComment(cv)
301 const originalContent = cv.record.content
302
303 buildCommentsTree([thread])
304
305 // The original input should not be mutated
306 expect(cv.record.content).toBe(originalContent)
307 })
308})
309
310// ---------------------------------------------------------------------------
311// searchCommentTree()
312// ---------------------------------------------------------------------------
313
314describe('searchCommentTree', () => {
315 const c1Uri = 'at://did:plc:a/social.coves.community.comment/c1' as AtUri
316 const c2Uri = 'at://did:plc:a/social.coves.community.comment/c2' as AtUri
317 const c3Uri = 'at://did:plc:a/social.coves.community.comment/c3' as AtUri
318 const missingUri =
319 'at://did:plc:a/social.coves.community.comment/missing' as AtUri
320
321 function makeTree(): CommentNodeI[] {
322 return [
323 {
324 comment: makeCommentView({ uri: c1Uri }),
325 children: [
326 {
327 comment: makeCommentView({ uri: c2Uri }),
328 children: [
329 {
330 comment: makeCommentView({ uri: c3Uri }),
331 children: [],
332 depth: 2,
333 },
334 ],
335 depth: 1,
336 },
337 ],
338 depth: 0,
339 },
340 ]
341 }
342
343 it('finds a root-level node', () => {
344 const tree = makeTree()
345 const found = searchCommentTree(tree, c1Uri)
346 expect(found).toBeDefined()
347 expect(found!.comment.uri).toBe(c1Uri)
348 })
349
350 it('finds a nested child node', () => {
351 const tree = makeTree()
352 const found = searchCommentTree(tree, c2Uri)
353 expect(found).toBeDefined()
354 expect(found!.comment.uri).toBe(c2Uri)
355 })
356
357 it('finds a deeply nested node', () => {
358 const tree = makeTree()
359 const found = searchCommentTree(tree, c3Uri)
360 expect(found).toBeDefined()
361 expect(found!.comment.uri).toBe(c3Uri)
362 })
363
364 it('returns undefined when URI does not exist in tree', () => {
365 const tree = makeTree()
366 const found = searchCommentTree(tree, missingUri)
367 expect(found).toBeUndefined()
368 })
369
370 it('returns undefined for empty tree', () => {
371 const found = searchCommentTree([], c1Uri)
372 expect(found).toBeUndefined()
373 })
374
375 it('finds the correct node when tree has multiple top-level entries', () => {
376 const c4Uri = 'at://did:plc:a/social.coves.community.comment/c4' as AtUri
377 const tree: CommentNodeI[] = [
378 {
379 comment: makeCommentView({ uri: c1Uri }),
380 children: [],
381 depth: 0,
382 },
383 {
384 comment: makeCommentView({ uri: c4Uri }),
385 children: [],
386 depth: 0,
387 },
388 ]
389
390 const found = searchCommentTree(tree, c4Uri)
391 expect(found).toBeDefined()
392 expect(found!.comment.uri).toBe(c4Uri)
393 })
394})
395
396// ---------------------------------------------------------------------------
397// insertCommentIntoTree()
398// ---------------------------------------------------------------------------
399
400describe('insertCommentIntoTree', () => {
401 const rootUri = 'at://did:plc:a/social.coves.community.comment/root' as AtUri
402 const childUri =
403 'at://did:plc:a/social.coves.community.comment/child' as AtUri
404 const newUri = 'at://did:plc:a/social.coves.community.comment/new' as AtUri
405
406 function makeTree(): CommentNodeI[] {
407 return [
408 {
409 comment: makeCommentView({ uri: rootUri }),
410 children: [
411 {
412 comment: makeCommentView({ uri: childUri }),
413 children: [],
414 depth: 1,
415 },
416 ],
417 depth: 0,
418 },
419 ]
420 }
421
422 it('inserts a top-level comment at the beginning of the tree', () => {
423 const tree = makeTree()
424 const cv = makeCommentView({ uri: newUri, parent: undefined })
425
426 const result = insertCommentIntoTree(tree, cv, false)
427
428 expect(result).toBe(true)
429 expect(tree).toHaveLength(2)
430 expect(tree[0].comment.uri).toBe(newUri)
431 expect(tree[0].depth).toBe(0)
432 expect(tree[0].children).toEqual([])
433 })
434
435 it('does not insert a top-level comment when parentComment is true', () => {
436 const tree = makeTree()
437 const cv = makeCommentView({ uri: newUri, parent: undefined })
438
439 const result = insertCommentIntoTree(tree, cv, true)
440
441 expect(result).toBe(false)
442 expect(tree).toHaveLength(1)
443 expect(tree[0].comment.uri).toBe(rootUri)
444 })
445
446 it('inserts a reply as the first child of its parent', () => {
447 const tree = makeTree()
448 const parentRef: CommentRef = {
449 uri: rootUri,
450 cid: 'bafyroot' as CID,
451 }
452 const cv = makeCommentView({ uri: newUri, parent: parentRef })
453
454 const result = insertCommentIntoTree(tree, cv, true)
455
456 expect(result).toBe(true)
457 const rootNode = tree[0]
458 expect(rootNode.children).toHaveLength(2)
459 expect(rootNode.children[0].comment.uri).toBe(newUri)
460 expect(rootNode.children[0].depth).toBe(1)
461 })
462
463 it('inserts a reply to a nested comment with correct depth', () => {
464 const tree = makeTree()
465 const parentRef: CommentRef = {
466 uri: childUri,
467 cid: 'bafychild' as CID,
468 }
469 const cv = makeCommentView({ uri: newUri, parent: parentRef })
470
471 const result = insertCommentIntoTree(tree, cv, true)
472
473 expect(result).toBe(true)
474 const childNode = tree[0].children[0]
475 expect(childNode.children).toHaveLength(1)
476 expect(childNode.children[0].comment.uri).toBe(newUri)
477 expect(childNode.children[0].depth).toBe(2)
478 })
479
480 it('does nothing when parent URI is not found in tree', () => {
481 const tree = makeTree()
482 const parentRef: CommentRef = {
483 uri: 'at://did:plc:a/social.coves.community.comment/nonexistent' as AtUri,
484 cid: 'bafynonexistent' as CID,
485 }
486 const cv = makeCommentView({ uri: newUri, parent: parentRef })
487
488 const originalLength = tree.length
489 const result = insertCommentIntoTree(tree, cv, true)
490
491 expect(result).toBe(false)
492 expect(tree).toHaveLength(originalLength)
493 expect(tree[0].children).toHaveLength(1)
494 })
495
496 it('inserts into an empty tree as top-level when parentComment is false', () => {
497 const tree: CommentNodeI[] = []
498 const cv = makeCommentView({ uri: newUri, parent: undefined })
499
500 const result = insertCommentIntoTree(tree, cv, false)
501
502 expect(result).toBe(true)
503 expect(tree).toHaveLength(1)
504 expect(tree[0].comment.uri).toBe(newUri)
505 expect(tree[0].depth).toBe(0)
506 })
507
508 it('does not insert into empty tree when parentComment is true and no parent', () => {
509 const tree: CommentNodeI[] = []
510 const cv = makeCommentView({ uri: newUri, parent: undefined })
511
512 const result = insertCommentIntoTree(tree, cv, true)
513
514 expect(result).toBe(false)
515 expect(tree).toHaveLength(0)
516 })
517
518 it('warns via console.warn when parent URI is not found', () => {
519 const tree = makeTree()
520 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
521 const parentRef: CommentRef = {
522 uri: 'at://did:plc:a/social.coves.community.comment/nonexistent' as AtUri,
523 cid: 'bafynonexistent' as CID,
524 }
525 const cv = makeCommentView({ uri: newUri, parent: parentRef })
526
527 const result = insertCommentIntoTree(tree, cv, true)
528
529 expect(result).toBe(false)
530 expect(warnSpy).toHaveBeenCalledOnce()
531 expect(warnSpy.mock.calls[0][0]).toContain('Parent node not found')
532 warnSpy.mockRestore()
533 })
534
535 it('inserts sequential replies in LIFO order (most recent first)', () => {
536 const tree = makeTree()
537 const firstUri =
538 'at://did:plc:a/social.coves.community.comment/first' as AtUri
539 const secondUri =
540 'at://did:plc:a/social.coves.community.comment/second' as AtUri
541 const parentRef: CommentRef = {
542 uri: rootUri,
543 cid: 'bafyroot' as CID,
544 }
545
546 const cv1 = makeCommentView({ uri: firstUri, parent: parentRef })
547 const cv2 = makeCommentView({ uri: secondUri, parent: parentRef })
548
549 insertCommentIntoTree(tree, cv1, true)
550 insertCommentIntoTree(tree, cv2, true)
551
552 const rootNode = tree[0]
553 // Second insertion should be first (unshift = LIFO)
554 expect(rootNode.children[0].comment.uri).toBe(secondUri)
555 expect(rootNode.children[1].comment.uri).toBe(firstUri)
556 })
557})