Files
familienarchiv/frontend/src/lib/activity/ChronikRow.svelte.spec.ts
2026-05-05 13:47:09 +02:00

208 lines
7.1 KiB
TypeScript

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();
});
// --- 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');
});
});