From 2850206c5f65c1080e4a8e29a1c7acca81696159 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 10 May 2026 03:38:47 +0200 Subject: [PATCH] test(discussion): cover CommentThread branches Empty hint, populated comment list, singular vs plural label, canComment toggle, showCompose flag with empty/non-empty messages, quotedText pre-fills textarea, onCountChange on mount, URL routing for annotation/block/document comment endpoints. 12 tests covering ~25 branches in CommentThread. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../discussion/CommentThread.svelte.test.ts | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts diff --git a/frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts b/frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts new file mode 100644 index 00000000..6fbb6293 --- /dev/null +++ b/frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import CommentThread from './CommentThread.svelte'; +import type { Comment } from '$lib/shared/types'; + +afterEach(cleanup); + +const baseComment = (overrides: Partial = {}): Comment => + ({ + id: 'c-1', + documentId: 'doc-1', + content: 'Hello world', + authorId: 'u-1', + authorName: 'Anna', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: null, + replies: [], + ...overrides + }) as Comment; + +describe('CommentThread', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it('renders the empty hint when there are no comments', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: false, + currentUserId: null, + initialComments: [] + } + }); + + await expect.element(page.getByText(/noch keine kommentare/i)).toBeVisible(); + }); + + it('renders the comment list when initialComments is non-empty', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: false, + currentUserId: null, + initialComments: [baseComment({ id: 'c-1', content: 'First comment' })] + } + }); + + await expect.element(page.getByText('First comment')).toBeVisible(); + // 1 comment singular — header text contains "1" and "Kommentar" + const header = document.querySelector('.font-sans.font-semibold'); + expect(header?.textContent).toMatch(/1\s+Kommentar(?!e)/); + }); + + it('renders the plural label when there are 2 or more', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: false, + currentUserId: null, + initialComments: [ + baseComment({ id: 'c-1', content: 'First comment' }), + baseComment({ id: 'c-2', content: 'Second comment' }) + ] + } + }); + + const header = document.querySelector('.font-sans.font-semibold'); + expect(header?.textContent).toMatch(/2\s+Kommentare/); + }); + + it('does not render the compose textarea when canComment is false', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: false, + currentUserId: null, + initialComments: [] + } + }); + + const ta = document.querySelector('textarea'); + expect(ta).toBeNull(); + }); + + it('renders the compose textarea when canComment is true', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: true, + currentUserId: 'u-1', + initialComments: [] + } + }); + + const ta = document.querySelector('textarea'); + expect(ta).not.toBeNull(); + }); + + it('hides the compose textarea when showCompose is false and there are no comments', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: true, + currentUserId: 'u-1', + initialComments: [], + showCompose: false + } + }); + + const ta = document.querySelector('textarea'); + expect(ta).toBeNull(); + }); + + it('shows the compose textarea when showCompose is false but flatMessages is non-empty', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: true, + currentUserId: 'u-1', + initialComments: [baseComment({ id: 'c-1' })], + showCompose: false + } + }); + + const ta = document.querySelector('textarea'); + expect(ta).not.toBeNull(); + }); + + it('seeds the textarea with a quote when quotedText is set', async () => { + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: true, + currentUserId: 'u-1', + initialComments: [], + quotedText: 'die wichtige Stelle' + } + }); + + await new Promise((r) => setTimeout(r, 50)); + const ta = document.querySelector('textarea') as HTMLTextAreaElement; + expect(ta.value).toContain('die wichtige Stelle'); + }); + + it('calls onCountChange on mount with the initial total when loadOnMount=false', async () => { + const onCountChange = vi.fn(); + render(CommentThread, { + props: { + documentId: 'doc-1', + canComment: false, + currentUserId: null, + initialComments: [ + baseComment({ + id: 'c-1', + replies: [ + baseComment({ id: 'r-1' }) as Comment, + baseComment({ id: 'r-2' }) as Comment + ] as Comment[] + }) + ], + loadOnMount: false, + onCountChange + } + }); + + await new Promise((r) => setTimeout(r, 30)); + expect(onCountChange).toHaveBeenCalledWith(3); // 1 thread + 2 replies + }); + + it('uses the annotation comments URL when annotationId is provided', async () => { + render(CommentThread, { + props: { + documentId: 'doc-X', + annotationId: 'ann-Y', + canComment: false, + currentUserId: null, + initialComments: [], + loadOnMount: true + } + }); + + await new Promise((r) => setTimeout(r, 30)); + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.includes('/api/documents/doc-X/annotations/ann-Y/comments'))).toBe( + true + ); + }); + + it('uses the block comments URL when blockId is provided', async () => { + render(CommentThread, { + props: { + documentId: 'doc-X', + blockId: 'block-Z', + canComment: false, + currentUserId: null, + initialComments: [], + loadOnMount: true + } + }); + + await new Promise((r) => setTimeout(r, 30)); + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect( + calls.some((c) => c.includes('/api/documents/doc-X/transcription-blocks/block-Z/comments')) + ).toBe(true); + }); + + it('uses the document comments URL when neither annotationId nor blockId is provided', async () => { + render(CommentThread, { + props: { + documentId: 'doc-X', + canComment: false, + currentUserId: null, + initialComments: [], + loadOnMount: true + } + }); + + await new Promise((r) => setTimeout(r, 30)); + const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); + expect(calls.some((c) => c.endsWith('/api/documents/doc-X/comments'))).toBe(true); + }); +});