import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import ChronikRow from './ChronikRow.svelte'; import type { components } from '$lib/generated/api'; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; afterEach(cleanup); const baseItem: ActivityFeedItemDTO = { kind: 'TEXT_SAVED', actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' }, documentId: 'doc-1', documentTitle: 'Brief 1920', happenedAt: '2026-04-19T10:00:00Z', youMentioned: false, count: 1 }; describe('ChronikRow', () => { it('renders the document title', async () => { render(ChronikRow, { item: baseItem }); await expect.element(page.getByText('Brief 1920')).toBeInTheDocument(); }); it('renders actor initials in avatar', async () => { render(ChronikRow, { item: baseItem }); await expect.element(page.getByText('MR')).toBeInTheDocument(); }); it('renders "?" fallback avatar when actor is missing', async () => { const item: ActivityFeedItemDTO = { ...baseItem, actor: undefined }; render(ChronikRow, { item }); const fallback = document.querySelector('[data-testid="chronik-avatar-fallback"]'); expect(fallback).not.toBeNull(); expect(fallback?.textContent?.trim()).toBe('?'); }); it('wraps the row in a link to the document', async () => { render(ChronikRow, { item: baseItem }); const link = document.querySelector('a[href="/documents/doc-1"]'); expect(link).not.toBeNull(); }); // --- simple variant --- it('renders simple variant when count === 1 and not a mention', async () => { render(ChronikRow, { item: baseItem }); // No rollup count badge expect(document.querySelector('[data-testid="chronik-count-badge"]')).toBeNull(); // No for-you marker expect(document.querySelector('[data-testid="chronik-foryou-marker"]')).toBeNull(); // No comment preview expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull(); }); // --- rollup variant --- it('renders rollup variant with count badge when count > 1', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED', count: 3, happenedAt: '2026-04-19T10:00:00Z', happenedAtUntil: '2026-04-19T11:30:00Z' }; render(ChronikRow, { item }); const badge = document.querySelector('[data-testid="chronik-count-badge"]'); expect(badge).not.toBeNull(); expect(badge?.textContent).toContain('3'); }); it('renders a time range with an en-dash for rollup variant', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'FILE_UPLOADED', count: 5, happenedAt: '2026-04-19T10:00:00Z', happenedAtUntil: '2026-04-19T11:30:00Z' }; render(ChronikRow, { item }); // en-dash character U+2013 const body = document.body.textContent ?? ''; expect(body).toContain('\u2013'); }); // --- for-you variant --- it('renders for-you marker when youMentioned is true', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true }; render(ChronikRow, { item }); const marker = document.querySelector('[data-testid="chronik-foryou-marker"]'); expect(marker).not.toBeNull(); }); it('applies accent border to for-you variant outer wrapper', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true }; render(ChronikRow, { item }); const wrapper = document.querySelector('[data-variant="for-you"]'); expect(wrapper).not.toBeNull(); expect(wrapper?.className).toContain('border-accent'); }); // --- comment variant --- it('renders comment preview for COMMENT_ADDED kind', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED' }; render(ChronikRow, { item }); const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); expect(preview).not.toBeNull(); }); it('comment preview does NOT duplicate the document title verbatim', async () => { // Leonie: user sees the title twice otherwise — looks like the comment is quoting itself. // Until the backend exposes item.commentPreview, the placeholder must be distinct. const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', documentTitle: 'Brief vom 12. Juli 1920' }; render(ChronikRow, { item }); const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); expect(preview).not.toBeNull(); expect(preview?.textContent).not.toContain('Brief vom 12. Juli 1920'); }); // --- deep-link href for comment events --- it('links to /documents/:id?commentId=…&annotationId=… for COMMENT_ADDED', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', commentId: 'comment-7', annotationId: 'annot-9' }; render(ChronikRow, { item }); const link = document.querySelector( 'a[href="/documents/doc-1?commentId=comment-7&annotationId=annot-9"]' ); expect(link).not.toBeNull(); }); it('links to /documents/:id?commentId=…&annotationId=… for MENTION_CREATED', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true, commentId: 'comment-8', annotationId: 'annot-11' }; render(ChronikRow, { item }); const link = document.querySelector( 'a[href="/documents/doc-1?commentId=comment-8&annotationId=annot-11"]' ); expect(link).not.toBeNull(); }); it('falls back to bare document href when commentId is absent on a comment row', async () => { // Back-compat for old/missing backend payloads. Still navigates sensibly. const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED' }; render(ChronikRow, { item }); const link = document.querySelector('a[href="/documents/doc-1"]'); expect(link).not.toBeNull(); }); it('links to commentId-only URL when commentId is set but annotationId is absent', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', commentId: 'comment-7' // annotationId absent — comment on a non-annotation block }; render(ChronikRow, { item }); const link = document.querySelector('a[href="/documents/doc-1?commentId=comment-7"]'); expect(link).not.toBeNull(); }); // --- commentPreview content --- it('renders commentPreview text when variant is comment and commentPreview is present', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', commentPreview: 'Hello family, great letter!' }; render(ChronikRow, { item }); const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); expect(preview).not.toBeNull(); expect(preview?.textContent).toContain('Hello family, great letter!'); }); it('renders placeholder ellipsis when variant is comment and commentPreview is null', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', commentPreview: undefined }; render(ChronikRow, { item }); const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); expect(preview).not.toBeNull(); expect(preview?.textContent?.trim()).toBe('„…"'); }); it('does not render preview paragraph for non-comment variants', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED' }; render(ChronikRow, { item }); expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull(); }); it('link has aria-label containing preview text for comment variant with preview', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', commentPreview: 'A wonderful letter from grandma' }; render(ChronikRow, { item }); const link = document.querySelector('a[aria-label]'); expect(link).not.toBeNull(); expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma'); }); it('link still has aria-label for comment variant when commentPreview is absent', async () => { const item: ActivityFeedItemDTO = { ...baseItem, kind: 'COMMENT_ADDED', commentPreview: undefined }; render(ChronikRow, { item }); const link = document.querySelector('a[aria-label]'); expect(link).not.toBeNull(); expect(link?.getAttribute('aria-label')).not.toBeNull(); }); // --- robustness: title rendering for edge cases --- it('still renders the row link when documentTitle is an empty string', async () => { // Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span // collapsed and before/after both emptied out. Swap to a sentinel-based // approach so this case renders like every other row. const empty: ActivityFeedItemDTO = { ...baseItem, documentTitle: '' }; render(ChronikRow, { item: empty }); const link = document.querySelector('a[href="/documents/doc-1"]'); expect(link).not.toBeNull(); }); it('renders a short document title that could substring-match the verb', async () => { const short: ActivityFeedItemDTO = { ...baseItem, documentTitle: 'Brief' }; render(ChronikRow, { item: short }); const titleEls = document.querySelectorAll('[data-testid="chronik-doc-title"]'); expect(titleEls.length).toBe(1); expect(titleEls[0].textContent).toBe('Brief'); }); });