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 <noreply@anthropic.com>
This commit is contained in:
235
frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts
Normal file
235
frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts
Normal file
@@ -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> = {}): 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<typeof vi.spyOn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user