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(); 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 vi.waitFor(() => { 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 } }); // 1 thread + 2 replies = 3. await vi.waitFor(() => expect(onCountChange).toHaveBeenCalledWith(3)); }); 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 vi.waitFor(() => { 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 vi.waitFor(() => { 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 vi.waitFor(() => { const calls = fetchSpy.mock.calls.map((c) => c[0].toString()); expect(calls.some((c) => c.endsWith('/api/documents/doc-X/comments'))).toBe(true); }); }); it('fires onCountChange with the loaded comment count after a successful reload', async () => { const onCountChange = vi.fn(); fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify([ { id: 'c-1', documentId: 'doc-1', content: 'Loaded', authorId: 'u-1', authorName: 'Anna', createdAt: '2026-01-01T00:00:00Z', updatedAt: null, replies: [] } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ) ); render(CommentThread, { props: { documentId: 'doc-1', canComment: false, currentUserId: null, initialComments: [], loadOnMount: true, onCountChange } }); await vi.waitFor(() => expect(onCountChange).toHaveBeenCalledWith(1)); }); it('treats currentUserId=null as never owning a comment', async () => { render(CommentThread, { props: { documentId: 'doc-1', canComment: true, currentUserId: null, initialComments: [baseComment({ id: 'c-1', authorId: 'u-1' })] } }); // No edit/delete buttons because no comment is "own". const editBtns = Array.from(document.querySelectorAll('button')).filter((b) => /bearbeiten/i.test(b.textContent ?? '') ); expect(editBtns.length).toBe(0); }); it('flat-messages flattens replies into the rendered list', async () => { render(CommentThread, { props: { documentId: 'doc-1', canComment: false, currentUserId: null, initialComments: [ { ...baseComment({ id: 'c-1', content: 'Top' }), replies: [ baseComment({ id: 'r-1', content: 'Reply 1' }), baseComment({ id: 'r-2', content: 'Reply 2' }) ] } as Comment ] } }); expect(document.body.textContent).toContain('Top'); expect(document.body.textContent).toContain('Reply 1'); expect(document.body.textContent).toContain('Reply 2'); }); it('does not seed quotedText when it is whitespace-only', async () => { render(CommentThread, { props: { documentId: 'doc-1', canComment: true, currentUserId: 'u-1', initialComments: [], quotedText: ' ' } }); await vi.waitFor(() => { expect(document.querySelector('textarea')).not.toBeNull(); }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; expect(ta.value).toBe(''); }); it('renders the initial comment when onCountChange is not provided', async () => { // Component must not assume the callback is wired up; verify content still renders. render(CommentThread, { props: { documentId: 'doc-1', canComment: false, currentUserId: null, initialComments: [baseComment()], loadOnMount: false } }); await expect.element(page.getByText('Hello world')).toBeVisible(); }); it('keeps the empty-hint state when reload fetch rejects (network error)', async () => { fetchSpy.mockRejectedValueOnce(new Error('network down')); render(CommentThread, { props: { documentId: 'doc-1', canComment: false, currentUserId: null, initialComments: [], loadOnMount: true } }); // On rejection the component swallows the error and falls back to empty state. await expect.element(page.getByText(/noch keine kommentare/i)).toBeVisible(); }); it('keeps the empty-hint state when reload returns non-OK status', async () => { fetchSpy.mockResolvedValueOnce(new Response('error', { status: 500 })); render(CommentThread, { props: { documentId: 'doc-1', canComment: false, currentUserId: null, initialComments: [], loadOnMount: true } }); await expect.element(page.getByText(/noch keine kommentare/i)).toBeVisible(); }); it('renders own comment when authorId matches currentUserId', async () => { render(CommentThread, { props: { documentId: 'doc-1', canComment: true, currentUserId: 'u-self', initialComments: [baseComment({ id: 'c-mine', authorId: 'u-self', content: 'mine' })] } }); await expect.element(page.getByText('mine')).toBeVisible(); }); });