Files
familienarchiv/frontend/src/lib/shared/discussion/CommentThread.svelte.test.ts
Marcel a93034a8d7 test(discussion): expand CommentThread coverage
Adds onCountChange-after-reload (loadOnMount=true), null currentUserId
isOwn behavior, replies flattened in display.

3 new tests covering ~10 branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00

313 lines
8.0 KiB
TypeScript

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);
});
it('does not call onCountChange when loadOnMount=true (initial render path)', async () => {
const onCountChange = vi.fn();
// Mock the reload fetch to return one comment so onCountChange fires from reload, not from mount-effect
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 new Promise((r) => setTimeout(r, 50));
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' })]
}
});
await new Promise((r) => setTimeout(r, 30));
// No edit/delete buttons because none 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', 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');
});
});