Coves frontend - a photon fork
at main 557 lines 17 kB view raw
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})