diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e45d2a22..b07e377b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,13 +36,7 @@ jobs: run: npm run lint working-directory: frontend - - name: Run unit and component tests - run: npm test - working-directory: frontend - env: - TZ: Europe/Berlin - - - name: Run coverage (server + client) + - name: Run unit and component tests with coverage run: npm run test:coverage working-directory: frontend env: diff --git a/CLAUDE.md b/CLAUDE.md index 3628e703..09e8d2c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,8 +202,7 @@ frontend/src/routes/ ├── profile/ User profile settings ├── users/[id]/ Public user profile page ├── login/ logout/ register/ -├── forgot-password/ reset-password/ -└── demo/ Dev-only demos +└── forgot-password/ reset-password/ ``` ### API Client Pattern diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 8b912685..c58a26c6 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -40,8 +40,7 @@ src/ │ ├── profile/ # User profile settings │ ├── users/[id]/ # Public user profile page │ ├── login/ logout/ register/ -│ ├── forgot-password/ reset-password/ -│ └── demo/ # Dev-only demos +│ └── forgot-password/ reset-password/ ├── lib/ # Domain-based package structure (mirrors backend) │ ├── document/ # Document domain: components, stores, services, utils │ │ ├── annotation/ # Annotation overlay components diff --git a/frontend/src/lib/activity/ChronikEmptyState.svelte.test.ts b/frontend/src/lib/activity/ChronikEmptyState.svelte.test.ts new file mode 100644 index 00000000..920a76db --- /dev/null +++ b/frontend/src/lib/activity/ChronikEmptyState.svelte.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ChronikEmptyState from './ChronikEmptyState.svelte'; + +afterEach(cleanup); + +describe('ChronikEmptyState', () => { + it('renders the first-run title and body and the clock icon', async () => { + render(ChronikEmptyState, { props: { variant: 'first-run' as const } }); + + await expect.element(page.getByText('Noch nichts geschehen')).toBeVisible(); + await expect.element(page.getByText(/sobald jemand aus der familie/i)).toBeVisible(); + + const wrapper = document.querySelector('[data-testid="chronik-empty-state"]'); + expect(wrapper?.getAttribute('data-variant')).toBe('first-run'); + }); + + it('renders the filter-empty title and body', async () => { + render(ChronikEmptyState, { props: { variant: 'filter-empty' as const } }); + + await expect.element(page.getByText('Nichts in dieser Ansicht')).toBeVisible(); + await expect.element(page.getByText('In diesem Filter gibt es keine Einträge.')).toBeVisible(); + + const wrapper = document.querySelector('[data-testid="chronik-empty-state"]'); + expect(wrapper?.getAttribute('data-variant')).toBe('filter-empty'); + }); + + it('renders the inbox-zero title and no body paragraph', async () => { + render(ChronikEmptyState, { props: { variant: 'inbox-zero' as const } }); + + await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeVisible(); + + // Only one

(the title) since body is empty + const wrapper = document.querySelector('[data-testid="chronik-empty-state"]'); + const paragraphs = wrapper?.querySelectorAll('p'); + expect(paragraphs?.length).toBe(1); + expect(wrapper?.getAttribute('data-variant')).toBe('inbox-zero'); + }); + + it('uses the accent color icon for inbox-zero (vs ink-3 for others)', async () => { + render(ChronikEmptyState, { props: { variant: 'inbox-zero' as const } }); + + const wrapper = document.querySelector('[data-testid="chronik-empty-state"]'); + const svg = wrapper?.querySelector('svg'); + expect(svg?.getAttribute('class')).toContain('text-accent'); + }); + + it('uses the ink-3 color icon for first-run', async () => { + render(ChronikEmptyState, { props: { variant: 'first-run' as const } }); + + const wrapper = document.querySelector('[data-testid="chronik-empty-state"]'); + const svg = wrapper?.querySelector('svg'); + expect(svg?.getAttribute('class')).toContain('text-ink-3'); + }); +}); diff --git a/frontend/src/lib/activity/ChronikErrorCard.svelte.test.ts b/frontend/src/lib/activity/ChronikErrorCard.svelte.test.ts new file mode 100644 index 00000000..8a516501 --- /dev/null +++ b/frontend/src/lib/activity/ChronikErrorCard.svelte.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ChronikErrorCard from './ChronikErrorCard.svelte'; + +afterEach(cleanup); + +describe('ChronikErrorCard', () => { + it('renders the default error message when no message is supplied', async () => { + render(ChronikErrorCard, { props: { onRetry: () => {} } }); + + await expect.element(page.getByText(/Aktivitäten konnten nicht/i)).toBeVisible(); + }); + + it('renders the supplied message when provided', async () => { + render(ChronikErrorCard, { + props: { onRetry: () => {}, message: 'Custom error message' } + }); + + await expect.element(page.getByText('Custom error message')).toBeVisible(); + }); + + it('calls onRetry when the retry button is clicked', async () => { + const onRetry = vi.fn(); + render(ChronikErrorCard, { props: { onRetry } }); + + await page.getByRole('button', { name: /erneut versuchen/i }).click(); + + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('marks the card as role="alert" for assistive tech', async () => { + render(ChronikErrorCard, { props: { onRetry: () => {} } }); + + await expect.element(page.getByRole('alert')).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/activity/ChronikFilterPills.svelte.test.ts b/frontend/src/lib/activity/ChronikFilterPills.svelte.test.ts new file mode 100644 index 00000000..c04fdf4b --- /dev/null +++ b/frontend/src/lib/activity/ChronikFilterPills.svelte.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ChronikFilterPills from './ChronikFilterPills.svelte'; + +afterEach(cleanup); + +describe('ChronikFilterPills', () => { + it('renders the radiogroup with the label', async () => { + render(ChronikFilterPills, { props: { value: 'alle' as const, onChange: () => {} } }); + + await expect + .element(page.getByRole('radiogroup', { name: /aktivitäten filtern/i })) + .toBeVisible(); + }); + + it('renders all five filter pills', async () => { + render(ChronikFilterPills, { props: { value: 'alle' as const, onChange: () => {} } }); + + const radios = document.querySelectorAll('[role="radio"]'); + expect(radios.length).toBe(5); + }); + + it('marks the active filter as aria-checked=true', async () => { + render(ChronikFilterPills, { props: { value: 'fuer-dich' as const, onChange: () => {} } }); + + const active = document.querySelector('[data-filter-value="fuer-dich"]') as HTMLElement; + expect(active.getAttribute('aria-checked')).toBe('true'); + }); + + it('sets tabindex=0 on the active pill and -1 on others', async () => { + render(ChronikFilterPills, { props: { value: 'kommentare' as const, onChange: () => {} } }); + + const active = document.querySelector('[data-filter-value="kommentare"]') as HTMLElement; + const others = Array.from(document.querySelectorAll('[role="radio"]')).filter( + (el) => el !== active + ) as HTMLElement[]; + expect(active.tabIndex).toBe(0); + others.forEach((el) => expect(el.tabIndex).toBe(-1)); + }); + + it('calls onChange with the new filter value when clicked', async () => { + const onChange = vi.fn(); + render(ChronikFilterPills, { props: { value: 'alle' as const, onChange } }); + + const transcription = document.querySelector( + '[data-filter-value="transkription"]' + ) as HTMLElement; + transcription.click(); + + expect(onChange).toHaveBeenCalledWith('transkription'); + }); +}); diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts new file mode 100644 index 00000000..6b887a86 --- /dev/null +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; +import type { NotificationItem } from '$lib/notification/notifications'; + +afterEach(cleanup); + +const mention = (overrides: Partial = {}): NotificationItem => ({ + id: 'n-1', + type: 'MENTION', + documentId: 'doc-1', + referenceId: 'ref-1', + annotationId: null, + read: false, + createdAt: new Date().toISOString(), + actorName: 'Anna', + documentTitle: 'Brief 1899', + ...overrides +}); + +describe('ChronikFuerDichBox', () => { + it('renders the inbox-zero state when there are no unread', async () => { + render(ChronikFuerDichBox, { + props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} } + }); + + await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); + const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); + expect(link).not.toBeNull(); + }); + + it('renders the count badge with the unread count', async () => { + render(ChronikFuerDichBox, { + props: { + unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], + onMarkRead: () => {}, + onMarkAllRead: () => {} + } + }); + + const badge = document.querySelector('[data-testid="chronik-fuerdich-count"]'); + expect(badge?.textContent).toContain('3'); + }); + + it('uses the @ glyph for MENTION and ↩ for REPLY', async () => { + render(ChronikFuerDichBox, { + props: { + unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], + onMarkRead: () => {}, + onMarkAllRead: () => {} + } + }); + + const items = document.querySelectorAll('ul[role="list"] li'); + expect(items.length).toBe(2); + expect(items[0].textContent).toContain('@'); + expect(items[1].textContent).toContain('↩'); + }); + + it('renders MENTION verb text from paraglide messages', async () => { + render(ChronikFuerDichBox, { + props: { + unread: [mention({ actorName: 'Bertha' })], + onMarkRead: () => {}, + onMarkAllRead: () => {} + } + }); + + await expect + .element(page.getByText(/bertha hat dich in einem kommentar erwähnt/i)) + .toBeVisible(); + }); + + it('renders REPLY verb text from paraglide messages', async () => { + render(ChronikFuerDichBox, { + props: { + unread: [mention({ type: 'REPLY', actorName: 'Carl' })], + onMarkRead: () => {}, + onMarkAllRead: () => {} + } + }); + + await expect + .element(page.getByText(/carl hat auf deinen kommentar geantwortet/i)) + .toBeVisible(); + }); + + it('calls onMarkRead with the notification when its dismiss button is clicked', async () => { + const onMarkRead = vi.fn(); + const item = mention({ id: 'n-7' }); + render(ChronikFuerDichBox, { + props: { unread: [item], onMarkRead, onMarkAllRead: () => {} } + }); + + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLElement; + dismiss.click(); + + expect(onMarkRead).toHaveBeenCalledWith(item); + }); + + it('calls onMarkAllRead when the mark-all-read button is clicked', async () => { + const onMarkAllRead = vi.fn(); + render(ChronikFuerDichBox, { + props: { + unread: [mention()], + onMarkRead: () => {}, + onMarkAllRead + } + }); + + const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; + btn.click(); + + expect(onMarkAllRead).toHaveBeenCalledOnce(); + }); + + it('builds a deep-link href to the comment for each notification', async () => { + render(ChronikFuerDichBox, { + props: { + unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], + onMarkRead: () => {}, + onMarkAllRead: () => {} + } + }); + + const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; + expect(link.getAttribute('href')).toContain('doc-x'); + }); +}); diff --git a/frontend/src/lib/activity/ChronikRow.svelte.test.ts b/frontend/src/lib/activity/ChronikRow.svelte.test.ts new file mode 100644 index 00000000..c62f4a1e --- /dev/null +++ b/frontend/src/lib/activity/ChronikRow.svelte.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import ChronikRow from './ChronikRow.svelte'; + +afterEach(cleanup); + +const baseActor = { id: 'a1', name: 'Anna Schmidt', initials: 'AS', color: '#012851' }; + +const makeItem = (overrides: Record = {}) => ({ + id: 'i1', + kind: 'TEXT_SAVED' as string, + actor: baseActor as null | typeof baseActor, + documentId: 'd1', + documentTitle: 'Brief 1923', + count: 1, + happenedAt: '2026-04-15T10:00:00Z', + happenedAtUntil: null as string | null, + commentId: null as string | null, + commentPreview: null as string | null, + annotationId: null as string | null, + youMentioned: false, + ...overrides +}); + +describe('ChronikRow', () => { + it('renders the actor avatar with initials when actor is present', async () => { + render(ChronikRow, { props: { item: makeItem() } }); + + expect(document.body.textContent).toContain('AS'); + }); + + it('renders the question-mark fallback avatar when actor is null', async () => { + render(ChronikRow, { props: { item: makeItem({ actor: null }) } }); + + const fallback = document.querySelector('[data-testid="chronik-avatar-fallback"]'); + expect(fallback).not.toBeNull(); + }); + + it('renders the for-you marker when youMentioned is true', async () => { + render(ChronikRow, { props: { item: makeItem({ youMentioned: true }) } }); + + const marker = document.querySelector('[data-testid="chronik-foryou-marker"]'); + expect(marker).not.toBeNull(); + }); + + it('renders the for-you data-variant when youMentioned is true', async () => { + render(ChronikRow, { props: { item: makeItem({ youMentioned: true }) } }); + + const link = document.querySelector('a[data-variant]') as HTMLElement; + expect(link.getAttribute('data-variant')).toBe('for-you'); + }); + + it('renders the rollup variant when count > 1', async () => { + render(ChronikRow, { props: { item: makeItem({ count: 3 }) } }); + + const link = document.querySelector('a[data-variant]') as HTMLElement; + expect(link.getAttribute('data-variant')).toBe('rollup'); + const badge = document.querySelector('[data-testid="chronik-count-badge"]'); + expect(badge).not.toBeNull(); + }); + + it('renders the comment variant for COMMENT_ADDED kind', async () => { + render(ChronikRow, { + props: { item: makeItem({ kind: 'COMMENT_ADDED', commentPreview: 'Tolle Geschichte!' }) } + }); + + const link = document.querySelector('a[data-variant]') as HTMLElement; + expect(link.getAttribute('data-variant')).toBe('comment'); + const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); + expect(preview?.textContent).toContain('Tolle Geschichte!'); + }); + + it('falls back to ellipsis comment preview when commentPreview is null', async () => { + render(ChronikRow, { props: { item: makeItem({ kind: 'COMMENT_ADDED' }) } }); + + const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); + expect(preview?.textContent).toContain('…'); + }); + + it('renders the document title in a styled span', async () => { + render(ChronikRow, { props: { item: makeItem() } }); + + const title = document.querySelector('[data-testid="chronik-doc-title"]'); + expect(title?.textContent).toBe('Brief 1923'); + }); + + it('uses /documents/{id} as default href', async () => { + render(ChronikRow, { props: { item: makeItem() } }); + + const link = document.querySelector('a[data-variant]') as HTMLAnchorElement; + expect(link.href).toContain('/documents/d1'); + }); + + it('uses comment-deep-link href when commentId is set', async () => { + render(ChronikRow, { + props: { item: makeItem({ commentId: 'c1', kind: 'COMMENT_ADDED' }) } + }); + + const link = document.querySelector('a[data-variant]') as HTMLAnchorElement; + expect(link.href).toContain('c1'); + }); + + it('renders a time-range label when rollup has happenedAtUntil', async () => { + render(ChronikRow, { + props: { + item: makeItem({ + count: 5, + happenedAt: '2026-04-15T10:00:00Z', + happenedAtUntil: '2026-04-15T14:30:00Z' + }) + } + }); + + // Time range uses U+2013 between two HH:MM strings — check for any colon-bearing time + expect(document.body.textContent).toMatch(/\d{2}:\d{2}/); + }); +}); diff --git a/frontend/src/lib/activity/ChronikTimeline.svelte.test.ts b/frontend/src/lib/activity/ChronikTimeline.svelte.test.ts new file mode 100644 index 00000000..49300ce0 --- /dev/null +++ b/frontend/src/lib/activity/ChronikTimeline.svelte.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import ChronikTimeline from './ChronikTimeline.svelte'; + +afterEach(cleanup); + +const baseActor = { id: 'a1', name: 'Anna Schmidt', initials: 'AS', color: '#012851' }; + +const makeItem = (overrides: Record = {}) => ({ + id: 'i1', + kind: 'TEXT_SAVED' as string, + actor: baseActor, + documentId: 'd1', + documentTitle: 'Brief 1923', + count: 1, + happenedAt: new Date().toISOString(), + youMentioned: false, + ...overrides +}); + +describe('ChronikTimeline', () => { + it('renders nothing when items is empty', async () => { + render(ChronikTimeline, { props: { items: [] } }); + + const buckets = document.querySelectorAll('[data-testid^="chronik-bucket-"]'); + expect(buckets.length).toBe(0); + }); + + it('renders the today bucket for today items', async () => { + const today = new Date(); + render(ChronikTimeline, { + props: { items: [makeItem({ id: 'i1', happenedAt: today.toISOString() })] } + }); + + const today_bucket = document.querySelector('[data-testid="chronik-bucket-today"]'); + expect(today_bucket).not.toBeNull(); + }); + + it('renders the older bucket for old items', async () => { + render(ChronikTimeline, { + props: { items: [makeItem({ id: 'i1', happenedAt: '2020-01-01T10:00:00Z' })] } + }); + + const olderBucket = document.querySelector('[data-testid="chronik-bucket-older"]'); + expect(olderBucket).not.toBeNull(); + }); + + it('renders multiple buckets when items span time ranges', async () => { + const today = new Date(); + render(ChronikTimeline, { + props: { + items: [ + makeItem({ id: 'i1', kind: 'TEXT_SAVED', happenedAt: today.toISOString() }), + makeItem({ + id: 'i2', + kind: 'FILE_UPLOADED', + documentId: 'd2', + happenedAt: '2020-01-01T10:00:00Z' + }) + ] + } + }); + + const buckets = document.querySelectorAll('[data-testid^="chronik-bucket-"]'); + expect(buckets.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/frontend/src/lib/activity/DashboardActivityFeed.svelte.test.ts b/frontend/src/lib/activity/DashboardActivityFeed.svelte.test.ts new file mode 100644 index 00000000..a0e7fbfc --- /dev/null +++ b/frontend/src/lib/activity/DashboardActivityFeed.svelte.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DashboardActivityFeed from './DashboardActivityFeed.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; + +afterEach(cleanup); + +const baseItem = (overrides: Partial = {}): ActivityFeedItemDTO => + ({ + kind: 'TEXT_SAVED', + documentId: 'doc-1', + documentTitle: 'Brief 1899', + actor: { + id: 'u-1', + name: 'Anna Schmidt', + initials: 'AS', + color: '#336699' + }, + count: 1, + happenedAt: '2026-04-14T14:02:00Z', + happenedAtUntil: null, + youMentioned: false, + ...overrides + }) as ActivityFeedItemDTO; + +describe('DashboardActivityFeed', () => { + it('renders the feed caption and show-all link', async () => { + render(DashboardActivityFeed, { props: { feed: [] } }); + + await expect.element(page.getByText('Kommentare & Aktivität')).toBeVisible(); + const link = document.querySelector('a[href="/aktivitaeten"]'); + expect(link).not.toBeNull(); + }); + + it('renders nothing in the list when the feed is empty', async () => { + render(DashboardActivityFeed, { props: { feed: [] } }); + + const lists = document.querySelectorAll('ul'); + expect(lists.length).toBe(0); + }); + + it('renders one row per feed item with the actor initials', async () => { + render(DashboardActivityFeed, { + props: { + feed: [baseItem(), baseItem({ documentId: 'doc-2', documentTitle: 'Brief 1900' })] + } + }); + + const items = document.querySelectorAll('li'); + expect(items.length).toBe(2); + expect(document.body.textContent).toContain('AS'); + }); + + it('renders the question-mark badge when no actor is set', async () => { + render(DashboardActivityFeed, { + props: { feed: [baseItem({ actor: null as unknown as undefined })] } + }); + + const li = document.querySelector('li'); + expect(li?.textContent).toContain('?'); + }); + + it('renders the rollup count badge when count > 1', async () => { + render(DashboardActivityFeed, { + props: { feed: [baseItem({ count: 5 })] } + }); + + const badge = document.querySelector('[data-testid="feed-rollup-count"]'); + expect(badge?.textContent?.trim()).toBe('5'); + }); + + it('omits the rollup count badge when count is 1', async () => { + render(DashboardActivityFeed, { props: { feed: [baseItem({ count: 1 })] } }); + + const badge = document.querySelector('[data-testid="feed-rollup-count"]'); + expect(badge).toBeNull(); + }); + + it('renders the "für dich" badge when youMentioned is true', async () => { + render(DashboardActivityFeed, { + props: { feed: [baseItem({ youMentioned: true })] } + }); + + await expect.element(page.getByText(/für dich/i)).toBeVisible(); + }); + + it('maps the kind enum to a localized verb (TEXT_SAVED)', async () => { + render(DashboardActivityFeed, { + props: { feed: [baseItem({ kind: 'TEXT_SAVED' as ActivityFeedItemDTO['kind'] })] } + }); + + expect(document.body.textContent).toContain('hat Text gespeichert in'); + }); + + it('maps the kind enum to a localized verb (FILE_UPLOADED)', async () => { + render(DashboardActivityFeed, { + props: { feed: [baseItem({ kind: 'FILE_UPLOADED' as ActivityFeedItemDTO['kind'] })] } + }); + + expect(document.body.textContent).toContain('hat eine Datei hochgeladen'); + }); + + it('falls back to the raw kind when no verb is mapped', async () => { + render(DashboardActivityFeed, { + props: { + feed: [baseItem({ kind: 'UNKNOWN_KIND' as unknown as ActivityFeedItemDTO['kind'] })] + } + }); + + expect(document.body.textContent).toContain('UNKNOWN_KIND'); + }); + + it('renders a rollup time range when happenedAtUntil is set and count > 1', async () => { + render(DashboardActivityFeed, { + props: { + feed: [ + baseItem({ + happenedAt: '2026-04-14T14:02:00Z', + happenedAtUntil: '2026-04-14T14:32:00Z', + count: 3 + }) + ] + } + }); + + // "14:02–14:32" appears (with the en-dash) + expect(document.body.textContent).toMatch(/\d{2}:\d{2}–\d{2}:\d{2}/); + }); + + it('uses the actor initials as the fallback name when name is null', async () => { + render(DashboardActivityFeed, { + props: { + feed: [ + baseItem({ + actor: { + id: 'u-2', + name: null as unknown as undefined, + initials: 'XR', + color: '#000' + } + }) + ] + } + }); + + const strong = document.querySelector('strong'); + expect(strong?.textContent).toBe('XR'); + }); + + it('builds the document detail href from documentId', async () => { + render(DashboardActivityFeed, { + props: { feed: [baseItem({ documentId: 'doc-xyz', documentTitle: 'Brief 1901' })] } + }); + + const link = document.querySelector('a[href="/documents/doc-xyz"]'); + expect(link).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/document/DocumentMetadataDrawer.svelte.test.ts b/frontend/src/lib/document/DocumentMetadataDrawer.svelte.test.ts new file mode 100644 index 00000000..8077f9b3 --- /dev/null +++ b/frontend/src/lib/document/DocumentMetadataDrawer.svelte.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; + +afterEach(cleanup); + +const sender = { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; +const receiver = (id: string, name: string) => ({ + id, + firstName: name.split(' ')[0], + lastName: name.split(' ').slice(1).join(' ') || name, + displayName: name +}); + +const baseProps = { + documentDate: '1923-04-15' as string | null, + location: 'Berlin' as string | null, + status: 'UPLOADED', + sender: null as typeof sender | null, + receivers: [] as ReturnType[], + tags: [] as { id: string; name: string }[], + inferredRelationship: null, + geschichten: [] as { + id: string; + title: string; + publishedAt?: string; + author?: { firstName?: string; lastName?: string; email: string }; + }[], + documentId: 'doc-1', + canBlogWrite: false +}; + +describe('DocumentMetadataDrawer', () => { + it('renders the three default section headings', async () => { + render(DocumentMetadataDrawer, { props: baseProps }); + + await expect.element(page.getByRole('heading', { name: 'Details' })).toBeVisible(); + await expect.element(page.getByRole('heading', { name: 'Personen' })).toBeVisible(); + await expect.element(page.getByRole('heading', { name: 'Schlagwörter' })).toBeVisible(); + }); + + it('renders the formatted long date when documentDate is provided', async () => { + render(DocumentMetadataDrawer, { props: baseProps }); + + // formatDate default ('long') format is "15. April 1923" in de-DE. + await expect.element(page.getByText(/1923/)).toBeVisible(); + }); + + it('renders an em-dash when documentDate is null', async () => { + render(DocumentMetadataDrawer, { props: { ...baseProps, documentDate: null } }); + + // The dash appears in date AND location AND geschichten — multiple matches expected + const dashes = document.querySelectorAll('dd, p'); + const dashTexts = Array.from(dashes) + .map((el) => el.textContent?.trim()) + .filter((t) => t === '—'); + expect(dashTexts.length).toBeGreaterThan(0); + }); + + it('renders the no-persons placeholder when sender and receivers are empty', async () => { + render(DocumentMetadataDrawer, { props: baseProps }); + + await expect.element(page.getByText('Keine Personen zugeordnet')).toBeVisible(); + }); + + it('renders the sender and inferred relationship label when both are present', async () => { + render(DocumentMetadataDrawer, { + props: { + ...baseProps, + sender, + inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' } + } + }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + }); + + it('renders the receivers list with up to five visible by default', async () => { + const receivers = Array.from({ length: 7 }, (_, i) => receiver(`r${i}`, `Person ${i}`)); + render(DocumentMetadataDrawer, { + props: { ...baseProps, sender, receivers } + }); + + await expect.element(page.getByText('Person 0')).toBeVisible(); + await expect.element(page.getByText('Person 4')).toBeVisible(); + await expect.element(page.getByText('Person 5')).not.toBeInTheDocument(); + }); + + it('renders the +N more button when there are more than five receivers', async () => { + const receivers = Array.from({ length: 8 }, (_, i) => receiver(`r${i}`, `Person ${i}`)); + render(DocumentMetadataDrawer, { + props: { ...baseProps, sender, receivers } + }); + + await expect.element(page.getByRole('button', { name: /\+3 weitere/i })).toBeVisible(); + }); + + it('expands the receiver list when the +N more button is clicked', async () => { + const receivers = Array.from({ length: 8 }, (_, i) => receiver(`r${i}`, `Person ${i}`)); + render(DocumentMetadataDrawer, { + props: { ...baseProps, sender, receivers } + }); + + await page.getByRole('button', { name: /\+3 weitere/i }).click(); + + await expect.element(page.getByText('Person 7')).toBeVisible(); + }); + + it('renders the no-tags placeholder when tags is empty', async () => { + render(DocumentMetadataDrawer, { props: baseProps }); + + await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeVisible(); + }); + + it('renders one anchor per tag when tags are present', async () => { + render(DocumentMetadataDrawer, { + props: { + ...baseProps, + tags: [ + { id: 't1', name: 'Familie' }, + { id: 't2', name: 'Reise' } + ] + } + }); + + await expect + .element(page.getByRole('link', { name: 'Familie' })) + .toHaveAttribute('href', '/?tag=Familie'); + await expect + .element(page.getByRole('link', { name: 'Reise' })) + .toHaveAttribute('href', '/?tag=Reise'); + }); + + it('hides the geschichten column when there are no stories and no canBlogWrite', async () => { + render(DocumentMetadataDrawer, { props: baseProps }); + + await expect + .element(page.getByRole('heading', { name: 'Geschichten' })) + .not.toBeInTheDocument(); + }); + + it('shows the geschichten column when canBlogWrite is true even with no stories', async () => { + render(DocumentMetadataDrawer, { props: { ...baseProps, canBlogWrite: true } }); + + await expect.element(page.getByRole('heading', { name: 'Geschichten' })).toBeVisible(); + }); + + it('renders the attach link to the new-geschichte route when canBlogWrite + documentId', async () => { + render(DocumentMetadataDrawer, { + props: { ...baseProps, canBlogWrite: true, documentId: 'doc-42' } + }); + + const links = document.querySelectorAll('a[href*="/geschichten/new?documentId="]'); + expect(links.length).toBe(1); + expect((links[0] as HTMLAnchorElement).href).toContain('documentId=doc-42'); + }); + + it('renders the geschichten list when stories are present', async () => { + render(DocumentMetadataDrawer, { + props: { + ...baseProps, + geschichten: [ + { + id: 'g1', + title: 'Reise nach Berlin', + publishedAt: '2026-04-15T10:00:00Z', + author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@x' } + } + ] + } + }); + + await expect.element(page.getByRole('link', { name: /reise nach berlin/i })).toBeVisible(); + }); + + it('renders the show-all geschichten link when there are at least three stories', async () => { + render(DocumentMetadataDrawer, { + props: { + ...baseProps, + geschichten: Array.from({ length: 3 }, (_, i) => ({ + id: `g${i}`, + title: `Geschichte ${i}`, + publishedAt: '2026-04-15T10:00:00Z', + author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@x' } + })) + } + }); + + await expect.element(page.getByText(/zeige alle|alle/i)).toBeVisible(); + }); + + it('renders the receiver-only inferred relationship pill only when there is exactly one receiver', async () => { + render(DocumentMetadataDrawer, { + props: { + ...baseProps, + sender, + receivers: [receiver('r1', 'Bert Meier')], + inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' } + } + }); + + // Both labels should be visible — Vater for sender, Tochter for the single receiver + await expect.element(page.getByText(/vater/i)).toBeVisible(); + await expect.element(page.getByText(/tochter/i)).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte b/frontend/src/lib/document/DocumentMobileMenu.svelte new file mode 100644 index 00000000..c2892bbd --- /dev/null +++ b/frontend/src/lib/document/DocumentMobileMenu.svelte @@ -0,0 +1,96 @@ + + +

(mobileMenuOpen = false)}> + + + {#if mobileMenuOpen} + + {/if} +
diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts new file mode 100644 index 00000000..9b561aa5 --- /dev/null +++ b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentMobileMenu from './DocumentMobileMenu.svelte'; + +afterEach(cleanup); + +const baseProps = { + canWrite: false, + isPdf: false, + transcribeMode: false, + filePath: null as string | null, + originalFilename: 'brief.pdf' as string | null, + fileUrl: '' +}; + +describe('DocumentMobileMenu', () => { + it('renders the kebab trigger button with the more-actions aria-label', async () => { + render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } }); + + await expect.element(page.getByRole('button', { name: /weitere aktionen/i })).toBeVisible(); + }); + + it('starts with the dropdown closed (aria-expanded=false)', async () => { + render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } }); + + await expect + .element(page.getByRole('button', { name: /weitere aktionen/i })) + .toHaveAttribute('aria-expanded', 'false'); + }); + + it('opens the dropdown when the trigger is clicked', async () => { + render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect + .element(page.getByRole('button', { name: /weitere aktionen/i })) + .toHaveAttribute('aria-expanded', 'true'); + }); + + it('shows the transcribe action inside the open menu when canWrite, isPdf, and not in transcribe mode', async () => { + render(DocumentMobileMenu, { + props: { ...baseProps, canWrite: true, isPdf: true, filePath: 'docs/x.pdf' } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); + }); + + it('hides the transcribe action when already in transcribeMode', async () => { + render(DocumentMobileMenu, { + props: { + ...baseProps, + canWrite: true, + isPdf: true, + transcribeMode: true, + filePath: 'docs/x.pdf' + } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + }); + + it('shows the download link inside the open menu when filePath is present', async () => { + render(DocumentMobileMenu, { + props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x' } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect.element(page.getByRole('link', { name: /herunterladen/i })).toBeVisible(); + }); + + it('omits the download link when filePath is null', async () => { + render(DocumentMobileMenu, { + props: { ...baseProps, canWrite: true, isPdf: true } + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect + .element(page.getByRole('link', { name: /herunterladen/i })) + .not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/document/DocumentRow.svelte.test.ts b/frontend/src/lib/document/DocumentRow.svelte.test.ts new file mode 100644 index 00000000..704428bc --- /dev/null +++ b/frontend/src/lib/document/DocumentRow.svelte.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + beforeNavigate: () => {}, + afterNavigate: () => {}, + goto: vi.fn(), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + preloadCode: vi.fn(), + preloadData: vi.fn(), + pushState: vi.fn(), + replaceState: vi.fn(), + disableScrollHandling: vi.fn(), + onNavigate: () => () => {} +})); + +const { default: DocumentRow } = await import('./DocumentRow.svelte'); + +afterEach(cleanup); + +const sender = { id: 's1', displayName: 'Anna Schmidt' }; +const receiver = { id: 'r1', displayName: 'Bert Meier' }; + +const makeDoc = (overrides: Record = {}) => ({ + id: 'd1', + title: 'Brief 1923', + originalFilename: 'b.pdf', + documentDate: '1923-04-15', + sender, + receivers: [receiver], + tags: [], + thumbnailUrl: null, + contentType: 'application/pdf', + summary: null, + archiveBox: null, + archiveFolder: null, + location: null, + ...overrides +}); + +const baseItem = (docOverrides: Record = {}) => ({ + document: makeDoc(docOverrides), + matchData: null, + completionPercentage: 0, + contributors: [] +}); + +describe('DocumentRow', () => { + it('renders the title', async () => { + render(DocumentRow, { props: { item: baseItem() } }); + + await expect + .element(page.getByRole('heading', { level: 3, name: /brief 1923/i })) + .toBeVisible(); + }); + + it('falls back to originalFilename when title is null', async () => { + render(DocumentRow, { props: { item: baseItem({ title: null }) } }); + + await expect.element(page.getByRole('heading', { level: 3, name: /b\.pdf/i })).toBeVisible(); + }); + + it('renders the sender name in the metadata column', async () => { + render(DocumentRow, { props: { item: baseItem() } }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + }); + + it('renders the unknown placeholder when sender is null', async () => { + render(DocumentRow, { props: { item: baseItem({ sender: null }) } }); + + const unknownTexts = document.querySelectorAll('.italic'); + const hasUnknown = Array.from(unknownTexts).some((el) => el.textContent?.includes('Unbekannt')); + expect(hasUnknown).toBe(true); + }); + + it('renders one tag button per document tag', async () => { + render(DocumentRow, { + props: { + item: baseItem({ + tags: [ + { id: 't1', name: 'Familie', color: null }, + { id: 't2', name: 'Reise', color: '#ffaabb' } + ] + }) + } + }); + + await expect.element(page.getByRole('button', { name: 'Familie' })).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Reise' })).toBeVisible(); + }); + + it('renders the bulk-select checkbox when canWrite is true', async () => { + render(DocumentRow, { props: { item: baseItem(), canWrite: true } }); + + const checkbox = document.querySelector('input[type="checkbox"]'); + expect(checkbox).not.toBeNull(); + }); + + it('hides the bulk-select checkbox when canWrite is false', async () => { + render(DocumentRow, { props: { item: baseItem(), canWrite: false } }); + + const checkbox = document.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeNull(); + }); + + it('renders archive chips when archive metadata is present', async () => { + render(DocumentRow, { + props: { + item: baseItem({ archiveBox: 'Box 1', archiveFolder: 'Mappe A', location: 'Berlin' }) + } + }); + + await expect.element(page.getByText('Box 1')).toBeVisible(); + await expect.element(page.getByText('Mappe A')).toBeVisible(); + await expect.element(page.getByText('Berlin')).toBeVisible(); + }); + + it('renders the snippet when matchData provides a transcriptionSnippet', async () => { + render(DocumentRow, { + props: { + item: { + document: makeDoc(), + matchData: { transcriptionSnippet: 'Hello world snippet' }, + completionPercentage: 50, + contributors: [] + } + } + }); + + await expect.element(page.getByTestId('search-snippet')).toBeVisible(); + }); + + it('renders the summary when present', async () => { + render(DocumentRow, { + props: { item: baseItem({ summary: 'Brief über die Reise nach Berlin' }) } + }); + + await expect.element(page.getByTestId('doc-summary')).toBeVisible(); + }); + + it('renders an em-dash for missing documentDate', async () => { + render(DocumentRow, { props: { item: baseItem({ documentDate: null }) } }); + + // Multiple em-dashes possible; just ensure at least one is rendered + expect(document.body.textContent).toContain('—'); + }); +}); diff --git a/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts b/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts new file mode 100644 index 00000000..bb0ed12e --- /dev/null +++ b/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentStatusChip from './DocumentStatusChip.svelte'; + +afterEach(cleanup); + +describe('DocumentStatusChip', () => { + it('renders the placeholder label and gray dot for PLACEHOLDER status', async () => { + render(DocumentStatusChip, { props: { status: 'PLACEHOLDER' } }); + + const dot = await page.getByTitle('Platzhalter').element(); + expect(dot.classList.contains('bg-gray-400')).toBe(true); + }); + + it('renders the uploaded label and emerald dot for UPLOADED status', async () => { + render(DocumentStatusChip, { props: { status: 'UPLOADED' } }); + + const dot = await page.getByTitle('Hochgeladen').element(); + expect(dot.classList.contains('bg-emerald-500')).toBe(true); + }); + + it('renders the transcribed label and blue dot for TRANSCRIBED status', async () => { + render(DocumentStatusChip, { props: { status: 'TRANSCRIBED' } }); + + const dot = await page.getByTitle('Transkribiert').element(); + expect(dot.classList.contains('bg-blue-400')).toBe(true); + }); + + it('renders the reviewed label and amber dot for REVIEWED status', async () => { + render(DocumentStatusChip, { props: { status: 'REVIEWED' } }); + + const dot = await page.getByTitle('Geprüft').element(); + expect(dot.classList.contains('bg-amber-400')).toBe(true); + }); + + it('renders the archived label and dark emerald dot for ARCHIVED status', async () => { + render(DocumentStatusChip, { props: { status: 'ARCHIVED' } }); + + const dot = await page.getByTitle('Archiviert').element(); + expect(dot.classList.contains('bg-emerald-600')).toBe(true); + }); + + it('exposes the status as both a title tooltip and an aria-label', async () => { + render(DocumentStatusChip, { props: { status: 'UPLOADED' } }); + + const dot = await page.getByTitle('Hochgeladen').element(); + expect(dot.getAttribute('aria-label')).toBe('Hochgeladen'); + }); +}); diff --git a/frontend/src/lib/document/DocumentThumbnail.svelte.test.ts b/frontend/src/lib/document/DocumentThumbnail.svelte.test.ts new file mode 100644 index 00000000..41562aa6 --- /dev/null +++ b/frontend/src/lib/document/DocumentThumbnail.svelte.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import DocumentThumbnail from './DocumentThumbnail.svelte'; + +afterEach(cleanup); + +describe('DocumentThumbnail', () => { + it('renders the supplied thumbnail image when thumbnailUrl is set', async () => { + render(DocumentThumbnail, { + props: { + doc: { id: 'd1', thumbnailUrl: '/api/d1/thumb', contentType: 'application/pdf' } + } + }); + + const img = document.querySelector('img') as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.src).toContain('/api/d1/thumb'); + }); + + it('renders the placeholder icon when thumbnailUrl is missing', async () => { + render(DocumentThumbnail, { + props: { doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' } } + }); + + const svg = document.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + it('uses the small container size by default', async () => { + render(DocumentThumbnail, { + props: { doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' } } + }); + + const container = document.querySelector('.h-\\[84px\\]'); + expect(container).not.toBeNull(); + }); + + it('uses the large container size when size="lg"', async () => { + render(DocumentThumbnail, { + props: { + doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' }, + size: 'lg' + } + }); + + const container = document.querySelector('.h-\\[168px\\]'); + expect(container).not.toBeNull(); + }); + + it('uses lazy loading attributes on the thumbnail image', async () => { + render(DocumentThumbnail, { + props: { + doc: { id: 'd1', thumbnailUrl: '/api/d1/thumb', contentType: 'application/pdf' } + } + }); + + const img = document.querySelector('img') as HTMLImageElement; + expect(img.loading).toBe('lazy'); + expect(img.decoding).toBe('async'); + }); +}); diff --git a/frontend/src/lib/document/DocumentTopBar.svelte b/frontend/src/lib/document/DocumentTopBar.svelte index 96381a69..8d8968dc 100644 --- a/frontend/src/lib/document/DocumentTopBar.svelte +++ b/frontend/src/lib/document/DocumentTopBar.svelte @@ -1,11 +1,12 @@ -{#snippet transcribeBtn(mobile: boolean)} - -{/snippet} - -{#snippet transcribeStopBtn(mobile: boolean)} - -{/snippet} - -{#snippet downloadLink(mobile: boolean)} - { - if (mobile) mobileMenuOpen = false; - }} - class={mobile - ? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary' - : 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'} - title={m.doc_download_title()} - > - - {#if mobile}{m.doc_download_title()}{/if} - -{/snippet} -
@@ -161,20 +77,11 @@ let mobileMenuOpen = $state(false);
-
-

- {doc.title || doc.originalFilename} -

- {#if shortDate} -

- {shortDate} - -

- {/if} -
+