Replaces 8 setTimeout sleeps with vi.waitFor on the actual signal (textarea value, fetch URL recorded, onCountChange call) and converts 3 .not.toThrow smoke tests into behavioural assertions: - "no onCountChange wired" → asserts initial comment text still renders - "network error during reload" → asserts empty-hint state is shown - "non-OK reload" → asserts empty-hint state is shown Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
392 lines
9.8 KiB
TypeScript
392 lines
9.8 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();
|
|
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();
|
|
});
|
|
});
|