Compare commits

..

10 Commits

Author SHA1 Message Date
Marcel
08c7dbcaa2 chore(renovate): require manual review for privileged CI image digest bumps
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 15s
CI / Backend Unit Tests (push) Successful in 4m7s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Successful in 57s
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (pull_request) Successful in 4m9s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
Adds a packageRule matching .gitea/workflows/** digest updates with
automerge: false. Digest bumps for images running --privileged --pid=host
have root-equivalent host access and must not be auto-merged.

Addresses Nora's review concern on #537.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:15:05 +02:00
Marcel
a3f1260c23 docs(ci): add Troubleshooting section for Reload Caddy failures
Covers the three failure modes Sara flagged: Caddy stopped (explicit
systemctl error), symlink missing/mis-pointed (silent reload, stale
smoke test), and Docker socket / nsenter unavailable (container error).
Each failure mode includes symptoms and recovery steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:14:35 +02:00
Marcel
e75ddc318b docs(adr): ADR-012 — nsenter via privileged container for host service management in CI
Captures the architectural decision, alternatives considered (sudo
systemctl, Caddy admin API, SSH), and consequences (symlink contract,
Renovate review requirement, step duplication tracked in #539).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:13:50 +02:00
Marcel
60cb9d5ad0 docs(deploy): note Caddyfile symlink is a CI dependency
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m4s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 58s
CI / Unit & Component Tests (pull_request) Failing after 2m49s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 56s
CI / Backend Unit Tests (pull_request) Successful in 4m12s
CI / fail2ban Regex (pull_request) Successful in 37s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:52:34 +02:00
Marcel
1ce0638ae6 docs(ci): update nsenter example to Alpine, document alternatives considered
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:47:41 +02:00
Marcel
f608838f7a fix(ci): pin Reload Caddy to alpine:3.21 digest, add reload-vs-restart rationale
- Switch ubuntu:22.04 (floating, ~70 MB) to alpine:3.21 pinned by sha256
  digest (~5 MB); util-linux installed at run time via apk add
- Add explicit comment explaining why `reload` not `restart`: SIGHUP
  re-reads config in-process without dropping TLS connections

Addresses Tobias + Nora blocker from PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:43:55 +02:00
Marcel
52a96f657d docs(ci): document DooD runner architecture and nsenter pattern
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 16s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Successful in 23s
CI / Unit & Component Tests (pull_request) Failing after 2m48s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (push) Successful in 4m4s
CI / Backend Unit Tests (pull_request) Successful in 4m6s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
Replace the stale generic runner provisioning docs with an accurate
description of the actual two-container setup on the Hetzner VPS.
Document the nsenter pattern for running host-level commands (systemctl)
from containerised CI steps, and the Caddyfile symlink contract that the
reload step depends on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:29:39 +02:00
Marcel
f87504fb23 fix(ci): add Caddy reload step to release workflow
Same gap as nightly.yml: production deploys also need Caddy to reload
the updated Caddyfile before the smoke test validates the public surface.
Uses the same nsenter pattern introduced in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:29:02 +02:00
Marcel
99de6f1d07 fix(ci): reload Caddy via nsenter, not sudo systemctl
`sudo systemctl reload caddy` does not work from inside a DooD job
container: `systemctl` is absent from Ubuntu container images and
container processes cannot reach the host systemd without entering its
namespaces. Replace with `docker run --privileged --pid=host ubuntu:22.04
nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy`, which uses
the already-mounted Docker socket to spin up a privileged sibling
container that enters the host PID namespace via nsenter. Tested live on
the Hetzner VPS. No sudoers entry required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:28:24 +02:00
Marcel
432ae2ac83 ci(nightly): reload Caddy before smoke test
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m50s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m10s
CI / fail2ban Regex (push) Successful in 38s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
Adds a `sudo systemctl reload caddy` step between the docker compose
deploy and the smoke test. This ensures any committed Caddyfile changes
are applied before the public surface is verified.

Previously the workflow had no mechanism to push Caddyfile changes to
the running host daemon. A Caddyfile edit would land in the repo but
Caddy would keep serving the previous config, causing the smoke test to
catch a stale header or still-proxied /actuator route rather than the
intended current config.

This step also surfaces the root cause of today's port-443 failure
explicitly: if Caddy is not running, the step fails with a clear service
error rather than a misleading "Failed to connect to port 443" from curl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:49:32 +02:00
161 changed files with 593 additions and 17545 deletions

View File

@@ -36,7 +36,13 @@ jobs:
run: npm run lint
working-directory: frontend
- name: Run unit and component tests with coverage
- name: Run unit and component tests
run: npm test
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Run coverage (server + client)
run: npm run test:coverage
working-directory: frontend
env:

View File

@@ -73,31 +73,8 @@ jobs:
MAIL_SMTP_AUTH=false
MAIL_STARTTLS_ENABLE=false
APP_MAIL_FROM=noreply@staging.raddatz.cloud
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
EOF
- name: Verify backend /import:ro mount is wired
# Regression guard for #526: the /admin/system mass-import card
# only works when the backend service mounts the host import
# payload at /import (read-only). If a future "compose cleanup"
# PR drops the volumes block, mass import silently breaks again.
# `compose config` renders both shorthand and longform mounts as
# `target: /import` + `read_only: true`, so we assert against
# the rendered form rather than the raw source YAML.
run: |
set -e
docker compose \
-f docker-compose.prod.yml \
-p archiv-staging \
--env-file .env.staging \
--profile staging \
config > /tmp/compose-rendered.yml
grep -q '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
|| { echo "::error::backend is missing the /import bind mount (see #526)"; exit 1; }
grep -A2 '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
| grep -q 'read_only: true' \
|| { echo "::error::backend /import mount is not read-only (see #526)"; exit 1; }
- name: Build images
# `--pull` forces re-fetching pinned base images so a CVE
# re-publication of the same tag (e.g. node:20.19.0-alpine3.21,

View File

@@ -71,7 +71,6 @@ jobs:
MAIL_SMTP_AUTH=true
MAIL_STARTTLS_ENABLE=true
APP_MAIL_FROM=noreply@raddatz.cloud
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
EOF
- name: Build images

View File

@@ -202,7 +202,8 @@ frontend/src/routes/
├── profile/ User profile settings
├── users/[id]/ Public user profile page
├── login/ logout/ register/
── forgot-password/ reset-password/
── forgot-password/ reset-password/
└── demo/ Dev-only demos
```
### API Client Pattern

View File

@@ -99,9 +99,7 @@ public class MassImportService {
@Value("${app.import.col.transcription:13}")
private int colTranscription;
@Value("${app.import.dir:/import}")
private String importDir;
private static final String IMPORT_DIR = "/import";
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
// ODS XML namespaces
@@ -131,7 +129,7 @@ public class MassImportService {
}
private File findSpreadsheetFile() throws IOException {
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
try (Stream<Path> files = Files.list(Paths.get(IMPORT_DIR))) {
return files
.filter(p -> {
String name = p.toString().toLowerCase();
@@ -139,7 +137,7 @@ public class MassImportService {
})
.findFirst()
.orElseThrow(() -> new RuntimeException(
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + IMPORT_DIR + " gefunden!"))
.toFile();
}
}
@@ -380,7 +378,7 @@ public class MassImportService {
}
private Optional<File> findFileRecursive(String filename) {
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
try (Stream<Path> walk = Files.walk(Paths.get(IMPORT_DIR))) {
return walk.filter(p -> !Files.isDirectory(p))
.filter(p -> p.getFileName().toString().equals(filename))
.map(Path::toFile)

View File

@@ -50,7 +50,6 @@ class MassImportServiceTest {
void setUp() {
service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
ReflectionTestUtils.setField(service, "importDir", "/import");
ReflectionTestUtils.setField(service, "colIndex", 0);
ReflectionTestUtils.setField(service, "colBox", 1);
ReflectionTestUtils.setField(service, "colFolder", 2);
@@ -80,19 +79,6 @@ class MassImportServiceTest {
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
}
@Test
void runImportAsync_readsFromConfiguredImportDir(@TempDir Path tempDir) {
// Empty temp dir → findSpreadsheetFile throws "no spreadsheet" with the
// configured path in the message. Proves the field, not a constant,
// drives the lookup.
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
assertThat(service.getStatus().message()).contains(tempDir.toString());
}
@Test
void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus(

View File

@@ -26,15 +26,6 @@
# MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit)
# MAIL_USERNAME, MAIL_PASSWORD
# APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud)
# IMPORT_HOST_DIR absolute host path holding ONLY the ODS
# spreadsheet and PDFs for /admin/system mass
# import — mounted read-only at /import inside
# the backend. Compose refuses to start when
# this var is unset, so staging and prod cannot
# accidentally share an import source. Must be
# readable by the backend container's UID
# (currently root via the OpenJDK image — any
# world-readable directory works).
networks:
archiv-net:
@@ -182,12 +173,6 @@ services:
# Bound to localhost only — Caddy fronts external traffic.
ports:
- "127.0.0.1:${PORT_BACKEND}:8080"
# Host path holding the ODS spreadsheet + PDFs for the mass-import endpoint.
# Read-only; MassImportService only reads (Files.list / Files.walk on /import).
# Required — no default — so staging and prod cannot accidentally share an
# import source. CI workflows pin this per-env (see .gitea/workflows/).
volumes:
- ${IMPORT_HOST_DIR:?Set IMPORT_HOST_DIR to a host path holding the mass-import payload (ODS + PDFs). See docs/DEPLOYMENT.md.}:/import:ro
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv

View File

@@ -97,7 +97,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
| `IMPORT_HOST_DIR` | Absolute host path holding the ODS spreadsheet + PDFs for the `/admin/system` mass-import card. Mounted read-only at `/import` inside the backend (compose-only — backend reads via `app.import.dir`). Compose refuses to start when unset, so staging and prod cannot accidentally share the source. Convention: `/srv/familienarchiv-staging/import` and `/srv/familienarchiv-production/import` | — | YES (prod compose) | — |
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
@@ -333,18 +332,9 @@ bash scripts/download-kraken-models.sh
### Trigger a mass import (Excel/ODS)
**Dev:** drop the ODS spreadsheet + PDFs into `./import/` at the repo root — the dev compose bind-mounts it to `/import` automatically.
**Staging/production:**
1. Pre-stage the payload on the host. Convention: `/srv/familienarchiv-staging/import/` or `/srv/familienarchiv-production/import/`.
```bash
rsync -avh --progress ./import/ user@host:/srv/familienarchiv-staging/import/
```
2. Make sure `IMPORT_HOST_DIR=<host-path>` is set in `.env.staging` / `.env.production` (the nightly/release workflows already write this — see §3). Compose refuses to start without it.
3. Redeploy the stack so the bind mount picks up — or, if the mount is already in place, skip to step 4.
4. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission), or click the "Import starten" button on `/admin/system`.
5. The import runs asynchronously — poll `GET /api/admin/import-status`, watch `/admin/system`, or tail the backend logs.
1. Place the import file in the `import/` bind mount on the backend container.
2. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission).
3. The import runs asynchronously — poll `GET /api/admin/import-status` or watch backend logs.
---

View File

@@ -40,7 +40,8 @@ src/
│ ├── profile/ # User profile settings
│ ├── users/[id]/ # Public user profile page
│ ├── login/ logout/ register/
── forgot-password/ reset-password/
── forgot-password/ reset-password/
│ └── demo/ # Dev-only demos
├── lib/ # Domain-based package structure (mirrors backend)
│ ├── document/ # Document domain: components, stores, services, utils
│ │ ├── annotation/ # Annotation overlay components

View File

@@ -1,56 +0,0 @@
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 <p> (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');
});
});

View File

@@ -1,37 +0,0 @@
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();
});
});

View File

@@ -1,53 +0,0 @@
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');
});
});

View File

@@ -1,132 +0,0 @@
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> = {}): 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');
});
});

View File

@@ -1,117 +0,0 @@
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<string, unknown> = {}) => ({
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}/);
});
});

View File

@@ -1,67 +0,0 @@
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<string, unknown> = {}) => ({
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);
});
});

View File

@@ -1,161 +0,0 @@
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> = {}): 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:0214: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();
});
});

View File

@@ -1,207 +0,0 @@
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<typeof receiver>[],
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();
});
});

View File

@@ -1,96 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
type Props = {
canWrite: boolean;
isPdf: boolean;
transcribeMode: boolean;
filePath?: string | null;
originalFilename?: string | null;
fileUrl: string;
};
let {
canWrite,
isPdf,
transcribeMode = $bindable(),
filePath = null,
originalFilename = null,
fileUrl
}: Props = $props();
let mobileMenuOpen = $state(false);
function startTranscribe() {
transcribeMode = true;
mobileMenuOpen = false;
}
</script>
<div role="group" class="relative" use:clickOutside onclickoutside={() => (mobileMenuOpen = false)}>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
aria-label={m.topbar_more_actions()}
aria-haspopup="true"
aria-expanded={mobileMenuOpen}
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</button>
{#if mobileMenuOpen}
<div
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canWrite && isPdf && !transcribeMode}
<button
onclick={startTranscribe}
aria-label={m.transcription_mode_label()}
aria-pressed={false}
class="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
</button>
{/if}
{#if filePath}
<a
href={fileUrl}
download={originalFilename}
onclick={() => (mobileMenuOpen = false)}
class="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"
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{m.doc_download_title()}
</a>
{/if}
</div>
{/if}
</div>

View File

@@ -1,91 +0,0 @@
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();
});
});

View File

@@ -1,150 +0,0 @@
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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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('—');
});
});

View File

@@ -1,50 +0,0 @@
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');
});
});

View File

@@ -1,61 +0,0 @@
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');
});
});

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition';
import { formatDate } from '$lib/shared/utils/date';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import PersonChipRow from '$lib/person/PersonChipRow.svelte';
import OverflowPillButton from '$lib/shared/primitives/OverflowPillButton.svelte';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
import DocumentMobileMenu from './DocumentMobileMenu.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
@@ -59,8 +58,93 @@ const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('applicatio
const receivers = $derived(doc.receivers ?? []);
const extraCount = $derived(Math.max(0, receivers.length - 2));
const overflowPersons = $derived(receivers.slice(2));
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
let mobileMenuOpen = $state(false);
</script>
{#snippet transcribeBtn(mobile: boolean)}
<button
onclick={() => {
transcribeMode = true;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.transcription_mode_label()}
aria-pressed={false}
class={mobile
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
</button>
{/snippet}
{#snippet transcribeStopBtn(mobile: boolean)}
<button
onclick={() => {
transcribeMode = false;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.transcription_mode_stop()}
aria-pressed={true}
class={mobile
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_stop()}
</button>
{/snippet}
{#snippet downloadLink(mobile: boolean)}
<a
href={fileUrl}
download={doc.originalFilename}
onclick={() => {
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()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
{#if mobile}{m.doc_download_title()}{/if}
</a>
{/snippet}
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
<!-- Main row -->
<div class="flex h-[75px] shrink-0 items-center pr-4 xs:h-[88px]">
@@ -77,11 +161,20 @@ const overflowPersons = $derived(receivers.slice(2));
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
<!-- Title + meta -->
<DocumentTopBarTitle
title={doc.title}
originalFilename={doc.originalFilename}
documentDate={doc.documentDate}
/>
<div class="min-w-0 flex-1 overflow-hidden">
<h1
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
title={doc.title ?? doc.originalFilename ?? ''}
>
{doc.title || doc.originalFilename}
</h1>
{#if shortDate}
<p class="font-sans text-[16px] text-ink-2">
<span class="lg:hidden">{shortDate}</span>
<span class="hidden lg:inline">{longDate}</span>
</p>
{/if}
</div>
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
@@ -99,9 +192,7 @@ const overflowPersons = $derived(receivers.slice(2));
onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen}
aria-label={m.doc_details_toggle()}
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen
? 'border-primary bg-primary text-primary-fg'
: 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.doc_details_toggle()}
<svg
@@ -121,26 +212,72 @@ const overflowPersons = $derived(receivers.slice(2));
<!-- Action buttons -->
<div class="flex shrink-0 items-center gap-1.5 font-sans">
<DocumentTopBarActions
documentId={doc.id}
canWrite={canWrite}
isPdf={!!isPdf}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}
fileUrl={fileUrl}
/>
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(false)}
{/if}
{#if (canWrite && isPdf) || doc.filePath}
<div class="md:hidden">
<DocumentMobileMenu
canWrite={canWrite}
isPdf={!!isPdf}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}
fileUrl={fileUrl}
{#if transcribeMode}
{@render transcribeStopBtn(false)}
{/if}
{#if canWrite && !transcribeMode}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if doc.filePath && !transcribeMode}
{@render downloadLink(false)}
{/if}
<!-- Kebab menu — mobile only, contains actions hidden below md -->
{#if (canWrite && isPdf) || doc.filePath}
<div
role="group"
class="relative md:hidden"
use:clickOutside
onclickoutside={() => (mobileMenuOpen = false)}
>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
aria-label={m.topbar_more_actions()}
aria-haspopup="true"
aria-expanded={mobileMenuOpen}
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</button>
{#if mobileMenuOpen}
<div
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(true)}
{/if}
{#if doc.filePath}
{@render downloadLink(true)}
{/if}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1,193 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentTopBar from './DocumentTopBar.svelte';
afterEach(cleanup);
const sender = { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' };
const receiver = { id: 'r1', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' };
const baseDoc = {
id: 'd1',
title: 'Brief an Helene',
originalFilename: 'brief.pdf',
documentDate: '1923-04-15',
sender,
receivers: [receiver],
filePath: null as string | null,
contentType: null as string | null,
location: null,
status: 'UPLOADED',
tags: [] as { id: string; name: string }[]
};
const baseProps = (overrides: Record<string, unknown> = {}) => ({
doc: baseDoc,
canWrite: false,
fileUrl: '',
transcribeMode: false,
inferredRelationship: null,
geschichten: [],
canBlogWrite: false,
...overrides
});
describe('DocumentTopBar', () => {
it('renders the document title as the main heading', async () => {
render(DocumentTopBar, { props: baseProps() });
await expect.element(page.getByRole('heading', { name: 'Brief an Helene' })).toBeVisible();
});
it('falls back to originalFilename when title is missing', async () => {
render(DocumentTopBar, { props: baseProps({ doc: { ...baseDoc, title: null } }) });
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
});
it('renders the short documentDate when one is present', async () => {
render(DocumentTopBar, { props: baseProps() });
await expect.element(page.getByText('15.04.1923')).toBeVisible();
});
it('omits the date paragraph entirely when documentDate is null', async () => {
render(DocumentTopBar, { props: baseProps({ doc: { ...baseDoc, documentDate: null } }) });
await expect.element(page.getByText(/^\d{2}\.\d{2}\.\d{4}$/)).not.toBeInTheDocument();
});
it('does not render the transcribe button when canWrite is false', async () => {
render(DocumentTopBar, {
props: baseProps({ doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' } })
});
await expect
.element(page.getByRole('button', { name: /transkribieren/i }))
.not.toBeInTheDocument();
});
it('does not render the transcribe button when contentType is not PDF', async () => {
render(DocumentTopBar, {
props: baseProps({
canWrite: true,
doc: { ...baseDoc, filePath: 'x', contentType: 'image/jpeg' }
})
});
await expect
.element(page.getByRole('button', { name: /transkribieren/i }))
.not.toBeInTheDocument();
});
it('renders the transcribe button when canWrite is true and the file is a PDF', async () => {
render(DocumentTopBar, {
props: baseProps({
canWrite: true,
doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' }
})
});
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
});
it('renders the stop-transcribe button when transcribeMode is true', async () => {
render(DocumentTopBar, {
props: baseProps({
canWrite: true,
transcribeMode: true,
doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' }
})
});
await expect.element(page.getByRole('button', { name: /fertig/i })).toBeVisible();
});
it('hides the edit link when transcribeMode is true', async () => {
render(DocumentTopBar, {
props: baseProps({
canWrite: true,
transcribeMode: true,
doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' }
})
});
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
});
it('renders the edit link when canWrite is true and not in transcribeMode', async () => {
render(DocumentTopBar, { props: baseProps({ canWrite: true }) });
await expect
.element(page.getByRole('link', { name: /bearbeiten/i }))
.toHaveAttribute('href', '/documents/d1/edit');
});
it('does not render the edit link when canWrite is false', async () => {
render(DocumentTopBar, { props: baseProps() });
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
});
it('renders the download link when filePath is present and not in transcribe mode', async () => {
render(DocumentTopBar, {
props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' }, fileUrl: '/api/docs/x' })
});
await expect.element(page.getByTitle('Herunterladen')).toBeVisible();
});
it('does not render the download link when filePath is null', async () => {
render(DocumentTopBar, { props: baseProps() });
await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument();
});
it('opens the metadata drawer when the details toggle is clicked', async () => {
render(DocumentTopBar, { props: baseProps() });
await page.getByRole('button', { name: /^details$/i }).click();
await expect
.element(page.getByRole('button', { name: /^details$/i }))
.toHaveAttribute('aria-expanded', 'true');
});
it('renders the mobile kebab menu trigger when filePath is present', async () => {
render(DocumentTopBar, {
props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' } })
});
await expect.element(page.getByRole('button', { name: /weitere aktionen/i })).toBeVisible();
});
it('does not render the mobile kebab menu when there is no filePath and no canWrite/PDF combo', async () => {
render(DocumentTopBar, { props: baseProps() });
await expect
.element(page.getByRole('button', { name: /weitere aktionen/i }))
.not.toBeInTheDocument();
});
it('opens the mobile kebab menu when the trigger is clicked', async () => {
render(DocumentTopBar, {
props: baseProps({ doc: { ...baseDoc, 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('renders the metadata drawer content when detailsOpen is toggled on', async () => {
render(DocumentTopBar, { props: baseProps() });
await page.getByRole('button', { name: /^details$/i }).click();
const drawer = document.querySelector('[data-topbar] > div:nth-child(2)');
expect(drawer).not.toBeNull();
});
});

View File

@@ -1,103 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Props = {
documentId: string;
canWrite: boolean;
isPdf: boolean;
transcribeMode: boolean;
filePath?: string | null;
originalFilename?: string | null;
fileUrl: string;
};
let {
documentId,
canWrite,
isPdf,
transcribeMode = $bindable(),
filePath = null,
originalFilename = null,
fileUrl
}: Props = $props();
</script>
{#if canWrite && isPdf && !transcribeMode}
<button
onclick={() => (transcribeMode = true)}
aria-label={m.transcription_mode_label()}
aria-pressed={false}
class="hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex"
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
</button>
{/if}
{#if transcribeMode}
<button
onclick={() => (transcribeMode = false)}
aria-label={m.transcription_mode_stop()}
aria-pressed={true}
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary"
>
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_stop()}
</button>
{/if}
{#if canWrite && !transcribeMode}
<a
href="/documents/{documentId}/edit"
aria-label={m.btn_edit()}
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}
{#if filePath && !transcribeMode}
<a
href={fileUrl}
download={originalFilename}
class="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()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
</a>
{/if}

View File

@@ -1,94 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
afterEach(cleanup);
const baseProps = {
documentId: 'd1',
canWrite: false,
isPdf: false,
transcribeMode: false,
filePath: null as string | null,
originalFilename: 'brief.pdf' as string | null,
fileUrl: ''
};
describe('DocumentTopBarActions', () => {
it('renders nothing visible when canWrite is false and no file is present', async () => {
render(DocumentTopBarActions, { props: baseProps });
await expect
.element(page.getByRole('button', { name: /transkribieren/i }))
.not.toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument();
});
it('renders the transcribe button when canWrite, isPdf, and not transcribing', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, canWrite: true, isPdf: true, filePath: 'docs/x.pdf' }
});
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
});
it('omits the transcribe button when not a PDF', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' }
});
await expect
.element(page.getByRole('button', { name: /transkribieren/i }))
.not.toBeInTheDocument();
});
it('renders the stop-transcribe button when transcribeMode is true', async () => {
render(DocumentTopBarActions, {
props: {
...baseProps,
canWrite: true,
isPdf: true,
transcribeMode: true,
filePath: 'docs/x.pdf'
}
});
await expect.element(page.getByRole('button', { name: /fertig/i })).toBeVisible();
});
it('renders the edit link to the document edit route when canWrite and not transcribing', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, canWrite: true, documentId: 'doc-42' }
});
await expect
.element(page.getByRole('link', { name: /bearbeiten/i }))
.toHaveAttribute('href', '/documents/doc-42/edit');
});
it('hides the edit link when transcribeMode is true', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, canWrite: true, transcribeMode: true }
});
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
});
it('renders the download link when filePath is set and not in transcribe mode', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x' }
});
await expect.element(page.getByTitle('Herunterladen')).toBeVisible();
});
it('hides the download link when transcribeMode is true', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x', transcribeMode: true }
});
await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument();
});
});

View File

@@ -1,30 +0,0 @@
<script lang="ts">
import { formatDate } from '$lib/shared/utils/date';
type Props = {
title?: string | null;
originalFilename?: string | null;
documentDate?: string | null;
};
let { title, originalFilename, documentDate }: Props = $props();
const displayTitle = $derived(title || originalFilename || '');
const shortDate = $derived(documentDate ? formatDate(documentDate, 'short') : null);
const longDate = $derived(documentDate ? formatDate(documentDate, 'long') : null);
</script>
<div class="min-w-0 flex-1 overflow-hidden">
<h1
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
title={displayTitle}
>
{displayTitle}
</h1>
{#if shortDate}
<p class="font-sans text-[16px] text-ink-2">
<span class="lg:hidden">{shortDate}</span>
<span class="hidden lg:inline">{longDate}</span>
</p>
{/if}
</div>

View File

@@ -1,64 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
afterEach(cleanup);
const baseProps = {
title: 'Brief an Helene' as string | null,
originalFilename: 'brief.pdf' as string | null,
documentDate: '1923-04-15' as string | null
};
describe('DocumentTopBarTitle', () => {
it('renders the title as a level-1 heading', async () => {
render(DocumentTopBarTitle, { props: baseProps });
await expect
.element(page.getByRole('heading', { level: 1, name: 'Brief an Helene' }))
.toBeVisible();
});
it('falls back to originalFilename when title is null', async () => {
render(DocumentTopBarTitle, { props: { ...baseProps, title: null } });
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
});
it('falls back to originalFilename when title is an empty string', async () => {
render(DocumentTopBarTitle, { props: { ...baseProps, title: '' } });
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
});
it('renders the short date format when a documentDate is supplied', async () => {
render(DocumentTopBarTitle, { props: baseProps });
await expect.element(page.getByText('15.04.1923')).toBeVisible();
});
it('omits the date paragraph entirely when documentDate is null', async () => {
render(DocumentTopBarTitle, { props: { ...baseProps, documentDate: null } });
expect(document.querySelector('p')).toBeNull();
});
it('uses the title (not the originalFilename) for the title attribute when title is set', async () => {
render(DocumentTopBarTitle, { props: baseProps });
const heading = (await page
.getByRole('heading', { name: 'Brief an Helene' })
.element()) as HTMLElement;
expect(heading.getAttribute('title')).toBe('Brief an Helene');
});
it('uses the originalFilename for the title attribute when title is null', async () => {
render(DocumentTopBarTitle, { props: { ...baseProps, title: null } });
const heading = (await page
.getByRole('heading', { name: 'brief.pdf' })
.element()) as HTMLElement;
expect(heading.getAttribute('title')).toBe('brief.pdf');
});
});

View File

@@ -1,75 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentViewer from './DocumentViewer.svelte';
afterEach(cleanup);
const baseProps = {
doc: { id: 'd1', filePath: null, contentType: null, fileHash: null },
fileUrl: '',
isLoading: false,
error: '',
transcribeMode: false,
blockNumbers: {},
annotationReloadKey: 0,
activeAnnotationId: null,
annotationsDimmed: false,
flashAnnotationId: null,
onAnnotationClick: () => {}
};
describe('DocumentViewer', () => {
it('renders the loading spinner and label when isLoading is true', async () => {
render(DocumentViewer, { props: { ...baseProps, isLoading: true } });
await expect.element(page.getByText('Lade Dokument...')).toBeVisible();
});
it('renders the error message when error is set', async () => {
render(DocumentViewer, { props: { ...baseProps, error: 'Datei nicht verfügbar' } });
await expect.element(page.getByText('Datei nicht verfügbar')).toBeVisible();
});
it('shows the direct-download link in the error state when filePath is present', async () => {
render(DocumentViewer, {
props: {
...baseProps,
doc: { ...baseProps.doc, filePath: 'docs/scan.pdf' },
error: 'Render failed'
}
});
await expect
.element(page.getByRole('link', { name: /direkter download/i }))
.toHaveAttribute('href', '/api/documents/d1/file');
});
it('omits the direct-download link in the error state when filePath is null', async () => {
render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } });
await expect
.element(page.getByRole('link', { name: /direkter download/i }))
.not.toBeInTheDocument();
});
it('renders the no-scan placeholder when filePath is null and there is no error', async () => {
render(DocumentViewer, { props: baseProps });
await expect.element(page.getByText('Kein Scan vorhanden')).toBeVisible();
});
it('renders an <img> for non-PDF content types when fileUrl is present', async () => {
render(DocumentViewer, {
props: {
...baseProps,
doc: { ...baseProps.doc, filePath: 'docs/x.jpg', contentType: 'image/jpeg' },
fileUrl: '/api/documents/d1/file'
}
});
const img = await page.getByRole('img', { name: /original-scan/i }).element();
expect(img.getAttribute('src')).toBe('/api/documents/d1/file');
});
});

View File

@@ -1,219 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
afterEach(cleanup);
const makeEntry = (id: string, title: string, overrides: Record<string, unknown> = {}) => ({
id,
title,
status: 'idle' as 'idle' | 'error',
previewUrl: '',
...overrides
});
describe('FileSwitcherStrip', () => {
it('renders the prev and next buttons', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A.pdf')],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
await expect.element(page.getByRole('button', { name: /vorherige datei/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /nächste datei/i })).toBeVisible();
});
it('renders one chip per file', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A.pdf'), makeEntry('f2', 'B.pdf'), makeEntry('f3', 'C.pdf')],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
const chips = document.querySelectorAll('[data-chip-id]');
expect(chips.length).toBe(3);
});
it('marks the active chip with aria-current=true', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
activeId: 'f2',
onSelect: () => {},
onRemove: () => {}
}
});
const f2 = document.querySelector('[data-chip-id="f2"]') as HTMLElement;
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
expect(f2.getAttribute('aria-current')).toBe('true');
expect(f1.getAttribute('aria-current')).toBeNull();
});
it('shows the error indicator on chips with status="error"', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A.pdf', { status: 'error' })],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
const chip = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
expect(chip.getAttribute('data-status')).toBe('error');
});
it('calls onSelect with the chip id when clicked', async () => {
const onSelect = vi.fn();
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
activeId: 'f1',
onSelect,
onRemove: () => {}
}
});
const f2 = document.querySelector('[data-chip-id="f2"]') as HTMLElement;
f2.click();
expect(onSelect).toHaveBeenCalledWith('f2');
});
it('calls onRemove when the remove button is clicked', async () => {
const onRemove = vi.fn();
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
activeId: 'f1',
onSelect: () => {},
onRemove
}
});
const remove = document.querySelector('[data-remove-id="f1"]') as HTMLElement;
remove.click();
expect(onRemove).toHaveBeenCalledWith('f1');
});
it('renders the active title in the sr-only announcer', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'Ein Brief.pdf'), makeEntry('f2', 'B')],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
const announcer = document.querySelector('[aria-live="polite"]');
expect(announcer?.textContent).toContain('Ein Brief.pdf');
});
it('prev button on a single-file strip is a no-op (active chip stays)', async () => {
const onSelect = vi.fn();
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A.pdf')],
activeId: 'f1',
onSelect,
onRemove: () => {}
}
});
await page.getByRole('button', { name: /vorherige datei/i }).click();
// The active chip is still f1 and onSelect was not invoked with a different id.
expect(document.querySelector('[data-chip-id="f1"]')?.getAttribute('aria-current')).toBe(
'true'
);
expect(onSelect).not.toHaveBeenCalled();
});
it('next button on a single-file strip is a no-op (active chip stays)', async () => {
const onSelect = vi.fn();
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A.pdf')],
activeId: 'f1',
onSelect,
onRemove: () => {}
}
});
await page.getByRole('button', { name: /nächste datei/i }).click();
expect(document.querySelector('[data-chip-id="f1"]')?.getAttribute('aria-current')).toBe(
'true'
);
expect(onSelect).not.toHaveBeenCalled();
});
it('navigates with ArrowRight key on focused chip', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B'), makeEntry('f3', 'C')],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
f1.focus();
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await vi.waitFor(() => {
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
});
});
it('navigates with ArrowLeft key on focused chip (wraps around)', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
f1.focus();
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await vi.waitFor(() => {
// ArrowLeft from index 0 wraps to last (f2).
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
});
});
it('ArrowDown is treated as ArrowRight (vertical key alias)', async () => {
render(FileSwitcherStrip, {
props: {
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
activeId: 'f1',
onSelect: () => {},
onRemove: () => {}
}
});
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
f1.focus();
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await vi.waitFor(() => {
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
});
});
});

View File

@@ -1,43 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ScriptTypeSelect from './ScriptTypeSelect.svelte';
afterEach(cleanup);
describe('ScriptTypeSelect', () => {
it('renders the label and select', async () => {
render(ScriptTypeSelect, { props: { value: '' } });
await expect.element(page.getByLabelText(/schrifttyp/i)).toBeVisible();
});
it('renders all four option values', async () => {
render(ScriptTypeSelect, { props: { value: '' } });
const options = document.querySelectorAll('option');
const values = Array.from(options).map((o) => (o as HTMLOptionElement).value);
expect(values).toEqual(['', 'TYPEWRITER', 'HANDWRITING_LATIN', 'HANDWRITING_KURRENT']);
});
it('marks the placeholder option as disabled', async () => {
render(ScriptTypeSelect, { props: { value: '' } });
const placeholder = document.querySelector('option[value=""]') as HTMLOptionElement;
expect(placeholder.disabled).toBe(true);
});
it('initialises the select with the supplied value', async () => {
render(ScriptTypeSelect, { props: { value: 'TYPEWRITER' } });
const select = (await page.getByRole('combobox').element()) as HTMLSelectElement;
expect(select.value).toBe('TYPEWRITER');
});
it('disables the select when the disabled prop is true', async () => {
render(ScriptTypeSelect, { props: { value: '', disabled: true } });
const select = (await page.getByRole('combobox').element()) as HTMLSelectElement;
expect(select.disabled).toBe(true);
});
});

View File

@@ -1,102 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
afterEach(cleanup);
const baseProps = (overrides: Record<string, unknown> = {}) => ({
filled: [
{ month: '1923-01', count: 5 },
{ month: '1923-02', count: 1 },
{ month: '1923-03', count: 0 }
],
maxCount: 5,
barAreaHeight: 100,
isSelected: () => false,
isInDragPreview: () => false,
isDragging: false,
dragWindowLeftPct: 0,
dragWindowRightPct: 0,
onbarpointerdown: () => {},
onbarpointerenter: () => {},
onbarclick: () => {},
...overrides
});
import TimelineBars from './TimelineBars.svelte';
describe('TimelineBars', () => {
it('renders one bar per filled bucket', async () => {
render(TimelineBars, { props: baseProps() });
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
expect(bars.length).toBe(3);
});
it('uses the singular aria-label when count is 1', async () => {
render(TimelineBars, { props: baseProps() });
const bars = Array.from(
document.querySelectorAll('[data-testid="timeline-bar"]')
) as HTMLButtonElement[];
expect(bars[1].getAttribute('aria-label')).toContain('1 Dokument');
});
it('uses the plural aria-label when count is greater than 1', async () => {
render(TimelineBars, { props: baseProps() });
const bars = Array.from(
document.querySelectorAll('[data-testid="timeline-bar"]')
) as HTMLButtonElement[];
expect(bars[0].getAttribute('aria-label')).toContain('5 Dokumente');
});
it('marks the bar as aria-pressed when isSelected returns true', async () => {
render(TimelineBars, {
props: baseProps({ isSelected: (label: string) => label === '1923-01' })
});
const bars = Array.from(
document.querySelectorAll('[data-testid="timeline-bar"]')
) as HTMLButtonElement[];
expect(bars[0].getAttribute('aria-pressed')).toBe('true');
expect(bars[1].getAttribute('aria-pressed')).toBe('false');
});
it('renders the drag window only when isDragging is true', async () => {
render(TimelineBars, {
props: baseProps({ isDragging: true, dragWindowLeftPct: 10, dragWindowRightPct: 30 })
});
const dragWindow = document.querySelector('[data-testid="timeline-drag-window"]');
expect(dragWindow).not.toBeNull();
});
it('omits the drag window when isDragging is false', async () => {
render(TimelineBars, { props: baseProps() });
const dragWindow = document.querySelector('[data-testid="timeline-drag-window"]');
expect(dragWindow).toBeNull();
});
it('calls onbarclick with the bucket index when a bar is clicked', async () => {
const onbarclick = vi.fn();
render(TimelineBars, { props: baseProps({ onbarclick }) });
const bars = Array.from(
document.querySelectorAll('[data-testid="timeline-bar"]')
) as HTMLButtonElement[];
bars[1].click();
expect(onbarclick).toHaveBeenCalledWith(1);
});
it('uses minimum bar height for zero-count buckets', async () => {
render(TimelineBars, { props: baseProps() });
const bars = Array.from(
document.querySelectorAll('[data-testid="timeline-bar"]')
) as HTMLButtonElement[];
const zeroBar = bars[2].querySelector('.bar-fill') as HTMLElement;
expect(zeroBar.style.height).toContain('2px');
});
});

View File

@@ -1,84 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TimelineControls from './TimelineControls.svelte';
afterEach(cleanup);
describe('TimelineControls', () => {
it('renders neither button when not zoomed and no selection', async () => {
render(TimelineControls, {
props: {
isZoomed: false,
hasSelection: false,
onresetzoom: () => {},
onclearselection: () => {}
}
});
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBe(0);
});
it('renders the reset-zoom button when isZoomed is true', async () => {
render(TimelineControls, {
props: {
isZoomed: true,
hasSelection: false,
onresetzoom: () => {},
onclearselection: () => {}
}
});
await expect.element(page.getByRole('button', { name: /zur übersicht/i })).toBeVisible();
});
it('renders the clear-selection button when hasSelection is true', async () => {
render(TimelineControls, {
props: {
isZoomed: false,
hasSelection: true,
onresetzoom: () => {},
onclearselection: () => {}
}
});
await expect.element(page.getByRole('button', { name: /auswahl zurücksetzen/i })).toBeVisible();
});
it('renders both buttons when both flags are true', async () => {
render(TimelineControls, {
props: {
isZoomed: true,
hasSelection: true,
onresetzoom: () => {},
onclearselection: () => {}
}
});
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBe(2);
});
it('calls onresetzoom when the reset button is clicked', async () => {
const onresetzoom = vi.fn();
render(TimelineControls, {
props: { isZoomed: true, hasSelection: false, onresetzoom, onclearselection: () => {} }
});
await page.getByRole('button', { name: /zur übersicht/i }).click();
expect(onresetzoom).toHaveBeenCalledOnce();
});
it('calls onclearselection when the clear button is clicked', async () => {
const onclearselection = vi.fn();
render(TimelineControls, {
props: { isZoomed: false, hasSelection: true, onresetzoom: () => {}, onclearselection }
});
await page.getByRole('button', { name: /auswahl zurücksetzen/i }).click();
expect(onclearselection).toHaveBeenCalledOnce();
});
});

View File

@@ -1,54 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TimelineXAxis from './TimelineXAxis.svelte';
afterEach(cleanup);
const bucket = (month: string, count = 1) => ({ month, count });
describe('TimelineXAxis', () => {
it('renders no ticks when filled is empty', async () => {
render(TimelineXAxis, { props: { filled: [] } });
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
expect(ticks.length).toBe(0);
});
it('renders tick marks when filled buckets are present', async () => {
const filled = Array.from({ length: 12 }, (_, i) =>
bucket(`1923-${String(i + 1).padStart(2, '0')}`)
);
render(TimelineXAxis, { props: { filled } });
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
expect(ticks.length).toBeGreaterThan(0);
});
it('omits the year when all visible buckets share the same year', async () => {
const filled = Array.from({ length: 12 }, (_, i) =>
bucket(`1923-${String(i + 1).padStart(2, '0')}`)
);
render(TimelineXAxis, { props: { filled } });
const ticks = Array.from(document.querySelectorAll('[data-testid="timeline-x-tick"]'));
const allText = ticks.map((t) => t.textContent ?? '').join(' ');
expect(allText).not.toContain('1923');
});
it('shows the year when buckets span multiple years', async () => {
const filled = [bucket('1923-01'), bucket('1924-06'), bucket('1925-12')];
render(TimelineXAxis, { props: { filled } });
const ticks = Array.from(document.querySelectorAll('[data-testid="timeline-x-tick"]'));
const allText = ticks.map((t) => t.textContent ?? '').join(' ');
expect(allText).toMatch(/19\d{2}/);
});
it('handles single-year (length-4) bucket month strings without omitting the year', async () => {
const filled = [bucket('1923'), bucket('1924')];
render(TimelineXAxis, { props: { filled } });
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
expect(ticks.length).toBeGreaterThan(0);
});
});

View File

@@ -1,29 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TimelineYAxis from './TimelineYAxis.svelte';
afterEach(cleanup);
describe('TimelineYAxis', () => {
it('renders the maxCount and 0 labels', async () => {
render(TimelineYAxis, { props: { maxCount: 42, barAreaHeight: 100 } });
const axis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
expect(axis.textContent).toContain('42');
expect(axis.textContent).toContain('0');
});
it('applies the supplied barAreaHeight as inline style', async () => {
render(TimelineYAxis, { props: { maxCount: 10, barAreaHeight: 250 } });
const axis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
expect(axis.style.height).toBe('250px');
});
it('renders zero count without crashing', async () => {
render(TimelineYAxis, { props: { maxCount: 0, barAreaHeight: 100 } });
const axis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
expect(axis).not.toBeNull();
});
});

View File

@@ -1,10 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadZone from './UploadZone.svelte';
afterEach(cleanup);
describe('UploadZone', () => {
describe('idle state', () => {
it('shows the filename in the upload zone', async () => {

View File

@@ -1,74 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import WhoWhenSection from './WhoWhenSection.svelte';
afterEach(cleanup);
describe('WhoWhenSection — date input behavior', () => {
it('marks the date input as invalid when input has text but no valid ISO', async () => {
render(WhoWhenSection, {});
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
dateInput.value = '32.13';
dateInput.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => {
// Invalid → border-red-400 class
expect(dateInput.className).toContain('border-red-400');
expect(document.querySelector('#date-error')).not.toBeNull();
});
});
it('does not show the error before the user has typed', async () => {
render(WhoWhenSection, {});
const error = document.querySelector('#date-error');
expect(error).toBeNull();
});
it('updates the hidden ISO input when typing a valid German date', async () => {
render(WhoWhenSection, {});
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
dateInput.value = '15.03.2024';
dateInput.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => {
const hidden = document.querySelector(
'input[name="documentDate"][type="hidden"]'
) as HTMLInputElement;
expect(hidden.value).toBe('2024-03-15');
});
});
it('renders the location input outside editMode with initialLocation', async () => {
render(WhoWhenSection, { editMode: false, initialLocation: 'Hamburg' });
const loc = document.querySelector('input#location') as HTMLInputElement;
expect(loc.value).toBe('Hamburg');
});
it('hides the location input in editMode', async () => {
render(WhoWhenSection, { editMode: true });
const loc = document.querySelector('input#location');
expect(loc).toBeNull();
});
it('shows the FieldLabelBadge for receivers in editMode', async () => {
render(WhoWhenSection, { editMode: true });
// FieldLabelBadge with variant=additive is rendered (just check the heading area)
const labels = Array.from(document.querySelectorAll('p, label')).filter((el) =>
/empfänger/i.test(el.textContent ?? '')
);
expect(labels.length).toBeGreaterThan(0);
});
it('renders the date asterisk indicator (required field)', async () => {
render(WhoWhenSection, {});
const label = document.querySelector('label[for="documentDate"]');
expect(label?.textContent).toContain('*');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
import type { Annotation } from '$lib/shared/types';
@@ -15,28 +15,17 @@ const annotation: Annotation = {
createdAt: '2026-01-01T00:00:00Z'
};
afterEach(cleanup);
function getSvg(): SVGSVGElement {
const svg = document.querySelector('svg[role="application"]') as SVGSVGElement;
if (!svg) throw new Error('no overlay svg');
return svg;
}
function makePointerEvent(type: string, init: PointerEventInit = {}): PointerEvent {
return new PointerEvent(type, { isPrimary: true, bubbles: true, pointerId: 1, ...init });
}
function makeKeyEvent(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
return new KeyboardEvent('keydown', { key, bubbles: true, ...init });
}
describe('AnnotationEditOverlay — structure', () => {
it('renders 8 handle elements (4 corners + 4 edges)', async () => {
describe('AnnotationEditOverlay', () => {
it('renders 8 handle elements', async () => {
render(AnnotationEditOverlay, { annotation });
const handles = document.querySelectorAll('[data-handle]');
expect(handles).toHaveLength(8);
});
it('renders handles for all four corners and four edge midpoints', async () => {
render(AnnotationEditOverlay, { annotation });
expect(document.querySelector('[data-handle="nw"]')).not.toBeNull();
expect(document.querySelector('[data-handle="ne"]')).not.toBeNull();
expect(document.querySelector('[data-handle="sw"]')).not.toBeNull();
@@ -47,7 +36,7 @@ describe('AnnotationEditOverlay — structure', () => {
expect(document.querySelector('[data-handle="w"]')).not.toBeNull();
});
it('each handle has a 44×44 hit area', async () => {
it('each handle has a 44x44 hit area', async () => {
render(AnnotationEditOverlay, { annotation });
const hitAreas = document.querySelectorAll('[data-handle-hit]');
@@ -58,7 +47,7 @@ describe('AnnotationEditOverlay — structure', () => {
});
});
it('renders a move area covering the full overlay', async () => {
it('renders a move area covering the full box', async () => {
render(AnnotationEditOverlay, { annotation });
const moveArea = document.querySelector('[data-move-area]');
@@ -68,271 +57,15 @@ describe('AnnotationEditOverlay — structure', () => {
it('renders an aria-live region for screen reader announcement', async () => {
render(AnnotationEditOverlay, { annotation });
const live = document.querySelector('[aria-live="polite"]');
expect(live).not.toBeNull();
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).not.toBeNull();
});
it('SVG root has tabindex=0 and role=application for keyboard focus', async () => {
it('SVG root has tabindex="0" so it can receive keyboard focus', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
expect(svg.getAttribute('tabindex')).toBe('0');
expect(svg.getAttribute('role')).toBe('application');
});
});
describe('AnnotationEditOverlay — keyboard navigation', () => {
it('moves left on ArrowLeft', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowLeft'));
// no thrown error — branches reached
expect(true).toBe(true);
});
it('moves right on ArrowRight', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowRight'));
expect(true).toBe(true);
});
it('moves up on ArrowUp', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowUp'));
expect(true).toBe(true);
});
it('moves down on ArrowDown', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowDown'));
expect(true).toBe(true);
});
it('uses larger step when shiftKey is pressed', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowLeft', { shiftKey: true }));
expect(true).toBe(true);
});
it('ignores non-arrow keys without preventDefault', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
const evt = makeKeyEvent('Enter');
svg.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(false);
});
it('clamps the position at left edge (x=0)', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowLeft'));
expect(true).toBe(true);
});
it('clamps the position at top edge (y=0)', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowUp'));
expect(true).toBe(true);
});
it('clamps at right edge so x + width never exceeds 1', async () => {
render(AnnotationEditOverlay, {
annotation: { ...annotation, x: 0.99, y: 0.5, width: 0.005, height: 0.4 }
});
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowRight'));
expect(true).toBe(true);
});
it('clamps at bottom edge so y + height never exceeds 1', async () => {
render(AnnotationEditOverlay, {
annotation: { ...annotation, x: 0.5, y: 0.99, width: 0.3, height: 0.005 }
});
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowDown'));
expect(true).toBe(true);
});
});
describe('AnnotationEditOverlay — handle keyboard', () => {
it('handle <g> exposes role=button so keyboard activates it', async () => {
render(AnnotationEditOverlay, { annotation });
const handle = document.querySelector('[data-handle="nw"]') as SVGGElement;
expect(handle.getAttribute('role')).toBe('button');
expect(handle.getAttribute('tabindex')).toBe('0');
});
});
describe('AnnotationEditOverlay — pointer drag (move)', () => {
it('starts a move drag on pointerdown on the move-area', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
// stub setPointerCapture so it doesn't throw without a real capturing implementation
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 100, clientY: 100 }));
expect(true).toBe(true);
});
it('ignores non-primary pointerdown', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(
new PointerEvent('pointerdown', {
isPrimary: false,
bubbles: true,
pointerId: 99,
clientX: 0,
clientY: 0
})
);
expect(true).toBe(true);
});
it('handles pointermove without an active drag (early-return branch)', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 0, clientY: 0 }));
expect(true).toBe(true);
});
it('handles pointerup without an active drag (early-return branch)', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 0, clientY: 0 }));
expect(true).toBe(true);
});
});
describe('AnnotationEditOverlay — pointer drag (handle)', () => {
it.each(['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'])(
'starts a handle drag from %s without throwing',
async (id) => {
render(AnnotationEditOverlay, { annotation });
const handle = document.querySelector(`[data-handle="${id}"]`) as SVGGElement;
(handle as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
vi.fn();
handle.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
expect(true).toBe(true);
}
);
it.each(['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'])(
'completes a full drag cycle (down + move + up) from handle %s',
async (id) => {
render(AnnotationEditOverlay, { annotation });
const handle = document.querySelector(`[data-handle="${id}"]`) as SVGGElement;
(handle as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
vi.fn();
const svg = getSvg();
handle.dispatchEvent(makePointerEvent('pointerdown', { clientX: 100, clientY: 100 }));
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 110, clientY: 110 }));
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 110, clientY: 110 }));
expect(true).toBe(true);
}
);
it('completes a move drag (down + move + up) on the move-area', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
const svg = getSvg();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 60, clientY: 60 }));
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 60, clientY: 60 }));
expect(true).toBe(true);
});
it('ignores non-primary pointermove', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
const svg = getSvg();
expect(() =>
svg.dispatchEvent(
new PointerEvent('pointermove', {
isPrimary: false,
bubbles: true,
pointerId: 99,
clientX: 60,
clientY: 60
})
)
).not.toThrow();
});
it('ignores non-primary pointerup', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
const svg = getSvg();
expect(() =>
svg.dispatchEvent(
new PointerEvent('pointerup', {
isPrimary: false,
bubbles: true,
pointerId: 99,
clientX: 60,
clientY: 60
})
)
).not.toThrow();
});
it('returns early on pointerup without movement (no save)', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
const svg = getSvg();
// Down then up at same coords — preDrag values match live values, no-op branch
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 50, clientY: 50 }));
expect(true).toBe(true);
const svg = document.querySelector('svg[role="application"]');
expect(svg).not.toBeNull();
expect(svg!.getAttribute('tabindex')).toBe('0');
});
});

View File

@@ -157,212 +157,4 @@ describe('AnnotationLayer', () => {
expect(el.classList.contains('annotation-flash')).toBe(false);
});
});
describe('container style', () => {
it('uses crosshair cursor when canDraw is true', async () => {
render(AnnotationLayer, {
annotations: [],
canDraw: true,
color: '#00c7b1',
onDraw: () => {}
});
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
expect(wrapper.style.cursor).toContain('crosshair');
expect(wrapper.style.touchAction).toBe('none');
});
it('omits crosshair cursor when canDraw is false', async () => {
render(AnnotationLayer, {
annotations: [],
canDraw: false,
color: '#00c7b1',
onDraw: () => {}
});
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
expect(wrapper.style.cursor).not.toContain('crosshair');
});
});
describe('annotation pointer hover', () => {
it('updates hoveredId on pointerenter and clears on pointerleave', async () => {
render(AnnotationLayer, {
annotations: [annotation],
canDraw: false,
color: '#00c7b1',
onDraw: () => {}
});
const ann = document.querySelector('[data-testid="annotation-ann-1"]') as HTMLElement;
ann.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
ann.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
// No throw is the assertion
expect(true).toBe(true);
});
it('renders both annotations with activeAnnotationId set', async () => {
const second: Annotation = {
...annotation,
id: 'ann-other',
x: 0.5,
y: 0.5
};
render(AnnotationLayer, {
annotations: [annotation, second],
canDraw: false,
color: '#00c7b1',
activeAnnotationId: 'ann-1',
dimmed: false,
onDraw: () => {}
});
const otherEl = document.querySelector('[data-testid="annotation-ann-other"]');
const activeEl = document.querySelector('[data-testid="annotation-ann-1"]');
expect(otherEl).not.toBeNull();
expect(activeEl).not.toBeNull();
});
it('skips faded styling when dimmed is true (dimmed wins over faded)', async () => {
const second: Annotation = { ...annotation, id: 'ann-other' };
render(AnnotationLayer, {
annotations: [annotation, second],
canDraw: false,
color: '#00c7b1',
activeAnnotationId: 'ann-1',
dimmed: true,
onDraw: () => {}
});
// Dimmed mode: badge hidden but renders
expect(document.querySelector('[data-testid="annotation-ann-1"]')).not.toBeNull();
});
it('renders without throwing when canDraw is true (delete button visible)', async () => {
expect(() =>
render(AnnotationLayer, {
annotations: [annotation],
canDraw: true,
color: '#00c7b1',
onDraw: () => {}
})
).not.toThrow();
});
it('renders without throwing when blockNumbers map has entries', async () => {
expect(() =>
render(AnnotationLayer, {
annotations: [annotation],
canDraw: false,
color: '#00c7b1',
blockNumbers: { 'ann-1': 5 },
onDraw: () => {}
})
).not.toThrow();
expect(document.body.textContent).toContain('5');
});
});
describe('drawing pointer flow', () => {
it('does not start a draw when canDraw is false', async () => {
render(AnnotationLayer, {
annotations: [],
canDraw: false,
color: '#00c7b1',
onDraw: () => {}
});
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
(wrapper as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
() => {};
wrapper.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
clientX: 50,
clientY: 50,
pointerId: 1
})
);
// No preview rect rendered
const preview = wrapper.querySelector('div[style*="border: 2px dashed"]');
expect(preview).toBeNull();
});
it('does not start a draw when pointerdown lands on an existing annotation', async () => {
render(AnnotationLayer, {
annotations: [annotation],
canDraw: true,
color: '#00c7b1',
onDraw: () => {}
});
const ann = document.querySelector('[data-testid="annotation-ann-1"]') as HTMLElement;
(ann as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = () => {};
// pointerdown bubbles to the layer; layer should refuse to draw because
// closest('[data-annotation]') matches.
ann.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
clientX: 0,
clientY: 0,
pointerId: 1
})
);
const preview = document.querySelector('div[style*="border: 2px dashed"]');
expect(preview).toBeNull();
});
it('renders no preview rect when no draw is in progress', async () => {
render(AnnotationLayer, {
annotations: [],
canDraw: true,
color: '#00c7b1',
onDraw: () => {}
});
const preview = document.querySelector('div[style*="border: 2px dashed"]');
expect(preview).toBeNull();
});
it('handles pointermove without a started draw (early-return)', async () => {
render(AnnotationLayer, {
annotations: [],
canDraw: true,
color: '#00c7b1',
onDraw: () => {}
});
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
expect(() =>
wrapper.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, clientX: 0, clientY: 0 })
)
).not.toThrow();
});
it('handles pointerup without a started draw (early-return)', async () => {
let drawn = false;
render(AnnotationLayer, {
annotations: [],
canDraw: true,
color: '#00c7b1',
onDraw: () => {
drawn = true;
}
});
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
wrapper.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, clientX: 0, clientY: 0 })
);
expect(drawn).toBe(false);
});
});
});

View File

@@ -1,77 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionColumn from './TranscriptionColumn.svelte';
afterEach(cleanup);
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
documentDate: '1923-04-15',
textedBlockCount: 0,
annotationCount: 10,
contributors: [],
hasMoreContributors: false,
...overrides
});
describe('TranscriptionColumn', () => {
it('renders the empty placeholder when docs is empty', async () => {
render(TranscriptionColumn, { props: { docs: [], weeklyCount: 0 } });
await expect.element(page.getByText(/Keine Dokumente warten/i)).toBeVisible();
});
it('renders the heading when docs has items', async () => {
render(TranscriptionColumn, { props: { docs: [makeDoc()], weeklyCount: 0 } });
await expect.element(page.getByRole('heading', { name: /text transkribieren/i })).toBeVisible();
});
it('renders the weekly pulse when weeklyCount > 0', async () => {
render(TranscriptionColumn, { props: { docs: [makeDoc()], weeklyCount: 5 } });
await expect.element(page.getByText(/diese Woche/i)).toBeVisible();
});
it('hides the weekly pulse when weeklyCount is 0', async () => {
render(TranscriptionColumn, { props: { docs: [makeDoc()], weeklyCount: 0 } });
await expect.element(page.getByText(/diese Woche/i)).not.toBeInTheDocument();
});
it('shows the block progress label when textedBlockCount > 0', async () => {
render(TranscriptionColumn, {
props: {
docs: [makeDoc({ textedBlockCount: 3, annotationCount: 10 })],
weeklyCount: 0
}
});
await expect.element(page.getByText('3 / 10 Blöcke')).toBeVisible();
});
it('shows the em-dash placeholder when textedBlockCount is 0', async () => {
render(TranscriptionColumn, { props: { docs: [makeDoc()], weeklyCount: 0 } });
expect(document.body.textContent).toContain('—');
});
it('renders the document title as a link with task=transcribe query', async () => {
render(TranscriptionColumn, { props: { docs: [makeDoc()], weeklyCount: 0 } });
await expect
.element(page.getByRole('link', { name: /brief 1923/i }))
.toHaveAttribute('href', '/documents/d1?task=transcribe');
});
it('omits the date when documentDate is undefined', async () => {
render(TranscriptionColumn, {
props: { docs: [makeDoc({ documentDate: undefined })], weeklyCount: 0 }
});
// formatMCDate should not be called; just verify component renders
await expect.element(page.getByRole('link', { name: /brief 1923/i })).toBeVisible();
});
});

View File

@@ -1,302 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
const { default: TranscriptionEditView } = await import('./TranscriptionEditView.svelte');
import type { TranscriptionBlockData } from '$lib/shared/types';
afterEach(cleanup);
const baseBlock = (overrides: Partial<TranscriptionBlockData> = {}): TranscriptionBlockData =>
({
id: 'b-1',
annotationId: 'ann-1',
text: 'Hello',
sortOrder: 1,
reviewed: false,
mentionedPersons: [],
label: null,
...overrides
}) as TranscriptionBlockData;
const baseProps = (overrides: Record<string, unknown> = {}) => ({
documentId: 'doc-1',
blocks: [] as TranscriptionBlockData[],
canComment: false,
currentUserId: null,
onBlockFocus: () => {},
onSaveBlock: async () => {},
onDeleteBlock: async () => {},
onReviewToggle: async () => {},
...overrides
});
describe('TranscriptionEditView', () => {
it('renders the empty-state coach when there are no blocks', async () => {
render(TranscriptionEditView, { props: baseProps() });
// TranscribeCoachEmptyState renders some German text
expect(document.body.textContent).toMatch(/markier|block|transkrip/i);
});
it('renders the review progress counter when there are blocks', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock({ id: 'b1', reviewed: false }), baseBlock({ id: 'b2', reviewed: true })]
})
});
expect(document.body.textContent).toMatch(/1\s*\/\s*2/);
});
it('shows the "alle als fertig markieren" button when onMarkAllReviewed is provided', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
onMarkAllReviewed: async () => {}
})
});
await expect.element(page.getByRole('button', { name: /alle als fertig/i })).toBeVisible();
});
it('disables the mark-all-reviewed button when all blocks are reviewed', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock({ reviewed: true })],
onMarkAllReviewed: async () => {}
})
});
const btn = (await page
.getByRole('button', { name: /alle als fertig/i })
.element()) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('enables the mark-all-reviewed button when not all blocks are reviewed', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock({ reviewed: false })],
onMarkAllReviewed: async () => {}
})
});
const btn = (await page
.getByRole('button', { name: /alle als fertig/i })
.element()) as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it('hides the mark-all-reviewed button when onMarkAllReviewed is not provided', async () => {
render(TranscriptionEditView, { props: baseProps({ blocks: [baseBlock()] }) });
await expect
.element(page.getByRole('button', { name: /alle als fertig/i }))
.not.toBeInTheDocument();
});
it('renders the OcrTrigger only when canRunOcr is true and onTriggerOcr is provided', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canRunOcr: true,
onTriggerOcr: () => {}
})
});
// OcrTrigger renders a select with script-type options
const select = document.querySelector('select');
expect(select).not.toBeNull();
});
it('hides the OcrTrigger when canRunOcr is false', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canRunOcr: false,
onTriggerOcr: () => {}
})
});
const select = document.querySelector('select');
expect(select).toBeNull();
});
it('renders the training-label chips when canWrite=true and there are blocks', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
trainingLabels: [],
onToggleTrainingLabel: async () => {}
})
});
// Training-label section caption
expect(document.body.textContent).toMatch(/training/i);
});
it('hides the training-label section when canWrite is false', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canWrite: false
})
});
expect(document.body.textContent).not.toMatch(/Für Training vormerken/i);
});
it('toggles the training label chip when clicked', async () => {
const onToggleTrainingLabel = vi.fn().mockResolvedValue(undefined);
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
trainingLabels: [],
onToggleTrainingLabel
})
});
const chip = Array.from(document.querySelectorAll('button')).find((b) =>
/kurrent|segmentier/i.test(b.textContent ?? '')
);
expect(chip).toBeDefined();
chip?.click();
await vi.waitFor(() => expect(onToggleTrainingLabel).toHaveBeenCalled());
});
it('renders blocks sorted by sortOrder', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [
baseBlock({ id: 'b3', sortOrder: 3, text: 'Third' }),
baseBlock({ id: 'b1', sortOrder: 1, text: 'First' }),
baseBlock({ id: 'b2', sortOrder: 2, text: 'Second' })
]
})
});
const text = document.body.textContent ?? '';
const idxFirst = text.indexOf('First');
const idxSecond = text.indexOf('Second');
const idxThird = text.indexOf('Third');
expect(idxFirst).toBeLessThan(idxSecond);
expect(idxSecond).toBeLessThan(idxThird);
});
it('renders both blocks with their text after rerender with a new activeAnnotationId', async () => {
const { rerender } = render(TranscriptionEditView, {
props: baseProps({
blocks: [
baseBlock({ id: 'b1', annotationId: 'ann-1', sortOrder: 1, text: 'First' }),
baseBlock({ id: 'b2', annotationId: 'ann-2', sortOrder: 2, text: 'Second' })
],
activeAnnotationId: null
})
});
// re-render with activeAnnotationId set to ann-2 — the activeBlockId $effect re-runs
// and both blocks must still be present in the rendered list.
await rerender({
...baseProps({
blocks: [
baseBlock({ id: 'b1', annotationId: 'ann-1', sortOrder: 1, text: 'First' }),
baseBlock({ id: 'b2', annotationId: 'ann-2', sortOrder: 2, text: 'Second' })
],
activeAnnotationId: 'ann-2'
})
});
await vi.waitFor(() => {
expect(document.body.textContent).toContain('First');
expect(document.body.textContent).toContain('Second');
});
});
it('handleMarkAllReviewed calls onMarkAllReviewed when clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock({ reviewed: false })],
onMarkAllReviewed
})
});
const btn = (await page
.getByRole('button', { name: /alle als fertig/i })
.element()) as HTMLButtonElement;
btn.click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledOnce());
});
it('renders all blocks with their text', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [
baseBlock({ id: 'b1', text: 'Erster Block' }),
baseBlock({ id: 'b2', text: 'Zweiter Block' })
]
})
});
expect(document.body.textContent).toContain('Erster Block');
expect(document.body.textContent).toContain('Zweiter Block');
});
it('shows the next-block CTA when there are blocks', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()]
})
});
// CTA shows the number of the next block ("Nächster Block 2")
expect(document.body.textContent).toMatch(/2/);
});
it('shows the active training label highlighted when included in trainingLabels', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
trainingLabels: ['KURRENT_RECOGNITION'],
onToggleTrainingLabel: async () => {}
})
});
// The chip for KURRENT_RECOGNITION should have the active class
const chips = document.querySelectorAll('button');
const activeChip = Array.from(chips).find(
(c) => c.className.includes('border-brand-mint') && c.className.includes('bg-brand-mint')
);
expect(activeChip).toBeDefined();
});
it('renders the inactive training-label chip class when not in trainingLabels', async () => {
render(TranscriptionEditView, {
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
trainingLabels: [],
onToggleTrainingLabel: async () => {}
})
});
// Inactive chip has border-line class, not bg-brand-mint
const chips = Array.from(document.querySelectorAll('button')).filter((b) =>
/kurrent|segmentier/i.test(b.textContent ?? '')
);
expect(chips.length).toBeGreaterThan(0);
expect(chips[0].className).not.toContain('bg-brand-mint');
});
});

View File

@@ -5,116 +5,178 @@ import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
afterEach(cleanup);
const baseProps = {
mode: 'read' as const,
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
};
describe('TranscriptionPanelHeader', () => {
it('renders the Lesen and Bearbeiten toggle buttons', async () => {
render(TranscriptionPanelHeader, baseProps);
await expect.element(page.getByRole('button', { name: /lesen/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /bearbeiten/i })).toBeVisible();
});
it('marks the Lesen button as aria-disabled when hasBlocks is false', async () => {
it('should render Lesen and Bearbeiten buttons', async () => {
render(TranscriptionPanelHeader, {
...baseProps,
mode: 'edit',
hasBlocks: false,
blockCount: 0
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
await expect
.element(page.getByRole('button', { name: /lesen/i }))
.toHaveAttribute('aria-disabled', 'true');
await expect.element(page.getByText('Lesen')).toBeInTheDocument();
await expect.element(page.getByText('Bearbeiten')).toBeInTheDocument();
});
it('calls onModeChange("edit") when the Bearbeiten button is clicked', async () => {
it('should disable Lesen button when hasBlocks is false', async () => {
render(TranscriptionPanelHeader, {
mode: 'edit',
hasBlocks: false,
blockCount: 0,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
const lesenBtn = document.querySelector('[data-testid="mode-read"]') as HTMLButtonElement;
expect(lesenBtn.getAttribute('aria-disabled')).toBe('true');
});
it('should call onModeChange when clicking Bearbeiten', async () => {
const onModeChange = vi.fn();
render(TranscriptionPanelHeader, { ...baseProps, onModeChange });
await page.getByRole('button', { name: /bearbeiten/i }).click();
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange,
onClose: () => {}
});
const editBtn = document.querySelector('[data-testid="mode-edit"]')!;
editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onModeChange).toHaveBeenCalledWith('edit');
});
it('does not call onModeChange when the disabled Lesen button is clicked', async () => {
it('should not call onModeChange when clicking disabled Lesen', async () => {
const onModeChange = vi.fn();
render(TranscriptionPanelHeader, {
...baseProps,
mode: 'edit',
hasBlocks: false,
blockCount: 0,
onModeChange
lastEditedAt: null,
onModeChange,
onClose: () => {}
});
await page.getByRole('button', { name: /lesen/i }).click({ force: true });
const readBtn = document.querySelector('[data-testid="mode-read"]')!;
readBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onModeChange).not.toHaveBeenCalled();
});
it('calls onClose when the close button is clicked', async () => {
it('should call onClose when clicking close button', async () => {
const onClose = vi.fn();
render(TranscriptionPanelHeader, { ...baseProps, onClose });
await page.getByRole('button', { name: /panel schließen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
});
it('shows the singular section label when blockCount is 1', async () => {
render(TranscriptionPanelHeader, { ...baseProps, blockCount: 1 });
await expect.element(page.getByText('1 Abschnitt')).toBeVisible();
});
it('shows the plural section label when blockCount is greater than 1', async () => {
render(TranscriptionPanelHeader, { ...baseProps, blockCount: 5 });
await expect.element(page.getByText('5 Abschnitte')).toBeVisible();
});
it('shows "0 Abschnitte" when blockCount is 0', async () => {
render(TranscriptionPanelHeader, {
...baseProps,
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose
});
const closeBtn = document.querySelector('[data-testid="panel-close"]')!;
closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).toHaveBeenCalled();
});
it('should show singular block count for 1 block', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 1,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
await expect.element(page.getByText('1 Abschnitt')).toBeInTheDocument();
});
it('should show plural block count for multiple blocks', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 5,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
await expect.element(page.getByText('5 Abschnitte')).toBeInTheDocument();
});
it('should show "0 Abschnitte" when blockCount is 0', async () => {
render(TranscriptionPanelHeader, {
mode: 'edit',
hasBlocks: false,
blockCount: 0,
mode: 'edit'
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
await expect.element(page.getByText('0 Abschnitte')).toBeVisible();
await expect.element(page.getByText('0 Abschnitte')).toBeInTheDocument();
});
it('renders the formatted last-edit date when lastEditedAt is provided', async () => {
it('should have close button with 44px touch target classes', async () => {
render(TranscriptionPanelHeader, {
...baseProps,
lastEditedAt: '2026-04-07T10:00:00Z'
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
await expect.element(page.getByText(/2026/)).toBeVisible();
const closeBtn = document.querySelector('[data-testid="panel-close"]') as HTMLElement;
expect(closeBtn.classList.contains('h-11')).toBe(true);
expect(closeBtn.classList.contains('w-11')).toBe(true);
});
it('renders the help popover trigger', async () => {
render(TranscriptionPanelHeader, baseProps);
it('should show formatted date when lastEditedAt is provided', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: '2026-04-07T10:00:00Z',
onModeChange: () => {},
onClose: () => {}
});
await expect
.element(page.getByRole('button', { name: /lese- und bearbeitungsmodus/i }))
.toBeVisible();
const statusText = document.querySelector('.hidden.md\\:block');
expect(statusText).not.toBeNull();
expect(statusText!.textContent).toContain('2026');
});
it('opens the help popover when the help trigger is clicked', async () => {
render(TranscriptionPanelHeader, baseProps);
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
await page.getByRole('button', { name: /lese- und bearbeitungsmodus/i }).click();
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
expect(helpBtn).not.toBeNull();
});
await expect
.element(page.getByRole('button', { name: /lese- und bearbeitungsmodus/i }))
.toHaveAttribute('aria-expanded', 'true');
it('opens a help popover with mode explanation when the chip is clicked', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});

View File

@@ -1,36 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionSection from './TranscriptionSection.svelte';
afterEach(cleanup);
describe('TranscriptionSection', () => {
it('renders the section heading and textarea', async () => {
render(TranscriptionSection, { props: {} });
await expect.element(page.getByRole('heading', { name: /transkription/i })).toBeVisible();
const textarea = document.querySelector(
'textarea[name="transcription"]'
) as HTMLTextAreaElement;
expect(textarea).not.toBeNull();
});
it('hydrates the textarea with the initial transcription value', async () => {
render(TranscriptionSection, { props: { initialTranscription: 'Hello World' } });
const textarea = document.querySelector(
'textarea[name="transcription"]'
) as HTMLTextAreaElement;
expect(textarea.value).toBe('Hello World');
});
it('renders an empty textarea by default', async () => {
render(TranscriptionSection, { props: {} });
const textarea = document.querySelector(
'textarea[name="transcription"]'
) as HTMLTextAreaElement;
expect(textarea.value).toBe('');
});
});

View File

@@ -1,461 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createTranscriptionBlocks } from './useTranscriptionBlocks.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types';
afterEach(() => {
vi.restoreAllMocks();
});
const baseBlock = (overrides: Partial<TranscriptionBlockData> = {}): TranscriptionBlockData =>
({
id: 'b-1',
annotationId: 'ann-1',
text: 'Hello',
sortOrder: 1,
reviewed: false,
mentionedPersons: [],
updatedAt: '2026-01-01T00:00:00Z',
...overrides
}) as TranscriptionBlockData;
function makeFetch(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
for (const [match, fn] of Object.entries(handlers)) {
if (u.includes(match) && (match.includes(':') || true)) {
return fn();
}
}
const key = `${method} ${u}`;
for (const [match, fn] of Object.entries(handlers)) {
if (key.includes(match)) return fn();
}
return new Response('not found', { status: 404 });
});
}
describe('createTranscriptionBlocks — initial state', () => {
it('starts with no blocks, no derived metadata', () => {
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1' });
expect(ctrl.blocks).toEqual([]);
expect(ctrl.hasBlocks).toBe(false);
expect(ctrl.blockNumbers).toEqual({});
expect(ctrl.lastEditedAt).toBeNull();
expect(ctrl.annotationReloadKey).toBe(0);
});
});
describe('createTranscriptionBlocks.load', () => {
it('fetches and stores blocks on success', async () => {
const fetchImpl = makeFetch({
'/api/documents/doc-1/transcription-blocks': () =>
new Response(
JSON.stringify([baseBlock({ id: 'b1' }), baseBlock({ id: 'b2', sortOrder: 2 })]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.blocks).toHaveLength(2);
expect(ctrl.hasBlocks).toBe(true);
});
it('is a no-op when documentId is empty', async () => {
const fetchImpl = vi.fn();
const ctrl = createTranscriptionBlocks({ documentId: () => '', fetchImpl });
await ctrl.load();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('keeps blocks empty on non-OK response', async () => {
const fetchImpl = makeFetch({
'transcription-blocks': () => new Response('boom', { status: 500 })
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.blocks).toEqual([]);
});
it('swallows network errors during load', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network');
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await expect(ctrl.load()).resolves.toBeUndefined();
expect(ctrl.blocks).toEqual([]);
});
});
describe('createTranscriptionBlocks — derived state', () => {
it('computes blockNumbers in sortOrder', async () => {
const fetchImpl = makeFetch({
'transcription-blocks': () =>
new Response(
JSON.stringify([
baseBlock({ id: 'b3', annotationId: 'a3', sortOrder: 3 }),
baseBlock({ id: 'b1', annotationId: 'a1', sortOrder: 1 }),
baseBlock({ id: 'b2', annotationId: 'a2', sortOrder: 2 })
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.blockNumbers).toEqual({ a1: 1, a2: 2, a3: 3 });
});
it('lastEditedAt picks the most recent updatedAt', async () => {
const fetchImpl = makeFetch({
'transcription-blocks': () =>
new Response(
JSON.stringify([
baseBlock({ id: 'b1', updatedAt: '2026-04-15T10:00:00Z' }),
baseBlock({ id: 'b2', updatedAt: '2026-04-20T10:00:00Z' }),
baseBlock({ id: 'b3', updatedAt: '2026-04-10T10:00:00Z' })
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.lastEditedAt).toBe(new Date('2026-04-20T10:00:00Z').toISOString());
});
it('lastEditedAt is null when no block has updatedAt', async () => {
const fetchImpl = makeFetch({
'transcription-blocks': () =>
new Response(JSON.stringify([baseBlock({ id: 'b1', updatedAt: undefined })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.lastEditedAt).toBeNull();
});
});
describe('createTranscriptionBlocks.delete', () => {
it('removes the block locally and bumps annotationReloadKey on success', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/transcription-blocks/b-1') && method === 'DELETE') {
return new Response(null, { status: 204 });
}
if (u.endsWith('/transcription-blocks')) {
return new Response(JSON.stringify([baseBlock({ id: 'b-1' }), baseBlock({ id: 'b-2' })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('', { status: 404 });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.blocks).toHaveLength(2);
const keyBefore = ctrl.annotationReloadKey;
await ctrl.delete('b-1');
expect(ctrl.blocks).toHaveLength(1);
expect(ctrl.blocks[0].id).toBe('b-2');
expect(ctrl.annotationReloadKey).toBe(keyBefore + 1);
});
it('throws on non-OK delete response', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const method = init?.method ?? 'GET';
if (method === 'DELETE') return new Response('boom', { status: 500 });
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await expect(ctrl.delete('b-1')).rejects.toThrow();
});
});
describe('createTranscriptionBlocks.reviewToggle', () => {
it('updates the block after a successful PUT', async () => {
let updated = false;
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review') && method === 'PUT') {
updated = true;
return new Response(JSON.stringify(baseBlock({ id: 'b-1', reviewed: true })), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await ctrl.reviewToggle('b-1');
expect(updated).toBe(true);
expect(ctrl.blocks[0].reviewed).toBe(true);
});
it('is a no-op when PUT returns non-OK', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const method = init?.method ?? 'GET';
if (method === 'PUT') return new Response('', { status: 500 });
return new Response(JSON.stringify([baseBlock({ reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await ctrl.reviewToggle('b-1');
expect(ctrl.blocks[0].reviewed).toBe(false);
});
});
describe('createTranscriptionBlocks.markAllReviewed', () => {
it('updates each matching block', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response(
JSON.stringify([
{ id: 'b-1', reviewed: true },
{ id: 'b-2', reviewed: true }
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify([
baseBlock({ id: 'b-1', reviewed: false }),
baseBlock({ id: 'b-2', reviewed: false })
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await ctrl.markAllReviewed();
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
});
it('is a no-op when PUT returns non-OK', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response('', { status: 500 });
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await ctrl.markAllReviewed();
expect(ctrl.blocks[0].reviewed).toBe(false);
});
});
describe('createTranscriptionBlocks.createFromDraw', () => {
it('appends a created block on 200', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.endsWith('/transcription-blocks') && method === 'POST') {
return new Response(JSON.stringify(baseBlock({ id: 'b-new', annotationId: 'ann-new' })), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
const created = await ctrl.createFromDraw({
x: 0.1,
y: 0.1,
width: 0.1,
height: 0.1,
pageNumber: 1
});
expect(created?.id).toBe('b-new');
expect(ctrl.blocks.find((b) => b.id === 'b-new')).toBeDefined();
});
it('returns null and does not append on non-OK response', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const method = init?.method ?? 'GET';
if (method === 'POST') return new Response('boom', { status: 500 });
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
const created = await ctrl.createFromDraw({
x: 0,
y: 0,
width: 0.1,
height: 0.1,
pageNumber: 1
});
expect(created).toBeNull();
expect(ctrl.blocks).toHaveLength(0);
});
it('returns null on network error', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network');
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
const created = await ctrl.createFromDraw({
x: 0,
y: 0,
width: 0.1,
height: 0.1,
pageNumber: 1
});
expect(created).toBeNull();
});
});
describe('createTranscriptionBlocks.toggleTrainingLabel', () => {
it('PATCHes the training-labels endpoint', async () => {
const fetchImpl = vi.fn(async () => new Response('', { status: 200 }));
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.toggleTrainingLabel('KURRENT_RECOGNITION', true);
expect(fetchImpl).toHaveBeenCalledWith(
'/api/documents/doc-1/training-labels',
expect.objectContaining({ method: 'PATCH' })
);
});
it('throws on non-OK response', async () => {
const fetchImpl = vi.fn(async () => new Response('boom', { status: 500 }));
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await expect(ctrl.toggleTrainingLabel('X', true)).rejects.toThrow();
});
});
describe('createTranscriptionBlocks.deleteAnnotation', () => {
it('deletes the linked block when one exists', async () => {
let blockDeleted = false;
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/transcription-blocks/b-1') && method === 'DELETE') {
blockDeleted = true;
return new Response(null, { status: 204 });
}
if (u.endsWith('/transcription-blocks')) {
return new Response(JSON.stringify([baseBlock({ id: 'b-1', annotationId: 'ann-1' })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('', { status: 200 });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await ctrl.deleteAnnotation('ann-1');
expect(blockDeleted).toBe(true);
expect(ctrl.blocks).toHaveLength(0);
});
it('deletes the bare annotation when no block is linked', async () => {
let annotationDeleted = false;
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/annotations/ann-orphan') && method === 'DELETE') {
annotationDeleted = true;
return new Response(null, { status: 204 });
}
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
const keyBefore = ctrl.annotationReloadKey;
await ctrl.deleteAnnotation('ann-orphan');
expect(annotationDeleted).toBe(true);
expect(ctrl.annotationReloadKey).toBe(keyBefore + 1);
});
it('throws when the bare-annotation DELETE fails', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/annotations/') && method === 'DELETE') {
return new Response('boom', { status: 500 });
}
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await expect(ctrl.deleteAnnotation('ann-orphan')).rejects.toThrow();
});
});
describe('createTranscriptionBlocks.findByAnnotationId', () => {
it('returns the block whose annotationId matches', async () => {
const fetchImpl = makeFetch({
'transcription-blocks': () =>
new Response(
JSON.stringify([
baseBlock({ id: 'b1', annotationId: 'ann-a' }),
baseBlock({ id: 'b2', annotationId: 'ann-b' })
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
expect(ctrl.findByAnnotationId('ann-b')?.id).toBe('b2');
expect(ctrl.findByAnnotationId('ann-missing')).toBeUndefined();
});
});
describe('createTranscriptionBlocks.bumpAnnotationReloadKey', () => {
it('increments annotationReloadKey by 1', () => {
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1' });
expect(ctrl.annotationReloadKey).toBe(0);
ctrl.bumpAnnotationReloadKey();
expect(ctrl.annotationReloadKey).toBe(1);
ctrl.bumpAnnotationReloadKey();
expect(ctrl.annotationReloadKey).toBe(2);
});
});

View File

@@ -1,214 +0,0 @@
/* eslint-disable svelte/prefer-svelte-reactivity -- the Date instances inside
lastEditedAt's $derived are scope-local to one computation; they're never
stored on $state. */
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge';
type DrawRect = {
x: number;
y: number;
width: number;
height: number;
pageNumber: number;
};
export interface TranscriptionBlocksOptions {
documentId: () => string;
fetchImpl?: typeof fetch;
}
export interface TranscriptionBlocksController {
readonly blocks: TranscriptionBlockData[];
readonly hasBlocks: boolean;
readonly blockNumbers: Record<string, number>;
readonly lastEditedAt: string | null;
readonly annotationReloadKey: number;
load(): Promise<void>;
save(blockId: string, text: string, mentionedPersons: PersonMention[]): Promise<void>;
delete(blockId: string): Promise<void>;
reviewToggle(blockId: string): Promise<void>;
markAllReviewed(): Promise<void>;
createFromDraw(rect: DrawRect): Promise<TranscriptionBlockData | null>;
toggleTrainingLabel(label: string, enrolled: boolean): Promise<void>;
deleteAnnotation(annotationId: string): Promise<void>;
findByAnnotationId(annotationId: string): TranscriptionBlockData | undefined;
bumpAnnotationReloadKey(): void;
}
export function createTranscriptionBlocks(
options: TranscriptionBlocksOptions
): TranscriptionBlocksController {
const { documentId } = options;
const fetchImpl = options.fetchImpl ?? fetch;
let blocks = $state<TranscriptionBlockData[]>([]);
let annotationReloadKey = $state(0);
const blockNumbers = $derived(
Object.fromEntries(
[...blocks].sort((a, b) => a.sortOrder - b.sortOrder).map((b, i) => [b.annotationId, i + 1])
)
);
const hasBlocks = $derived(blocks.length > 0);
const lastEditedAt = $derived.by(() => {
if (blocks.length === 0) return null;
const dates = blocks.filter((b) => b.updatedAt).map((b) => new Date(b.updatedAt!).getTime());
if (dates.length === 0) return null;
return new Date(Math.max(...dates)).toISOString();
});
async function load(): Promise<void> {
const id = documentId();
if (!id) return;
try {
const res = await fetchImpl(`/api/documents/${id}/transcription-blocks`);
if (res.ok) {
blocks = (await res.json()) as TranscriptionBlockData[];
}
} catch (e) {
console.error('Failed to load transcription blocks:', e);
}
}
async function save(
blockId: string,
text: string,
mentionedPersons: PersonMention[]
): Promise<void> {
try {
const updated = await saveBlockWithConflictRetry({
fetchImpl,
documentId: documentId(),
blockId,
text,
mentionedPersons
});
blocks = blocks.map((b) => (b.id === blockId ? updated : b));
} catch (err) {
if (err instanceof BlockConflictResolvedError && err.merged) {
blocks = blocks.map((b) => (b.id === blockId ? err.merged! : b));
}
throw err;
}
}
async function deleteBlock(blockId: string): Promise<void> {
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/${blockId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
blocks = blocks.filter((b) => b.id !== blockId);
annotationReloadKey++;
}
async function reviewToggle(blockId: string): Promise<void> {
const res = await fetchImpl(
`/api/documents/${documentId()}/transcription-blocks/${blockId}/review`,
{ method: 'PUT' }
);
if (!res.ok) return;
const updated = (await res.json()) as TranscriptionBlockData;
blocks = blocks.map((b) => (b.id === blockId ? updated : b));
}
async function markAllReviewed(): Promise<void> {
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
method: 'PUT'
});
if (!res.ok) return;
const updated = (await res.json()) as { id: string; reviewed: boolean }[];
for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id);
if (existing) existing.reviewed = b.reviewed;
}
}
async function createFromDraw(rect: DrawRect): Promise<TranscriptionBlockData | null> {
try {
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: rect.pageNumber,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
text: '',
label: null
})
});
if (res.ok) {
const created = (await res.json()) as TranscriptionBlockData;
blocks = [...blocks, created];
return created;
}
return null;
} catch (e) {
console.error('Failed to create transcription block:', e);
return null;
}
}
async function toggleTrainingLabel(label: string, enrolled: boolean): Promise<void> {
const res = await fetchImpl(`/api/documents/${documentId()}/training-labels`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, enrolled })
});
if (!res.ok) throw new Error('Failed to update training label');
}
async function deleteAnnotation(annotationId: string): Promise<void> {
const block = blocks.find((b) => b.annotationId === annotationId);
if (block) {
await deleteBlock(block.id);
return;
}
const res = await fetchImpl(`/api/documents/${documentId()}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete annotation failed');
annotationReloadKey++;
}
function findByAnnotationId(annotationId: string): TranscriptionBlockData | undefined {
return blocks.find((b) => b.annotationId === annotationId);
}
function bumpAnnotationReloadKey(): void {
annotationReloadKey++;
}
return {
get blocks() {
return blocks;
},
get hasBlocks() {
return hasBlocks;
},
get blockNumbers() {
return blockNumbers;
},
get lastEditedAt() {
return lastEditedAt;
},
get annotationReloadKey() {
return annotationReloadKey;
},
load,
save,
delete: deleteBlock,
reviewToggle,
markAllReviewed,
createFromDraw,
toggleTrainingLabel,
deleteAnnotation,
findByAnnotationId,
bumpAnnotationReloadKey
};
}

View File

@@ -1,280 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('pdfjs-dist', () => {
function TextLayerMock() {}
TextLayerMock.prototype.render = () => Promise.resolve();
TextLayerMock.prototype.cancel = () => {};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
})
})
}),
TextLayer: TextLayerMock
};
});
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
const { default: PdfViewer } = await import('./PdfViewer.svelte');
afterEach(cleanup);
describe('PdfViewer — empty / error states', () => {
it('renders the no-file placeholder when url is empty', async () => {
render(PdfViewer, { url: '' });
await expect.element(page.getByText('Keine Datei vorhanden')).toBeVisible();
});
it('does not render the controls when url is empty', async () => {
render(PdfViewer, { url: '' });
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBe(0);
});
});
describe('PdfViewer — loaded state', () => {
it('renders the PDF navigation controls (Zurück/Weiter/Vergrößern/Verkleinern) when a url is provided', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
annotationReloadKey: 0
});
// PdfControls renders its nav + zoom buttons once the document.promise resolves.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Weiter' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Vergrößern' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Verkleinern' })).toBeVisible();
});
it('renders the canvas background container when annotationsDimmed=true', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
annotationsDimmed: true
});
await vi.waitFor(() => {
expect(document.querySelector('.bg-pdf-bg')).not.toBeNull();
});
});
it('forces the annotation toggle into "hide" mode when transcribeMode is true and annotations exist', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify([
{
id: 'a1',
documentId: 'test',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.1,
height: 0.1,
color: '#000',
createdAt: '2026-01-01T00:00:00Z',
fileHash: 'match'
}
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
documentFileHash: 'match'
});
// transcribeMode forces showAnnotations=true; toggle button surfaces with "hide" label
// (only when annotationCount > 0).
await expect
.element(page.getByRole('button', { name: /annotierungen verbergen/i }))
.toBeVisible();
} finally {
fetchSpy.mockRestore();
}
});
it('renders the canvas region when documentFileHash is provided', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
documentFileHash: 'abc123'
});
await vi.waitFor(() => {
expect(document.querySelector('.bg-pdf-bg')).not.toBeNull();
});
});
it('renders the PDF controls when flashAnnotationId is set', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
flashAnnotationId: 'ann-flashing'
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
});
it('renders the PDF controls when blockNumbers map is provided', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
blockNumbers: { 'ann-1': 1, 'ann-2': 2 }
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
});
it('renders the PDF controls when activeAnnotationId is set', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
activeAnnotationId: 'ann-1'
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
});
it('renders the PDF nav controls in transcribeMode + activeAnnotationId combo', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
activeAnnotationId: 'ann-1'
});
// Without an annotations fetch, the visibility toggle is hidden — just assert the always-on nav.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Weiter' })).toBeVisible();
});
it('renders the PDF controls when an onAnnotationClick callback is wired up', async () => {
const onAnnotationClick = vi.fn();
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
onAnnotationClick
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
});
it('shows the outdated-annotation notice when annotations have non-matching fileHash', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify([
{
id: 'a1',
documentId: 'test',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.1,
height: 0.1,
color: '#000',
createdAt: '2026-01-01T00:00:00Z',
fileHash: 'old-hash'
}
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
documentFileHash: 'new-hash'
});
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).not.toBeNull();
});
} finally {
fetchSpy.mockRestore();
}
});
it('does not show outdated-annotation notice when all annotations match', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify([
{
id: 'a1',
documentId: 'test',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.1,
height: 0.1,
color: '#000',
createdAt: '2026-01-01T00:00:00Z',
fileHash: 'matching-hash'
}
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
documentFileHash: 'matching-hash'
});
// Controls finish mounting, and the outdated notice stays absent.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull();
} finally {
fetchSpy.mockRestore();
}
});
it('still renders the controls when the annotations fetch rejects', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'));
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test'
});
// PDF rendering does not depend on the annotations fetch — controls still appear.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull();
} finally {
fetchSpy.mockRestore();
}
});
it('still renders the controls when the annotations fetch returns a non-OK status', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('error', { status: 500 }));
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test'
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull();
} finally {
fetchSpy.mockRestore();
}
});
});

View File

@@ -66,111 +66,4 @@ describe('createPdfRenderer', () => {
expect(r.error).toBeNull();
expect(r.loading).toBe(false);
});
it('renderCurrentPage is a no-op when pdfjsLib is not initialized', async () => {
const r = createPdfRenderer();
// Should not throw — early-return branch
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('prerender is a no-op when pdfDoc is null', async () => {
const r = createPdfRenderer();
await expect(r.prerender()).resolves.toBeUndefined();
});
it('destroy is safe to call when no document is loaded', () => {
const r = createPdfRenderer();
expect(() => r.destroy()).not.toThrow();
});
it('setElements stores canvas and text layer refs', () => {
const r = createPdfRenderer();
const canvas = document.createElement('canvas');
const textLayer = document.createElement('div');
expect(() => r.setElements(canvas, textLayer)).not.toThrow();
});
it('isLoaded reflects totalPages > 0', () => {
const r = createPdfRenderer();
// Initial state — totalPages=0 → not loaded
expect(r.isLoaded).toBe(false);
});
it('multiple zoomIn calls accumulate', () => {
const r = createPdfRenderer();
r.zoomIn();
r.zoomIn();
r.zoomIn();
expect(r.scale).toBeCloseTo(2.25);
});
it('mixed zoom in then zoom out lands back at start', () => {
const r = createPdfRenderer();
r.zoomIn();
r.zoomIn();
r.zoomOut();
r.zoomOut();
expect(r.scale).toBeCloseTo(1.5);
});
it('zoomOut at the floor does nothing', () => {
const r = createPdfRenderer();
// Force scale down to 0.5
for (let i = 0; i < 20; i++) r.zoomOut();
const before = r.scale;
r.zoomOut();
expect(r.scale).toBe(before);
});
it('init() is callable and resolves without throwing in browser env', async () => {
const r = createPdfRenderer();
await expect(r.init()).resolves.toBeUndefined();
// pdfjsReady is now true
expect(r.pdfjsReady).toBe(true);
});
it('after init, loadDocument with a bogus URL sets error', async () => {
const r = createPdfRenderer();
await r.init();
await r.loadDocument('about:invalid-pdf');
// Either error is set or loading flips back to false — both are acceptable
expect(r.loading).toBe(false);
});
it('renderCurrentPage is a no-op when canvasEl is null but pdfjsLib is initialized', async () => {
const r = createPdfRenderer();
await r.init();
// Without setElements, canvasEl is null — early return
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('renderCurrentPage is a no-op when textLayerEl is null', async () => {
const r = createPdfRenderer();
await r.init();
// Set only canvas, leave textLayer unset is not directly testable;
// confirm calling without elements wired returns early.
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('init() can be called multiple times safely', async () => {
const r = createPdfRenderer();
await r.init();
await r.init();
expect(r.pdfjsReady).toBe(true);
});
it('zoomIn after multiple zoomOuts lands at predictable scale', () => {
const r = createPdfRenderer();
// 1.5 -> 0.5 (floor) -> 0.75
for (let i = 0; i < 10; i++) r.zoomOut();
r.zoomIn();
expect(r.scale).toBeCloseTo(0.75);
});
it('goToPage(1) works when totalPages would be at least 1 (no-op currently)', () => {
const r = createPdfRenderer();
r.goToPage(1);
expect(r.currentPage).toBe(1);
});
});

View File

@@ -1,109 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
afterEach(cleanup);
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
publishedAt: '2026-04-15T10:00:00Z',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
...overrides
});
const baseProps = (overrides: Record<string, unknown> = {}) => ({
geschichten: [] as ReturnType<typeof makeGeschichte>[],
personId: 'p-1',
personName: 'Anna Schmidt',
canWrite: false,
...overrides
});
describe('GeschichtenCard', () => {
it('renders nothing when geschichten is empty', async () => {
render(GeschichtenCard, { props: baseProps() });
expect(document.querySelector('section')).toBeNull();
});
it('renders the section when at least one geschichte is present', async () => {
render(GeschichtenCard, { props: baseProps({ geschichten: [makeGeschichte()] }) });
await expect.element(page.getByRole('heading', { name: /geschichten/i })).toBeVisible();
});
it('shows the write-action link when canWrite is true', async () => {
render(GeschichtenCard, {
props: baseProps({ geschichten: [makeGeschichte()], canWrite: true })
});
await expect
.element(page.getByRole('link', { name: /geschichte schreiben/i }))
.toHaveAttribute('href', '/geschichten/new?personId=p-1');
});
it('hides the write-action link when canWrite is false', async () => {
render(GeschichtenCard, { props: baseProps({ geschichten: [makeGeschichte()] }) });
await expect
.element(page.getByRole('link', { name: /geschichte schreiben/i }))
.not.toBeInTheDocument();
});
it('limits visible geschichten to 3', async () => {
const geschichten = Array.from({ length: 5 }, (_, i) =>
makeGeschichte({ id: `g${i}`, title: `Geschichte ${i + 1}` })
);
render(GeschichtenCard, { props: baseProps({ geschichten }) });
await expect.element(page.getByText('Geschichte 1')).toBeVisible();
await expect.element(page.getByText('Geschichte 3')).toBeVisible();
await expect.element(page.getByText('Geschichte 4')).not.toBeInTheDocument();
});
it('renders the show-all link in the footer when there are 3 or more', async () => {
const geschichten = Array.from({ length: 3 }, (_, i) =>
makeGeschichte({ id: `g${i}`, title: `g${i}` })
);
render(GeschichtenCard, { props: baseProps({ geschichten }) });
await expect
.element(page.getByRole('link', { name: /alle geschichten zu anna schmidt/i }))
.toHaveAttribute('href', '/geschichten?personId=p-1');
});
it('hides the show-all footer when fewer than 3 geschichten', async () => {
render(GeschichtenCard, {
props: baseProps({
geschichten: [makeGeschichte({ id: 'g1' }), makeGeschichte({ id: 'g2', title: 'Two' })]
})
});
await expect
.element(page.getByRole('link', { name: /alle geschichten zu/i }))
.not.toBeInTheDocument();
});
it('renders the author full name when both first and last names are set', async () => {
render(GeschichtenCard, { props: baseProps({ geschichten: [makeGeschichte()] }) });
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
});
it('falls back to author email when no name', async () => {
render(GeschichtenCard, {
props: baseProps({
geschichten: [
makeGeschichte({
author: { firstName: undefined, lastName: undefined, email: 'fallback@x' }
})
]
})
});
await expect.element(page.getByText(/fallback@x/)).toBeVisible();
});
});

View File

@@ -1,216 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import NotificationDropdown from './NotificationDropdown.svelte';
afterEach(cleanup);
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
id: 'n1',
type: 'REPLY' as 'REPLY' | 'MENTION',
documentId: 'd1',
documentTitle: 'Brief',
referenceId: 'c1',
annotationId: null,
read: false,
createdAt: new Date().toISOString(),
actorName: 'Anna Schmidt',
...overrides
});
describe('NotificationDropdown', () => {
it('renders the dialog with the bell label', async () => {
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await expect.element(page.getByRole('dialog', { name: /benachrichtigungen/i })).toBeVisible();
});
it('renders the empty state when there are no notifications', async () => {
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await expect.element(page.getByText('Keine neuen Benachrichtigungen')).toBeVisible();
});
it('hides the mark-all-read action when the list is empty', async () => {
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await expect
.element(page.getByRole('button', { name: /alle gelesen/i }))
.not.toBeInTheDocument();
});
it('renders the mark-all-read action when notifications are present', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await expect.element(page.getByRole('button', { name: /alle gelesen/i })).toBeVisible();
});
it('renders one item per notification with the reply text for REPLY type', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await expect
.element(page.getByText(/Bert hat auf deinen Kommentar geantwortet/i))
.toBeVisible();
});
it('renders the mention text for MENTION type', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await expect
.element(page.getByText(/Clara hat dich in einem Kommentar erwähnt/i))
.toBeVisible();
});
it('renders the unread dot only for unread notifications', async () => {
render(NotificationDropdown, {
props: {
notifications: [
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true })
],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
const unreadDots = document.querySelectorAll('[aria-label="ungelesen"]');
expect(unreadDots.length).toBe(1);
});
it('calls onMarkRead with the notification when an item is clicked', async () => {
const onMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, {
props: {
notifications: [n],
onMarkRead,
onMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onMarkRead).toHaveBeenCalledWith(n);
});
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
onMarkRead: () => {},
onMarkAllRead,
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('calls onClose when the view-all link is clicked', async () => {
const onClose = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
await page.getByRole('link').click();
expect(onClose).toHaveBeenCalledOnce();
});
it('renders MENTION items with the mention verb text', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
expect(document.body.textContent).toMatch(/erwähnt|mention/i);
});
it('renders REPLY items with the reply glyph', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
// Reply uses the curved-arrow glyph
expect(document.body.textContent).toMatch(/↩|reply|geantwortet/i);
});
it('renders multiple notifications in order', async () => {
render(NotificationDropdown, {
props: {
notifications: [
makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' })
],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
const items = document.querySelectorAll('button[type="button"]');
// At least 2 items + mark-all button
expect(items.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -1,102 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrProgress from './OcrProgress.svelte';
// Mock EventSource so the $effect doesn't open a real SSE connection.
class MockEventSource {
url: string;
listeners = new Map<string, EventListener>();
onerror: (() => void) | null = null;
close = vi.fn();
constructor(url: string) {
this.url = url;
}
addEventListener(type: string, fn: EventListener) {
this.listeners.set(type, fn);
}
dispatch(type: string, data: unknown) {
const fn = this.listeners.get(type);
if (fn) fn({ data: JSON.stringify(data) } as MessageEvent);
}
}
let lastSource: MockEventSource | null = null;
beforeEach(() => {
const trackedFactory = function (url: string) {
const src = new MockEventSource(url);
lastSource = src;
return src;
};
(globalThis as unknown as { EventSource: unknown }).EventSource = new Proxy(MockEventSource, {
construct(_target, args) {
return trackedFactory(args[0]);
}
});
});
afterEach(() => {
cleanup();
lastSource = null;
});
async function waitForSource(): Promise<MockEventSource> {
await vi.waitFor(() => expect(lastSource).not.toBeNull());
return lastSource as MockEventSource;
}
describe('OcrProgress', () => {
it('renders the running progress block by default', async () => {
render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } });
await expect.element(page.getByRole('heading', { name: /ocr läuft/i })).toBeVisible();
});
it('renders the progress bar with the running label', async () => {
render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } });
expect(document.querySelector('[role="progressbar"]')).not.toBeNull();
});
it('updates the progress bar when document events arrive', async () => {
render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } });
const src = await waitForSource();
src.dispatch('document', { processed: 5, total: 10 });
await vi.waitFor(async () => {
const bar = (await page.getByRole('progressbar').element()) as HTMLElement;
expect(bar.getAttribute('aria-valuenow')).toBe('50');
});
});
it('switches to the done state and calls onDone when the done event arrives', async () => {
const onDone = vi.fn();
render(OcrProgress, { props: { jobId: 'job-1', onDone } });
const src = await waitForSource();
src.dispatch('done', {});
await vi.waitFor(() => expect(onDone).toHaveBeenCalledOnce());
await expect.element(page.getByRole('heading', { name: /ocr läuft/i })).not.toBeInTheDocument();
});
it('switches to the error state when the error event arrives', async () => {
render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } });
const src = await waitForSource();
src.dispatch('error', {});
await expect.element(page.getByRole('heading', { name: /ocr fehlgeschlagen/i })).toBeVisible();
});
it('renders the retry button in the error state', async () => {
render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } });
const src = await waitForSource();
src.dispatch('error', {});
await expect.element(page.getByRole('button', { name: /erneut versuchen/i })).toBeVisible();
});
});

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrTrigger from './OcrTrigger.svelte';
afterEach(cleanup);
describe('OcrTrigger', () => {
it('renders the script-type select and the trigger button', async () => {
render(OcrTrigger, {
props: { blockCount: 1, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} }
});
await expect.element(page.getByRole('combobox')).toBeVisible();
await expect.element(page.getByRole('button')).toBeVisible();
});
it('initialises the select with the stored script type when provided', async () => {
render(OcrTrigger, {
props: { blockCount: 1, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} }
});
const select = (await page.getByRole('combobox').element()) as HTMLSelectElement;
expect(select.value).toBe('HANDWRITING_KURRENT');
});
it('starts with an empty selection when storedScriptType is UNKNOWN', async () => {
render(OcrTrigger, {
props: { blockCount: 1, storedScriptType: 'UNKNOWN', onTrigger: () => {} }
});
const select = (await page.getByRole('combobox').element()) as HTMLSelectElement;
expect(select.value).toBe('');
});
it('disables the trigger button when no script type is selected', async () => {
render(OcrTrigger, {
props: { blockCount: 1, storedScriptType: 'UNKNOWN', onTrigger: () => {} }
});
const btn = (await page.getByRole('button').element()) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('disables the trigger button when blockCount is 0 even if a script type is selected', async () => {
render(OcrTrigger, {
props: { blockCount: 0, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} }
});
const btn = (await page.getByRole('button').element()) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('shows the no-annotations hint when blockCount is 0', async () => {
render(OcrTrigger, {
props: { blockCount: 0, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} }
});
await expect
.element(page.getByText('Zeichnen Sie zuerst Bereiche auf dem Dokument ein.'))
.toBeVisible();
});
it('omits the no-annotations hint when blockCount is greater than 0', async () => {
render(OcrTrigger, {
props: { blockCount: 5, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} }
});
await expect
.element(page.getByText('Zeichnen Sie zuerst Bereiche auf dem Dokument ein.'))
.not.toBeInTheDocument();
});
it('calls onTrigger with the selected script type and useExistingAnnotations=true', async () => {
const onTrigger = vi.fn();
render(OcrTrigger, {
props: { blockCount: 5, storedScriptType: 'HANDWRITING_KURRENT', onTrigger }
});
await page.getByRole('button').click();
expect(onTrigger).toHaveBeenCalledWith('HANDWRITING_KURRENT', true);
});
it('does not call onTrigger when no script type is selected', async () => {
const onTrigger = vi.fn();
render(OcrTrigger, {
props: { blockCount: 5, storedScriptType: 'UNKNOWN', onTrigger }
});
await page.getByRole('button').click({ force: true });
expect(onTrigger).not.toHaveBeenCalled();
});
});

View File

@@ -1,110 +0,0 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SegmentationTrainingCard from './SegmentationTrainingCard.svelte';
afterEach(cleanup);
const baseInfo = (overrides: Record<string, unknown> = {}) => ({
availableSegBlocks: 10,
ocrServiceAvailable: true,
runs: [],
...overrides
});
describe('SegmentationTrainingCard', () => {
it('renders the heading and description', async () => {
render(SegmentationTrainingCard, { props: { trainingInfo: baseInfo() } });
await expect
.element(page.getByRole('heading', { name: /segmentierung trainieren/i }))
.toBeVisible();
await expect.element(page.getByText(/Starte ein neues Training/i)).toBeVisible();
});
it('shows the count of available segmentation blocks', async () => {
render(SegmentationTrainingCard, {
props: { trainingInfo: baseInfo({ availableSegBlocks: 42 }) }
});
await expect.element(page.getByText('42 Segmentierungsblöcke bereit')).toBeVisible();
});
it('shows zero blocks when trainingInfo is null', async () => {
render(SegmentationTrainingCard, { props: { trainingInfo: null } });
await expect.element(page.getByText('0 Segmentierungsblöcke bereit')).toBeVisible();
});
it('disables the start button when fewer than 5 blocks are available', async () => {
render(SegmentationTrainingCard, {
props: { trainingInfo: baseInfo({ availableSegBlocks: 3 }) }
});
const btn = (await page
.getByRole('button', { name: /training starten/i })
.element()) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('shows the too-few-blocks hint when fewer than 5 blocks are available', async () => {
render(SegmentationTrainingCard, {
props: { trainingInfo: baseInfo({ availableSegBlocks: 3 }) }
});
await expect
.element(page.getByText(/Mindestens 5 Segmentierungsblöcke erforderlich/i))
.toBeVisible();
});
it('disables the start button when the OCR service is reported down', async () => {
render(SegmentationTrainingCard, {
props: { trainingInfo: baseInfo({ ocrServiceAvailable: false }) }
});
const btn = (await page
.getByRole('button', { name: /training starten/i })
.element()) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('shows the service-down hint when ocrServiceAvailable is false', async () => {
render(SegmentationTrainingCard, {
props: { trainingInfo: baseInfo({ ocrServiceAvailable: false }) }
});
await expect.element(page.getByText('OCR-Dienst ist nicht erreichbar.')).toBeVisible();
});
it('enables the start button when blocks are sufficient and the service is up', async () => {
render(SegmentationTrainingCard, { props: { trainingInfo: baseInfo() } });
const btn = (await page
.getByRole('button', { name: /training starten/i })
.element()) as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it('shows the success message after a successful training POST', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }));
try {
render(SegmentationTrainingCard, { props: { trainingInfo: baseInfo() } });
await page.getByRole('button', { name: /training starten/i }).click();
await expect
.element(page.getByText('Training wurde gestartet und abgeschlossen.'))
.toBeVisible();
} finally {
fetchSpy.mockRestore();
}
});
it('renders the training history heading', async () => {
render(SegmentationTrainingCard, { props: { trainingInfo: baseInfo() } });
await expect.element(page.getByRole('heading', { name: /verlauf/i })).toBeVisible();
});
});

View File

@@ -1,101 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TrainingHistory from './TrainingHistory.svelte';
afterEach(cleanup);
const makeRun = (overrides: Record<string, unknown> = {}) => ({
id: 'r1',
createdAt: '2026-04-15T10:00:00Z',
status: 'DONE' as 'DONE' | 'FAILED' | 'QUEUED' | 'RUNNING',
blockCount: 100,
documentCount: 5,
personId: null as string | null,
cer: 0.05,
errorMessage: null as string | null,
...overrides
});
describe('TrainingHistory', () => {
it('renders the empty placeholder when runs is empty', async () => {
render(TrainingHistory, { props: { runs: [] } });
await expect.element(page.getByText('Noch keine Trainings-Läufe.')).toBeVisible();
});
it('renders the QUEUED status pill', async () => {
render(TrainingHistory, { props: { runs: [makeRun({ status: 'QUEUED' })] } });
await expect.element(page.getByText('Warteschlange')).toBeVisible();
});
it('renders the DONE status pill', async () => {
render(TrainingHistory, { props: { runs: [makeRun({ status: 'DONE' })] } });
await expect.element(page.getByText('Fertig')).toBeVisible();
});
it('renders the FAILED status pill', async () => {
render(TrainingHistory, { props: { runs: [makeRun({ status: 'FAILED' })] } });
// "Fehler" might match multiple things — check for the pill specifically
const pill = Array.from(document.querySelectorAll('span')).find(
(el) => el.textContent?.trim() === 'Fehler'
);
expect(pill).toBeDefined();
});
it('renders the RUNNING status pill for unknown statuses', async () => {
render(TrainingHistory, { props: { runs: [makeRun({ status: 'RUNNING' as const })] } });
await expect.element(page.getByText('Läuft…')).toBeVisible();
});
it('shows the error-detail disclosure when a FAILED run has errorMessage', async () => {
render(TrainingHistory, {
props: { runs: [makeRun({ status: 'FAILED', errorMessage: 'Network timeout' })] }
});
await expect.element(page.getByText('Network timeout')).toBeInTheDocument();
});
it('renders Personalisiert label when personId is set', async () => {
render(TrainingHistory, {
props: {
runs: [makeRun({ personId: 'p-1' })],
personNames: { 'p-1': 'Anna Schmidt' }
}
});
await expect.element(page.getByText('Personalisiert')).toBeVisible();
});
it('renders Basis label when personId is null', async () => {
render(TrainingHistory, { props: { runs: [makeRun()] } });
await expect.element(page.getByText('Basis')).toBeVisible();
});
it('limits visible runs to COLLAPSED_COUNT (3) by default', async () => {
const runs = Array.from({ length: 7 }, (_, i) => makeRun({ id: `r${i}` }));
render(TrainingHistory, { props: { runs } });
const rows = document.querySelectorAll('#training-history-rows > tr');
expect(rows.length).toBeLessThanOrEqual(4); // 3 visible + maybe expand row
});
it('hides person columns when showPersonColumns is false', async () => {
render(TrainingHistory, {
props: { runs: [makeRun({ personId: 'p1' })], showPersonColumns: false }
});
await expect.element(page.getByText('Personalisiert')).not.toBeInTheDocument();
});
it('renders em-dash CER for runs without cer', async () => {
render(TrainingHistory, { props: { runs: [makeRun({ cer: null })] } });
expect(document.body.textContent).toContain('—');
});
});

View File

@@ -1,453 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { createOcrJob } from './useOcrJob.svelte';
afterEach(() => {
vi.restoreAllMocks();
});
function makeFetch(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn(async (url: RequestInfo | URL) => {
const u = url.toString();
for (const [match, fn] of Object.entries(handlers)) {
if (u.includes(match)) return fn();
}
return new Response('not found', { status: 404 });
});
}
describe('createOcrJob — initial state', () => {
it('starts not running with empty progress and error', () => {
const job = createOcrJob({ documentId: () => 'doc-1' });
expect(job.running).toBe(false);
expect(job.progressMessage).toBe('');
expect(job.errorMessage).toBe('');
expect(job.skippedPages).toBe(0);
});
});
describe('createOcrJob.triggerOcr', () => {
it('sets running=true and starts polling on 200 with jobId', async () => {
const fetchImpl = makeFetch({
'/ocr': () =>
new Response(JSON.stringify({ jobId: 'job-7' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}),
'/ocr/jobs/job-7': () =>
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'WORKING' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.triggerOcr('KURRENT', false);
expect(job.running).toBe(true);
expect(job.errorMessage).toBe('');
expect(fetchImpl).toHaveBeenCalledWith(
'/api/documents/doc-1/ocr',
expect.objectContaining({ method: 'POST' })
);
job.destroy();
});
it('sets errorMessage with generic message on 500', async () => {
const fetchImpl = makeFetch({
'/ocr': () => new Response('boom', { status: 500 })
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.triggerOcr('KURRENT', false);
expect(job.running).toBe(false);
expect(job.errorMessage).toBeTruthy();
job.destroy();
});
it('extracts backend error code from 4xx body', async () => {
const fetchImpl = makeFetch({
'/ocr': () =>
new Response(JSON.stringify({ code: 'OCR_DISABLED' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.triggerOcr('KURRENT', false);
expect(job.running).toBe(false);
expect(job.errorMessage).toBeTruthy();
// errorMessage is localized — at minimum non-empty
job.destroy();
});
it('handles non-JSON 4xx body gracefully', async () => {
const fetchImpl = makeFetch({
'/ocr': () => new Response('not json', { status: 400 })
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.triggerOcr('KURRENT', false);
expect(job.running).toBe(false);
expect(job.errorMessage).toBeTruthy();
job.destroy();
});
it('handles fetch network error', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network down');
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.triggerOcr('KURRENT', false);
expect(job.running).toBe(false);
expect(job.errorMessage).toBeTruthy();
job.destroy();
});
it('passes useExistingAnnotations=true in the request body', async () => {
const fetchImpl = makeFetch({
'/ocr': () =>
new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}),
'/jobs/job-1': () =>
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.triggerOcr('LATIN', true);
const triggerCall = fetchImpl.mock.calls.find(
(c) => c[0].toString().includes('/ocr') && !c[0].toString().includes('jobs')
);
expect(triggerCall).toBeDefined();
const init = (triggerCall as unknown as [string, RequestInit])[1];
const body = JSON.parse(init.body as string);
expect(body).toEqual({ scriptType: 'LATIN', useExistingAnnotations: true });
job.destroy();
});
});
describe('createOcrJob.checkStatus', () => {
it('starts polling when status is RUNNING with a jobId', async () => {
const fetchImpl = makeFetch({
'ocr-status': () =>
new Response(JSON.stringify({ status: 'RUNNING', jobId: 'job-9' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}),
'/ocr/jobs/job-9': () =>
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.checkStatus();
expect(job.running).toBe(true);
job.destroy();
});
it('starts polling when status is PENDING with a jobId', async () => {
const fetchImpl = makeFetch({
'ocr-status': () =>
new Response(JSON.stringify({ status: 'PENDING', jobId: 'job-9' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.checkStatus();
expect(job.running).toBe(true);
job.destroy();
});
it('does not start polling when status is DONE', async () => {
const fetchImpl = makeFetch({
'ocr-status': () =>
new Response(JSON.stringify({ status: 'DONE', jobId: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.checkStatus();
expect(job.running).toBe(false);
job.destroy();
});
it('does not start polling when no jobId present', async () => {
const fetchImpl = makeFetch({
'ocr-status': () =>
new Response(JSON.stringify({ status: 'RUNNING', jobId: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.checkStatus();
expect(job.running).toBe(false);
job.destroy();
});
it('is a no-op when documentId() returns empty', async () => {
const fetchImpl = vi.fn();
const job = createOcrJob({ documentId: () => '', fetchImpl });
await job.checkStatus();
expect(fetchImpl).not.toHaveBeenCalled();
job.destroy();
});
it('handles 5xx ocr-status gracefully', async () => {
const fetchImpl = makeFetch({
'ocr-status': () => new Response('boom', { status: 500 })
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.checkStatus();
expect(job.running).toBe(false);
job.destroy();
});
it('handles network error gracefully', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network');
});
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
await job.checkStatus();
expect(job.running).toBe(false);
job.destroy();
});
});
describe('createOcrJob — polling loop (fake timers)', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// const wait used to live here; replaced by vi.advanceTimersByTimeAsync below.
it('updates progressMessage from translated job code', async () => {
const fetchImpl = makeFetch({
'/api/documents/doc-1/ocr': () =>
new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}),
'/api/ocr/jobs/job-1': () =>
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'PREPARING' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
pollIntervalMs: 20
});
await job.triggerOcr('KURRENT', false);
await vi.advanceTimersByTimeAsync(50);
expect(job.progressMessage).not.toBe('');
job.destroy();
});
it('captures skippedPages from job result', async () => {
const fetchImpl = makeFetch({
'/api/documents/doc-1/ocr': () =>
new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}),
'/api/ocr/jobs/job-1': () =>
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'SKIPPED:5' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
});
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
pollIntervalMs: 20
});
await job.triggerOcr('KURRENT', false);
await vi.advanceTimersByTimeAsync(50);
expect(job.skippedPages).toBeGreaterThanOrEqual(0);
job.destroy();
});
it('calls onJobFinished("DONE") when polling sees status=DONE', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
return new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ status: 'DONE', progressMessage: '' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const onJobFinished = vi.fn().mockResolvedValue(undefined);
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
onJobFinished,
pollIntervalMs: 20,
resetDelayMs: 10
});
await job.triggerOcr('KURRENT', false);
await vi.advanceTimersByTimeAsync(100);
expect(onJobFinished).toHaveBeenCalledWith('DONE');
job.destroy();
});
it('sets errorMessage and calls onJobFinished("FAILED") when polling sees status=FAILED', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
return new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ status: 'FAILED', progressMessage: '' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const onJobFinished = vi.fn().mockResolvedValue(undefined);
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
onJobFinished,
pollIntervalMs: 20,
resetDelayMs: 10
});
await job.triggerOcr('KURRENT', false);
await vi.advanceTimersByTimeAsync(100);
expect(onJobFinished).toHaveBeenCalledWith('FAILED');
expect(job.errorMessage).toBeTruthy();
job.destroy();
});
it('ignores non-OK polling responses', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
return new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('boom', { status: 500 });
});
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
pollIntervalMs: 20
});
await job.triggerOcr('KURRENT', false);
await vi.advanceTimersByTimeAsync(50);
expect(job.running).toBe(true);
job.destroy();
});
it('swallows polling fetch network errors', async () => {
let triggered = false;
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
triggered = true;
return new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (triggered) throw new Error('network');
return new Response('', { status: 200 });
});
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
pollIntervalMs: 20
});
await job.triggerOcr('KURRENT', false);
await vi.advanceTimersByTimeAsync(50);
expect(job.running).toBe(true);
job.destroy();
});
});
describe('createOcrJob.destroy', () => {
it('returns undefined and is safe to call without an active job', () => {
const job = createOcrJob({ documentId: () => 'doc-1' });
// destroy() is a void function — call it directly. If it threw, the test would fail.
expect(job.destroy()).toBeUndefined();
});
it('stops the polling interval when called mid-poll', async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
return new Response(JSON.stringify({ jobId: 'job-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const job = createOcrJob({
documentId: () => 'doc-1',
fetchImpl,
pollIntervalMs: 20
});
await job.triggerOcr('KURRENT', false);
job.destroy();
const callsAtDestroy = fetchImpl.mock.calls.length;
await vi.advanceTimersByTimeAsync(100);
// No additional fetch calls after destroy
expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy);
vi.useRealTimers();
});
});

View File

@@ -1,144 +0,0 @@
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
export interface OcrJobOptions {
documentId: () => string;
fetchImpl?: typeof fetch;
onJobFinished?: (status: 'DONE' | 'FAILED') => void | Promise<void>;
/** Polling interval in ms — defaults to 2000. Tests pass a small value. */
pollIntervalMs?: number;
/** Reset delay in ms after DONE/FAILED before clearing UI state. Defaults to 1000. */
resetDelayMs?: number;
}
export interface OcrJobController {
readonly running: boolean;
readonly progressMessage: string;
readonly errorMessage: string;
readonly skippedPages: number;
triggerOcr(scriptType: string, useExistingAnnotations: boolean): Promise<void>;
checkStatus(): Promise<void>;
destroy(): void;
}
const DEFAULT_POLL_INTERVAL_MS = 2000;
const DEFAULT_RESET_DELAY_MS = 1000;
export function createOcrJob(options: OcrJobOptions): OcrJobController {
const { documentId, onJobFinished } = options;
const fetchImpl = options.fetchImpl ?? fetch;
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const resetDelayMs = options.resetDelayMs ?? DEFAULT_RESET_DELAY_MS;
let running = $state(false);
let progressMessage = $state('');
let errorMessage = $state('');
let skippedPages = $state(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
function clearPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPolling(jobId: string): void {
clearPolling();
pollTimer = setInterval(() => {
void pollOnce(jobId);
}, pollIntervalMs);
}
async function pollOnce(jobId: string): Promise<void> {
try {
const res = await fetchImpl(`/api/ocr/jobs/${jobId}`);
if (!res.ok) return;
const job = (await res.json()) as { status: string; progressMessage?: string };
const progress = translateOcrProgress(job.progressMessage ?? '');
progressMessage = progress.message;
if (progress.skippedPages !== undefined) {
skippedPages = progress.skippedPages;
}
if (job.status === 'DONE' || job.status === 'FAILED') {
clearPolling();
const finalStatus = job.status as 'DONE' | 'FAILED';
setTimeout(() => {
running = false;
progressMessage = '';
skippedPages = 0;
}, resetDelayMs);
if (finalStatus === 'FAILED') {
errorMessage = m.ocr_status_error();
}
await onJobFinished?.(finalStatus);
}
} catch {
// polling is best-effort
}
}
async function triggerOcr(scriptType: string, useExistingAnnotations: boolean): Promise<void> {
running = true;
errorMessage = '';
try {
const res = await fetchImpl(`/api/documents/${documentId()}/ocr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scriptType, useExistingAnnotations })
});
if (res.ok) {
const data = (await res.json()) as { jobId: string };
startPolling(data.jobId);
} else {
running = false;
const body = await res.json().catch(() => null);
const code = (body as { code?: string } | null)?.code;
errorMessage = code ? getErrorMessage(code) : m.ocr_status_error();
}
} catch {
running = false;
errorMessage = m.ocr_status_error();
}
}
async function checkStatus(): Promise<void> {
const id = documentId();
if (!id) return;
try {
const res = await fetchImpl(`/api/documents/${id}/ocr-status`);
if (!res.ok) return;
const status = (await res.json()) as { status: string; jobId: string | null };
if ((status.status === 'PENDING' || status.status === 'RUNNING') && status.jobId) {
running = true;
startPolling(status.jobId);
}
} catch {
// best-effort
}
}
function destroy(): void {
clearPolling();
}
return {
get running() {
return running;
},
get progressMessage() {
return progressMessage;
},
get errorMessage() {
return errorMessage;
},
get skippedPages() {
return skippedPages;
},
triggerOcr,
checkStatus,
destroy
};
}

View File

@@ -1,62 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonChip from './PersonChip.svelte';
afterEach(cleanup);
const personWithFirstName = {
id: 'p-1',
firstName: 'Helene',
lastName: 'Schmidt',
displayName: 'Helene Schmidt'
};
const personLastNameOnly = {
id: 'p-2',
firstName: null,
lastName: 'Müller',
displayName: 'Müller'
};
describe('PersonChip', () => {
it('renders the full display name when abbreviated is false', async () => {
render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } });
await expect.element(page.getByText('Helene Schmidt')).toBeVisible();
});
it('renders the abbreviated name when abbreviated is true', async () => {
render(PersonChip, { props: { person: personWithFirstName, abbreviated: true } });
await expect.element(page.getByText('H. Schmidt')).toBeVisible();
});
it('falls back to lastName-only when the person has no firstName', async () => {
render(PersonChip, { props: { person: personLastNameOnly, abbreviated: true } });
await expect.element(page.getByText('Müller')).toBeVisible();
});
it('links to the person detail route by id', async () => {
render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } });
await expect
.element(page.getByRole('link', { name: /helene schmidt/i }))
.toHaveAttribute('href', '/persons/p-1');
});
it('renders initials inside the avatar circle', async () => {
render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } });
await expect.element(page.getByText('HS')).toBeVisible();
});
it('uses a deterministic avatar background color derived from the person id', async () => {
render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } });
const initials = await page.getByText('HS').element();
const style = (initials as HTMLElement).getAttribute('style') ?? '';
expect(style).toMatch(/background-color:\s*(rgb\(|#)/i);
});
});

View File

@@ -1,85 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonChipRow from './PersonChipRow.svelte';
afterEach(cleanup);
const sender = { id: 's-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' };
const r1 = { id: 'r-1', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' };
const r2 = { id: 'r-2', firstName: 'Clara', lastName: 'Weiss', displayName: 'Clara Weiss' };
const r3 = { id: 'r-3', firstName: 'Doris', lastName: 'Lang', displayName: 'Doris Lang' };
describe('PersonChipRow', () => {
it('renders only the sender when there are no receivers', async () => {
render(PersonChipRow, {
props: { sender, receivers: [], abbreviated: false, extraCount: 0 }
});
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByRole('img', { name: '' })).not.toBeInTheDocument();
});
it('renders the arrow image when sender and at least one receiver are present', async () => {
render(PersonChipRow, {
props: { sender, receivers: [r1], abbreviated: false, extraCount: 0 }
});
const arrow = document.querySelector('img[aria-hidden="true"]');
expect(arrow).not.toBeNull();
});
it('renders both sender and visible receivers with abbreviated=false', async () => {
render(PersonChipRow, {
props: { sender, receivers: [r1, r2], abbreviated: false, extraCount: 0 }
});
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByText('Bert Meier')).toBeVisible();
});
it('uses abbreviated names when abbreviated=true', async () => {
render(PersonChipRow, {
props: { sender, receivers: [r1], abbreviated: true, extraCount: 0 }
});
await expect.element(page.getByText('A. Schmidt')).toBeVisible();
await expect.element(page.getByText('B. Meier')).toBeVisible();
});
it('limits the visible receivers to the first two', async () => {
render(PersonChipRow, {
props: { sender, receivers: [r1, r2, r3], abbreviated: false, extraCount: 1 }
});
await expect.element(page.getByText('Bert Meier')).toBeVisible();
await expect.element(page.getByText('Clara Weiss')).toBeVisible();
await expect.element(page.getByText('Doris Lang')).not.toBeInTheDocument();
});
it('renders the OverflowPillDisplay when extraCount > 0', async () => {
render(PersonChipRow, {
props: { sender, receivers: [r1, r2, r3], abbreviated: false, extraCount: 1 }
});
await expect.element(page.getByText(/\+1/)).toBeVisible();
});
it('omits the OverflowPillDisplay when extraCount is 0', async () => {
render(PersonChipRow, {
props: { sender, receivers: [r1], abbreviated: false, extraCount: 0 }
});
await expect.element(page.getByText(/\+\d/)).not.toBeInTheDocument();
});
it('renders only receivers when there is no sender', async () => {
render(PersonChipRow, {
props: { sender: null, receivers: [r1], abbreviated: false, extraCount: 0 }
});
await expect.element(page.getByText('Bert Meier')).toBeVisible();
const arrow = document.querySelector('img[aria-hidden="true"]');
expect(arrow).toBeNull();
});
});

View File

@@ -1,42 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonTypeBadge from './PersonTypeBadge.svelte';
afterEach(cleanup);
describe('PersonTypeBadge', () => {
it('renders the institution label and badge-institution class for personType="INSTITUTION"', async () => {
render(PersonTypeBadge, { props: { personType: 'INSTITUTION' } });
await expect.element(page.getByText('Institution')).toBeVisible();
const badge = await page.getByText('Institution').element();
expect(badge.classList.contains('badge-institution')).toBe(true);
});
it('renders the group label and badge-group class for personType="GROUP"', async () => {
render(PersonTypeBadge, { props: { personType: 'GROUP' } });
const badge = await page.getByText('Gruppe').element();
expect(badge.classList.contains('badge-group')).toBe(true);
});
it('renders the unknown label and badge-unknown class for personType="UNKNOWN"', async () => {
render(PersonTypeBadge, { props: { personType: 'UNKNOWN' } });
const badge = await page.getByText('Unbekannt').element();
expect(badge.classList.contains('badge-unknown')).toBe(true);
});
it('renders nothing when personType does not match a known kind', async () => {
render(PersonTypeBadge, { props: { personType: 'INDIVIDUAL' } });
expect(document.querySelector('.badge')).toBeNull();
});
it('renders nothing for empty personType', async () => {
render(PersonTypeBadge, { props: { personType: '' } });
expect(document.querySelector('.badge')).toBeNull();
});
});

View File

@@ -1,190 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import PersonTypeahead from './PersonTypeahead.svelte';
afterEach(cleanup);
describe('PersonTypeahead', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
return new Response(
JSON.stringify([
{ id: 'p-1', displayName: 'Anna Schmidt', firstName: 'Anna', lastName: 'Schmidt' },
{ id: 'p-2', displayName: 'Bertha Müller', firstName: 'Bertha', lastName: 'Müller' }
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
});
});
afterEach(() => fetchSpy?.mockRestore());
it('renders the label and the search input', async () => {
render(PersonTypeahead, { props: { name: 'sender', label: 'Absender' } });
const label = document.querySelector('label[for="sender-search"]');
expect(label?.textContent).toContain('Absender');
const input = document.querySelector('input#sender-search');
expect(input).not.toBeNull();
});
it('appends an asterisk when required is true', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', required: true }
});
const label = document.querySelector('label[for="s-search"]');
expect(label?.textContent).toContain('*');
});
it('does not append an asterisk when required is false', async () => {
render(PersonTypeahead, { props: { name: 's', label: 'Absender' } });
const label = document.querySelector('label[for="s-search"]');
expect(label?.textContent).not.toContain('*');
});
it('uses the placeholder prop when provided', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', placeholder: 'Tippe los…' }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
expect(input.placeholder).toBe('Tippe los…');
});
it('seeds the searchTerm from initialName', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', initialName: 'Anna Schmidt' }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
expect(input.value).toBe('Anna Schmidt');
});
it('exposes the value via a hidden input', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', value: 'p-1' }
});
const hidden = document.querySelector('input[name="s"][type="hidden"]') as HTMLInputElement;
expect(hidden.value).toBe('p-1');
});
it('uses the large class set when large=true', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', large: true }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
expect(input.className).toContain('h-14');
});
it('uses the compact class set when compact=true', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', compact: true }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
expect(input.className).toContain('h-9');
});
it('does not render the listbox initially', async () => {
render(PersonTypeahead, { props: { name: 's', label: 'Absender' } });
const listbox = document.querySelector('[role="listbox"]');
expect(listbox).toBeNull();
});
it('opens the listbox on focus when restrictToCorrespondentsOf is set', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', restrictToCorrespondentsOf: 'parent-id' }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
input.dispatchEvent(new Event('focus', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).not.toBeNull();
});
});
it('updates aria-expanded when the dropdown opens', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', restrictToCorrespondentsOf: 'parent-id' }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
expect(input.getAttribute('aria-expanded')).toBe('false');
input.dispatchEvent(new Event('focus', { bubbles: true }));
await vi.waitFor(() => {
expect(input.getAttribute('aria-expanded')).toBe('true');
});
});
it('Escape key on a closed dropdown is a no-op (no listbox appears)', async () => {
render(PersonTypeahead, { props: { name: 's', label: 'Absender' } });
const input = document.querySelector('input#s-search') as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('keeps the input usable when fetch rejects on focus (no error UI, no crash)', async () => {
fetchSpy.mockRejectedValueOnce(new Error('boom'));
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', restrictToCorrespondentsOf: 'parent-id' }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
input.dispatchEvent(new Event('focus', { bubbles: true }));
// Graceful failure: no listbox surfaces but the input stays mounted and interactive.
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
expect(document.querySelector('input#s-search')).not.toBeNull();
});
it('keeps the input usable when fetch returns a non-OK response on focus', async () => {
fetchSpy.mockResolvedValueOnce(new Response('error', { status: 500 }));
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', restrictToCorrespondentsOf: 'parent-id' }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
input.dispatchEvent(new Event('focus', { bubbles: true }));
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
expect(document.querySelector('input#s-search')).not.toBeNull();
});
it('renders the FieldLabelBadge when badge is provided', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', badge: 'replace' }
});
const label = document.querySelector('label[for="s-search"]');
expect(label?.children.length).toBeGreaterThan(0);
});
it('does not render the FieldLabelBadge when badge is undefined', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender' }
});
const label = document.querySelector('label[for="s-search"]');
expect(label?.querySelector('[class*="badge"]')).toBeNull();
});
it('honours the autofocus prop', async () => {
render(PersonTypeahead, {
props: { name: 's', label: 'Absender', autofocus: true }
});
const input = document.querySelector('input#s-search') as HTMLInputElement;
expect(input.hasAttribute('autofocus')).toBe(true);
});
});

View File

@@ -1,182 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import StammbaumCard from './StammbaumCard.svelte';
afterEach(cleanup);
const baseProps = (overrides: Record<string, unknown> = {}) => ({
personId: 'p-1',
familyMember: false,
relationships: [] as unknown[],
inferredRelationships: [] as unknown[],
canWrite: false,
relationshipError: null as string | null,
...overrides
});
describe('StammbaumCard', () => {
it('renders the heading', async () => {
render(StammbaumCard, { props: baseProps() });
await expect
.element(page.getByRole('heading', { name: /stammbaum & beziehungen/i }))
.toBeVisible();
});
it('renders the family-member toggle when canWrite is true', async () => {
render(StammbaumCard, { props: baseProps({ canWrite: true }) });
await expect.element(page.getByRole('switch')).toBeVisible();
});
it('omits the family-member toggle when canWrite is false', async () => {
render(StammbaumCard, { props: baseProps() });
await expect.element(page.getByRole('switch')).not.toBeInTheDocument();
});
it('marks the toggle as aria-checked=true when familyMember is true', async () => {
render(StammbaumCard, { props: baseProps({ canWrite: true, familyMember: true }) });
await expect.element(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
});
it('renders the in-tree banner when familyMember is true', async () => {
render(StammbaumCard, { props: baseProps({ familyMember: true }) });
await expect.element(page.getByText('Erscheint im Stammbaum')).toBeVisible();
await expect
.element(page.getByRole('link', { name: /ansehen/i }))
.toHaveAttribute('href', '/stammbaum?focus=p-1');
});
it('hides the in-tree banner when familyMember is false', async () => {
render(StammbaumCard, { props: baseProps() });
await expect.element(page.getByText('Erscheint im Stammbaum')).not.toBeInTheDocument();
});
it('shows the relationshipError alert when set', async () => {
render(StammbaumCard, {
props: baseProps({ relationshipError: 'Beziehung konnte nicht gespeichert werden.' })
});
await expect
.element(page.getByText('Beziehung konnte nicht gespeichert werden.'))
.toBeVisible();
});
it('renders the empty placeholder for direct relationships when none exist', async () => {
render(StammbaumCard, { props: baseProps() });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeVisible();
});
it('hides the inferred-relationships disclosure when there are none', async () => {
render(StammbaumCard, { props: baseProps() });
await expect.element(page.getByText('Abgeleitete Beziehungen')).not.toBeInTheDocument();
});
it('renders the AddRelationshipForm when canWrite is true', async () => {
render(StammbaumCard, { props: baseProps({ canWrite: true }) });
// AddRelationshipForm renders interactive elements
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBeGreaterThan(1);
});
it('renders direct relationships sorted by relationType order', async () => {
render(StammbaumCard, {
props: baseProps({
relationships: [
{
id: 'r-friend',
relationType: 'FRIEND',
personA: { id: 'p-1', displayName: 'Anna' },
personB: { id: 'p-friend', displayName: 'Carlos' }
},
{
id: 'r-parent',
relationType: 'PARENT_OF',
personA: { id: 'p-1', displayName: 'Anna' },
personB: { id: 'p-child', displayName: 'Daniel' }
}
]
})
});
const items = document.querySelectorAll('ul.divide-y > li, ul.divide-y > *');
expect(items.length).toBeGreaterThanOrEqual(2);
});
it('renders the year range "fromto" for a relationship with both years', async () => {
render(StammbaumCard, {
props: baseProps({
relationships: [
{
id: 'r-1',
relationType: 'COLLEAGUE',
fromYear: 1940,
toYear: 1945,
personA: { id: 'p-1', displayName: 'Anna' },
personB: { id: 'p-x', displayName: 'Xavier' }
}
]
})
});
expect(document.body.textContent).toContain('1940');
expect(document.body.textContent).toContain('1945');
});
it('renders only "fromYear" for a relationship with no end year', async () => {
render(StammbaumCard, {
props: baseProps({
relationships: [
{
id: 'r-2',
relationType: 'NEIGHBOR',
fromYear: 1935,
personA: { id: 'p-1', displayName: 'Anna' },
personB: { id: 'p-y', displayName: 'Yvonne' }
}
]
})
});
expect(document.body.textContent).toContain('1935');
expect(document.body.textContent).not.toContain('1935');
});
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
render(StammbaumCard, {
props: baseProps({
inferredRelationships: [
{
label: 'GRANDPARENT_OF',
person: { id: 'p-grand', displayName: 'Grandma' }
}
]
})
});
await expect.element(page.getByText('Abgeleitete Beziehungen')).toBeVisible();
expect(document.body.textContent).toContain('Grandma');
});
it('caps the inferred relationships at 5 items', async () => {
const inferred = Array.from({ length: 8 }, (_, i) => ({
label: 'COUSIN_OF',
person: { id: `p-cousin-${i}`, displayName: `Cousin ${i}` }
}));
render(StammbaumCard, {
props: baseProps({ inferredRelationships: inferred })
});
const items = document.querySelectorAll('details ul > li');
expect(items.length).toBe(5);
});
});

View File

@@ -1,169 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
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: () => () => {}
}));
afterEach(cleanup);
const baseNode = (overrides: Record<string, unknown> = {}) => ({
id: 'p-1',
displayName: 'Anna Schmidt',
familyMember: true,
...overrides
});
describe('StammbaumSidePanel', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/relationships') && !u.includes('/inferred')) {
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (u.includes('/inferred-relationships')) {
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response('[]', { status: 200 });
});
});
afterEach(() => {
fetchSpy?.mockRestore();
});
it('renders the heading from node displayName', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {} }
});
await expect.element(page.getByRole('heading', { name: 'Anna Schmidt' })).toBeVisible();
});
it('shows birth/death years when set', async () => {
render(StammbaumSidePanel, {
props: {
node: baseNode({ birthYear: 1899, deathYear: 1950 }),
onClose: () => {}
}
});
expect(document.body.textContent).toContain('1899');
expect(document.body.textContent).toContain('1950');
});
it('renders ? when birthYear is missing but deathYear is set', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode({ deathYear: 1950 }), onClose: () => {} }
});
expect(document.body.textContent).toMatch(/\?/);
});
it('does not render the years line when both are missing', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {} }
});
expect(document.body.textContent).not.toMatch(/\?\?/);
});
it('calls onClose when the close button is clicked', async () => {
const onClose = vi.fn();
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose }
});
const closeBtn = document.querySelector('button[aria-label]') as HTMLButtonElement;
closeBtn.click();
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when Escape is pressed on window', async () => {
const onClose = vi.fn();
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose }
});
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onClose).toHaveBeenCalledOnce();
});
it('does not call onClose for non-Escape keys', async () => {
const onClose = vi.fn();
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose }
});
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(onClose).not.toHaveBeenCalled();
});
it('renders the person detail link', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {} }
});
const link = document.querySelector('a[href="/persons/p-1"]');
expect(link).not.toBeNull();
});
it('shows the empty placeholder when there are no direct relationships', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {} }
});
await expect.element(page.getByText(/keine beziehungen bekannt/i)).toBeVisible();
});
it('shows the error banner when both fetch calls fail', async () => {
fetchSpy.mockImplementation(async () => new Response('error', { status: 500 }));
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {} }
});
await vi.waitFor(() => {
expect(document.querySelector('[role="alert"]')).not.toBeNull();
});
});
it('shows the AddRelationshipForm only when canWrite is true', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {}, canWrite: true }
});
await vi.waitFor(() => {
const addButtons = Array.from(document.querySelectorAll('button')).filter((b) =>
b.textContent?.toLowerCase().includes('hinzufügen')
);
expect(addButtons.length).toBeGreaterThan(0);
});
});
it('hides the AddRelationshipForm when canWrite is false', async () => {
render(StammbaumSidePanel, {
props: { node: baseNode(), onClose: () => {}, canWrite: false }
});
// canWrite=false hides the form — assert the toggle button is absent in the rendered DOM.
const addButtons = Array.from(document.querySelectorAll('button')).filter((b) =>
b.textContent?.toLowerCase().includes('hinzufügen')
);
expect(addButtons.length).toBe(0);
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
@@ -347,304 +347,3 @@ describe('StammbaumTree viewBox', () => {
expect(y + h / 2).toBeCloseTo(c.y, 1);
});
});
describe('StammbaumTree node rendering branches', () => {
it('renders the selected node with primary fill (selected branch)', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [],
selectedId: ID_A,
zoom: 1,
onSelect: () => {}
});
const rects = Array.from(document.querySelectorAll('rect'));
const primaryRects = rects.filter((r) => r.getAttribute('fill') === 'var(--c-primary)');
expect(primaryRects.length).toBeGreaterThan(0);
});
it('renders birth/death years line when set', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true, birthYear: 1899, deathYear: 1950 }
],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
expect(document.body.textContent).toContain('1899');
expect(document.body.textContent).toContain('1950');
});
it('renders ? for missing birthYear with deathYear set', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
expect(document.body.textContent).toMatch(/\?/);
});
it('omits the years line when neither birthYear nor deathYear is set', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
expect(document.body.textContent).not.toMatch(/\?\?/);
});
it('calls onSelect when a node is clicked', async () => {
const onSelect = vi.fn();
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect
});
const node = document.querySelector('g[role="button"]') as SVGGElement;
node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onSelect).toHaveBeenCalledWith(ID_A);
});
it('handles Enter keypress on node like click', async () => {
const onSelect = vi.fn();
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect
});
const node = document.querySelector('g[role="button"]') as SVGGElement;
const evt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
node.dispatchEvent(evt);
expect(onSelect).toHaveBeenCalledWith(ID_A);
});
it('handles Space keypress on node like click', async () => {
const onSelect = vi.fn();
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect
});
const node = document.querySelector('g[role="button"]') as SVGGElement;
node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(onSelect).toHaveBeenCalledWith(ID_A);
});
it('does not call onSelect for other keys', async () => {
const onSelect = vi.fn();
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect
});
const node = document.querySelector('g[role="button"]') as SVGGElement;
node.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
expect(onSelect).not.toHaveBeenCalled();
});
it('renders dashed spouse line when toYear is set (divorced)', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [
{
id: 'e1',
personId: ID_A,
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF',
toYear: 1925
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const dashed = Array.from(document.querySelectorAll('line')).filter((l) =>
l.hasAttribute('stroke-dasharray')
);
expect(dashed.length).toBeGreaterThan(0);
});
it('renders solid spouse line when no toYear (still married)', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [
{
id: 'e1',
personId: ID_A,
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('line'));
const dashedLines = lines.filter((l) => l.getAttribute('stroke-dasharray'));
expect(dashedLines.length).toBe(0);
});
it('renders single-parent connector lines when no spouse pair', async () => {
const PARENT = '00000000-0000-0000-0000-00000000aaa1';
const CHILD = '00000000-0000-0000-0000-00000000bbb1';
render(StammbaumTree, {
nodes: [
{ id: PARENT, displayName: 'Parent', familyMember: true },
{ id: CHILD, displayName: 'Child', familyMember: true }
],
edges: [
{
id: 'e-p',
personId: PARENT,
relatedPersonId: CHILD,
personDisplayName: 'Parent',
relatedPersonDisplayName: 'Child',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = document.querySelectorAll('line');
expect(lines.length).toBeGreaterThanOrEqual(2);
});
it('focuses a node and renders the focus ring on focus event', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const node = document.querySelector('g[role="button"]') as SVGGElement;
node.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
const focusRing = Array.from(document.querySelectorAll('rect')).find(
(r) => r.getAttribute('stroke') === 'var(--c-focus-ring)'
);
expect(focusRing).toBeDefined();
});
it('removes the focus ring on blur', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const node = document.querySelector('g[role="button"]') as SVGGElement;
node.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
node.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
const focusRing = Array.from(document.querySelectorAll('rect')).find(
(r) => r.getAttribute('stroke') === 'var(--c-focus-ring)'
);
expect(focusRing).toBeUndefined();
});
it('aria-label includes node displayName and life dates', async () => {
render(StammbaumTree, {
nodes: [
{
id: ID_A,
displayName: 'Anna Schmidt',
familyMember: true,
birthYear: 1900,
deathYear: 1980
}
],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const node = document.querySelector('g[role="button"]');
expect(node?.getAttribute('aria-label')).toContain('Anna Schmidt');
expect(node?.getAttribute('aria-label')).toContain('1900');
});
it('aria-expanded reflects selected state', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [],
selectedId: ID_A,
zoom: 1,
onSelect: () => {}
});
const nodes = document.querySelectorAll('g[role="button"]');
const a = nodes[0] as SVGGElement;
const b = nodes[1] as SVGGElement;
const aSelected = a.getAttribute('aria-expanded') === 'true';
const bSelected = b.getAttribute('aria-expanded') === 'true';
// Exactly one should be aria-expanded=true (the selected one)
expect([aSelected, bSelected].filter(Boolean).length).toBe(1);
});
it('accent stripe rect appears only on selected node', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [],
selectedId: ID_A,
zoom: 1,
onSelect: () => {}
});
const accentRects = Array.from(document.querySelectorAll('rect')).filter(
(r) => r.getAttribute('fill') === 'var(--c-accent)'
);
expect(accentRects.length).toBe(1);
});
});

View File

@@ -1,160 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AddRelationshipForm from './AddRelationshipForm.svelte';
afterEach(cleanup);
describe('AddRelationshipForm', () => {
it('renders the toggle button to open the form', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await expect.element(page.getByRole('button', { name: /hinzufügen/i })).toBeVisible();
});
it('opens the form when the toggle button is clicked', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
expect(document.querySelector('select[name="relationType"]')).not.toBeNull();
});
it('renders all relationship type options when open', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const select = document.querySelector('select[name="relationType"]') as HTMLSelectElement;
const optionValues = Array.from(select.options).map((o) => o.value);
expect(optionValues).toContain('PARENT_OF');
expect(optionValues).toContain('SPOUSE_OF');
expect(optionValues).toContain('FRIEND');
expect(optionValues).toContain('OTHER');
});
it('shows the year-error alert when toYear is before fromYear', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1920';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/bis-jahr darf nicht vor von-jahr/i)).toBeVisible();
});
it('does not show the year-error when toYear equals fromYear', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1923';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/bis-jahr darf nicht/i)).not.toBeInTheDocument();
});
it('cancel button closes the form', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
expect(document.querySelector('select[name="relationType"]')).not.toBeNull();
const cancelBtn = Array.from(document.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('abbrechen')
);
expect(cancelBtn).toBeDefined();
cancelBtn?.click();
await vi.waitFor(() => {
expect(document.querySelector('select[name="relationType"]')).toBeNull();
});
});
it('does not invoke onSubmit before user submission', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { props: { personId: 'p-1', onSubmit } });
// Without a person selected, the form cannot be submitted by the user.
expect(onSubmit).not.toHaveBeenCalled();
});
it('renders with use:enhance form action when onSubmit is undefined', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
await vi.waitFor(() => {
expect(document.querySelector('form[action="?/addRelationship"]')).not.toBeNull();
});
});
it('renders the callback-based form when onSubmit is provided', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1', onSubmit: async () => {} } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
await vi.waitFor(() => {
// callback form has no action attribute (just onsubmit handler)
expect(document.querySelector('form[action="?/addRelationship"]')).toBeNull();
expect(document.querySelector('form')).not.toBeNull();
});
});
it('shows the self-error when the related person id equals personId', async () => {
render(AddRelationshipForm, { props: { personId: 'p-self' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const relInput = (await vi.waitFor(() => {
const el = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement;
expect(el).not.toBeNull();
return el;
})) as HTMLInputElement;
relInput.value = 'p-self';
relInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/selbst|self/i)).toBeVisible();
});
it('keeps submit disabled when no related person is selected', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
await vi.waitFor(() => {
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement | null;
expect(submitBtn).not.toBeNull();
expect(submitBtn!.disabled).toBe(true);
});
});
it('keeps submit disabled when there is a yearError', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
const relInput = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1920';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
relInput.value = 'p-other';
relInput.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => {
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(submitBtn.disabled).toBe(true);
});
});
});

View File

@@ -1,21 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import RelationshipPill from './RelationshipPill.svelte';
afterEach(cleanup);
describe('RelationshipPill', () => {
it('renders the supplied label', async () => {
render(RelationshipPill, { props: { label: 'Vater' } });
await expect.element(page.getByText('Vater')).toBeVisible();
});
it('renders an empty string label without crashing', async () => {
render(RelationshipPill, { props: { label: '' } });
const span = document.querySelector('span');
expect(span).not.toBeNull();
});
});

View File

@@ -1,83 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardFamilyPulse from './DashboardFamilyPulse.svelte';
afterEach(cleanup);
const basePulse = (overrides: Record<string, unknown> = {}) => ({
pages: 0,
yourPages: 0,
contributors: [] as { initials: string; color: string; name?: string | null }[],
annotated: 0,
transcribed: 0,
uploaded: 0,
reviewed: 0,
...overrides
});
describe('DashboardFamilyPulse', () => {
it('renders nothing when pulse is null', async () => {
render(DashboardFamilyPulse, { props: { pulse: null } });
expect(document.querySelector('section')).toBeNull();
});
it('renders the eyebrow when pulse is not null', async () => {
render(DashboardFamilyPulse, { props: { pulse: basePulse() } });
await expect.element(page.getByText('Diese Woche')).toBeVisible();
});
it('hides the headline when pages is 0', async () => {
render(DashboardFamilyPulse, { props: { pulse: basePulse({ pages: 0 }) } });
await expect.element(page.getByRole('heading')).not.toBeInTheDocument();
});
it('renders the headline when pages > 0', async () => {
render(DashboardFamilyPulse, { props: { pulse: basePulse({ pages: 12 }) } });
await expect.element(page.getByText(/12 Seiten bearbeitet/)).toBeVisible();
});
it('renders the "you" line only when yourPages > 0', async () => {
render(DashboardFamilyPulse, { props: { pulse: basePulse({ yourPages: 3 }) } });
await expect.element(page.getByText(/3 davon bearbeitet/)).toBeVisible();
});
it('omits the contributors section when there are none', async () => {
render(DashboardFamilyPulse, { props: { pulse: basePulse() } });
await expect.element(page.getByText('Mitwirkende')).not.toBeInTheDocument();
});
it('renders one chip per contributor', async () => {
render(DashboardFamilyPulse, {
props: {
pulse: basePulse({
contributors: [
{ initials: 'AS', color: '#012851', name: 'Anna Schmidt' },
{ initials: 'BM', color: '#5a3080', name: 'Bert Meier' }
]
})
}
});
await expect.element(page.getByText('AS')).toBeVisible();
await expect.element(page.getByText('BM')).toBeVisible();
});
it('renders the three count tiles', async () => {
render(DashboardFamilyPulse, {
props: {
pulse: basePulse({ annotated: 15, transcribed: 7, uploaded: 3 })
}
});
await expect.element(page.getByText('15')).toBeVisible();
await expect.element(page.getByText('7')).toBeVisible();
await expect.element(page.getByText('3')).toBeVisible();
});
});

View File

@@ -1,71 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardRecentDocuments from './DashboardRecentDocuments.svelte';
afterEach(cleanup);
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
updatedAt: '2026-04-15T10:00:00Z',
...overrides
});
describe('DashboardRecentDocuments', () => {
it('renders nothing when recentDocs is empty', async () => {
render(DashboardRecentDocuments, { props: { recentDocs: [] } });
expect(document.querySelector('[data-testid="dashboard-recent-docs"]')).toBeNull();
});
it('renders the heading and one row per doc', async () => {
render(DashboardRecentDocuments, {
props: { recentDocs: [makeDoc({ id: 'd1', title: 'A' }), makeDoc({ id: 'd2', title: 'B' })] }
});
await expect.element(page.getByRole('heading', { name: /zuletzt aktiv/i })).toBeVisible();
expect(document.querySelectorAll('[data-testid^="doc-row-"]').length).toBe(2);
});
it('renders the title as a link to the document detail', async () => {
render(DashboardRecentDocuments, { props: { recentDocs: [makeDoc()] } });
await expect
.element(page.getByRole('link', { name: 'Brief 1923' }))
.toHaveAttribute('href', '/documents/d1');
});
it('renders the formatted date when updatedAt is present', async () => {
render(DashboardRecentDocuments, { props: { recentDocs: [makeDoc()] } });
expect(document.querySelector('[data-testid="doc-date-d1"]')).not.toBeNull();
});
it('omits the date when updatedAt is undefined', async () => {
render(DashboardRecentDocuments, {
props: { recentDocs: [makeDoc({ updatedAt: undefined })] }
});
expect(document.querySelector('[data-testid="doc-date-d1"]')).toBeNull();
});
it('renders the stats footnote when stats.totalDocuments is set', async () => {
render(DashboardRecentDocuments, {
props: {
recentDocs: [makeDoc()],
stats: { totalDocuments: 50, totalPersons: 12 } as unknown as never
}
});
const footnote = document.querySelector('[data-testid="dashboard-stats-footnote"]');
expect(footnote?.textContent).toContain('50');
expect(footnote?.textContent).toContain('12');
});
it('omits the stats footnote when stats is null', async () => {
render(DashboardRecentDocuments, { props: { recentDocs: [makeDoc()], stats: null } });
expect(document.querySelector('[data-testid="dashboard-stats-footnote"]')).toBeNull();
});
});

View File

@@ -1,105 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardResumeStrip from './DashboardResumeStrip.svelte';
afterEach(cleanup);
const makeResume = (overrides: Record<string, unknown> = {}) => ({
documentId: 'd1',
title: 'Brief 1923',
caption: 'Sender → Receiver',
excerpt: 'First paragraph',
totalBlocks: 12,
pct: 50,
thumbnailUrl: '/api/d1/thumb',
collaborators: [{ initials: 'AS', color: '#012851', name: null }],
...overrides
});
describe('DashboardResumeStrip', () => {
it('renders the empty card when resumeDoc is null', async () => {
render(DashboardResumeStrip, { props: { resumeDoc: null } });
const empty = document.querySelector('[data-testid="resume-strip-empty"]');
expect(empty).not.toBeNull();
await expect
.element(page.getByRole('heading', { name: /noch kein dokument begonnen/i }))
.toBeVisible();
});
it('renders the resume strip when resumeDoc is provided', async () => {
render(DashboardResumeStrip, { props: { resumeDoc: makeResume() } });
expect(document.querySelector('[data-testid="resume-strip"]')).not.toBeNull();
});
it('renders the document title', async () => {
render(DashboardResumeStrip, { props: { resumeDoc: makeResume() } });
await expect.element(page.getByRole('heading', { name: /brief 1923/i })).toBeVisible();
});
it('renders the thumbnail image when thumbnailUrl is set', async () => {
render(DashboardResumeStrip, { props: { resumeDoc: makeResume() } });
expect(document.querySelector('[data-testid="resume-thumbnail-img"]')).not.toBeNull();
});
it('renders the placeholder icon when thumbnailUrl is missing', async () => {
render(DashboardResumeStrip, {
props: { resumeDoc: makeResume({ thumbnailUrl: null }) }
});
expect(document.querySelector('[data-testid="resume-thumbnail-fallback"]')).not.toBeNull();
});
it('renders the progress bar with correct aria-valuenow', async () => {
render(DashboardResumeStrip, { props: { resumeDoc: makeResume({ pct: 75 }) } });
const progress = document.querySelector('[role="progressbar"]') as HTMLElement;
expect(progress.getAttribute('aria-valuenow')).toBe('75');
});
it('renders the resume CTA link to the document detail', async () => {
render(DashboardResumeStrip, {
props: { resumeDoc: makeResume({ documentId: 'doc-42' }) }
});
const link = document.querySelector('a[href="/documents/doc-42"]') as HTMLAnchorElement;
expect(link).not.toBeNull();
});
it('renders the collaborators stack', async () => {
render(DashboardResumeStrip, {
props: {
resumeDoc: makeResume({
collaborators: [
{ initials: 'XR', color: '#012851', name: null },
{ initials: 'YQ', color: '#5A3080', name: null }
]
})
}
});
await expect.element(page.getByText('XR')).toBeVisible();
await expect.element(page.getByText('YQ')).toBeVisible();
});
it('falls back to the default color when collaborator color is invalid', async () => {
render(DashboardResumeStrip, {
props: {
resumeDoc: makeResume({
collaborators: [{ initials: 'ZQ', color: 'not-a-hex', name: null }]
})
}
});
// safeColor falls back to #8c9aa3 — browser may serialize as rgb(140, 154, 163)
const span = Array.from(document.querySelectorAll('span')).find(
(s) => s.textContent?.trim() === 'ZQ'
) as HTMLElement;
const style = span.getAttribute('style') ?? '';
expect(style.toLowerCase()).toMatch(/(8c9aa3|140,\s*154,\s*163)/);
});
});

View File

@@ -1,56 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
afterEach(cleanup);
const makeDraft = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'My Draft Story',
body: '<p>Draft content</p>',
publishedAt: null,
updatedAt: '2026-04-15T10:00:00Z',
...overrides
});
describe('ReaderDraftsModule', () => {
it('renders the heading', async () => {
render(ReaderDraftsModule, { props: { drafts: [] } });
await expect.element(page.getByRole('heading', { name: /meine entwürfe/i })).toBeVisible();
});
it('renders the empty placeholder when drafts is empty', async () => {
render(ReaderDraftsModule, { props: { drafts: [] } });
await expect.element(page.getByText('Keine Entwürfe')).toBeVisible();
});
it('renders one row per draft', async () => {
render(ReaderDraftsModule, {
props: {
drafts: [
makeDraft({ id: 'g1', title: 'Draft 1' }),
makeDraft({ id: 'g2', title: 'Draft 2' })
]
}
});
await expect.element(page.getByText('Draft 1')).toBeVisible();
await expect.element(page.getByText('Draft 2')).toBeVisible();
});
it('renders the draft link to /geschichten/{id}/edit', async () => {
render(ReaderDraftsModule, { props: { drafts: [makeDraft({ id: 'g-42' })] } });
const link = document.querySelector('a[href="/geschichten/g-42/edit"]');
expect(link).not.toBeNull();
});
it('renders the meta line with relative time', async () => {
render(ReaderDraftsModule, { props: { drafts: [makeDraft()] } });
expect(document.body.textContent).toContain('Entwurf');
});
});

View File

@@ -1,65 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderHeaderBar from './ReaderHeaderBar.svelte';
afterEach(cleanup);
const baseProps = (overrides: Record<string, unknown> = {}) => ({
name: 'Anna',
documents: 50,
persons: 12,
stories: 5,
...overrides
});
describe('ReaderHeaderBar', () => {
it('renders the welcome greeting with name', async () => {
render(ReaderHeaderBar, { props: baseProps() });
await expect.element(page.getByText(/anna/i)).toBeVisible();
});
it('renders Morgen label for hours before noon', async () => {
render(ReaderHeaderBar, { props: baseProps({ hour: 9 }) });
await expect.element(page.getByText('Morgen')).toBeVisible();
});
it('renders Mittag label for hours 12-17', async () => {
render(ReaderHeaderBar, { props: baseProps({ hour: 14 }) });
await expect.element(page.getByText('Mittag')).toBeVisible();
});
it('renders Abend label for hours 18+', async () => {
render(ReaderHeaderBar, { props: baseProps({ hour: 20 }) });
await expect.element(page.getByText('Abend')).toBeVisible();
});
it('renders the stats counts and links', async () => {
render(ReaderHeaderBar, { props: baseProps() });
// Counts visible somewhere
expect(document.body.textContent).toContain('50');
expect(document.body.textContent).toContain('12');
const docsLink = document.querySelector('a[href="/documents"]');
const personsLink = document.querySelector('a[href="/persons"]');
const storiesLink = document.querySelector('a[href="/geschichten"]');
expect(docsLink).not.toBeNull();
expect(personsLink).not.toBeNull();
expect(storiesLink).not.toBeNull();
});
it('renders em-dash when stats are null', async () => {
render(ReaderHeaderBar, {
props: baseProps({ documents: null, persons: null, stories: null })
});
const dashes = Array.from(document.querySelectorAll('span.text-2xl'));
const dashCount = dashes.filter((el) => el.textContent?.trim() === '—').length;
expect(dashCount).toBe(3);
});
});

View File

@@ -1,80 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderPersonChips from './ReaderPersonChips.svelte';
afterEach(cleanup);
const makePerson = (overrides: Record<string, unknown> = {}) => ({
id: 'p1',
displayName: 'Anna Schmidt',
lastName: 'Schmidt',
documentCount: 5,
...overrides
});
describe('ReaderPersonChips', () => {
it('renders the section with the persons heading via aria-label', async () => {
render(ReaderPersonChips, { props: { persons: [] } });
await expect.element(page.getByRole('region', { name: /personen/i })).toBeVisible();
});
it('renders the no-persons placeholder when persons is empty', async () => {
render(ReaderPersonChips, { props: { persons: [] } });
await expect.element(page.getByText('Noch keine Personen im Archiv.')).toBeVisible();
});
it('renders one chip per person with link to person detail', async () => {
render(ReaderPersonChips, {
props: {
persons: [
makePerson({ id: 'p1', displayName: 'Anna Schmidt' }),
makePerson({ id: 'p2', displayName: 'Bert Meier' })
]
}
});
await expect
.element(page.getByRole('link', { name: /anna schmidt/i }))
.toHaveAttribute('href', '/persons/p1');
await expect
.element(page.getByRole('link', { name: /bert meier/i }))
.toHaveAttribute('href', '/persons/p2');
});
it('renders document count chip when documentCount > 0', async () => {
render(ReaderPersonChips, {
props: { persons: [makePerson({ documentCount: 7 })] }
});
await expect.element(page.getByText('7')).toBeVisible();
});
it('omits document count chip when documentCount is 0', async () => {
render(ReaderPersonChips, {
props: { persons: [makePerson({ documentCount: 0 })] }
});
await expect.element(page.getByText('0')).not.toBeInTheDocument();
});
it('falls back to lastName when displayName is missing', async () => {
render(ReaderPersonChips, {
props: {
persons: [makePerson({ displayName: null, lastName: 'Schmidt' })]
}
});
await expect.element(page.getByRole('link', { name: /schmidt/i })).toBeVisible();
});
it('renders the all-persons footer link', async () => {
render(ReaderPersonChips, { props: { persons: [makePerson()] } });
await expect
.element(page.getByRole('link', { name: /alle personen/i }))
.toHaveAttribute('href', '/persons');
});
});

View File

@@ -1,97 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderRecentDocs from './ReaderRecentDocs.svelte';
afterEach(cleanup);
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T10:00:00Z',
sender: { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
...overrides
});
describe('ReaderRecentDocs', () => {
it('renders the heading', async () => {
render(ReaderRecentDocs, { props: { documents: [] } });
await expect
.element(page.getByRole('heading', { name: /zuletzt aktualisiert/i }))
.toBeVisible();
});
it('renders the all-documents link', async () => {
render(ReaderRecentDocs, { props: { documents: [] } });
await expect
.element(page.getByRole('link', { name: /alle dokumente/i }))
.toHaveAttribute('href', '/documents');
});
it('renders the New badge when createdAt equals updatedAt', async () => {
render(ReaderRecentDocs, {
props: {
documents: [
makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' })
]
}
});
await expect.element(page.getByText('Neu')).toBeVisible();
});
it('hides the New badge when document was updated after creation', async () => {
render(ReaderRecentDocs, {
props: {
documents: [
makeDoc({
createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T11:00:00Z'
})
]
}
});
await expect.element(page.getByText('Neu')).not.toBeInTheDocument();
});
it('renders the sender displayName', async () => {
render(ReaderRecentDocs, { props: { documents: [makeDoc()] } });
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
});
it('falls back to em-dash when sender is null', async () => {
render(ReaderRecentDocs, {
props: { documents: [makeDoc({ sender: null })] }
});
expect(document.body.textContent).toContain('—');
});
it('falls back to lastName when displayName is missing', async () => {
render(ReaderRecentDocs, {
props: {
documents: [
makeDoc({
sender: { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: null }
})
]
}
});
await expect.element(page.getByText(/Schmidt/)).toBeVisible();
});
it('renders the document link to /documents/{id}', async () => {
render(ReaderRecentDocs, { props: { documents: [makeDoc({ id: 'd-42' })] } });
const links = document.querySelectorAll('a[href^="/documents/"]');
expect(
Array.from(links).some((a) => (a as HTMLAnchorElement).href.includes('/documents/d-42'))
).toBe(true);
});
});

View File

@@ -1,73 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderRecentStories from './ReaderRecentStories.svelte';
afterEach(cleanup);
const makeStory = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text content</p>',
publishedAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T10:00:00Z',
...overrides
});
describe('ReaderRecentStories', () => {
it('renders nothing when stories is empty', async () => {
render(ReaderRecentStories, { props: { stories: [] } });
expect(document.querySelector('h3')).toBeNull();
});
it('renders the heading and one row per story', async () => {
render(ReaderRecentStories, {
props: {
stories: [
makeStory({ id: 'g1', title: 'Story 1' }),
makeStory({ id: 'g2', title: 'Story 2' })
]
}
});
await expect.element(page.getByRole('heading', { name: /neue geschichten/i })).toBeVisible();
await expect.element(page.getByText('Story 1')).toBeVisible();
await expect.element(page.getByText('Story 2')).toBeVisible();
});
it('renders the link to /geschichten in the header', async () => {
render(ReaderRecentStories, { props: { stories: [makeStory()] } });
await expect
.element(page.getByRole('link', { name: /alle geschichten/i }))
.toHaveAttribute('href', '/geschichten');
});
it('renders the story link to /geschichten/{id}', async () => {
render(ReaderRecentStories, { props: { stories: [makeStory({ id: 'g-42' })] } });
const links = document.querySelectorAll('a[href^="/geschichten/"]');
const detailLinks = Array.from(links).filter((a) =>
(a as HTMLAnchorElement).href.includes('/geschichten/g-42')
);
expect(detailLinks.length).toBe(1);
});
it('renders the body excerpt when present', async () => {
render(ReaderRecentStories, {
props: { stories: [makeStory({ body: '<p>Once upon a time in 1923</p>' })] }
});
await expect.element(page.getByText(/Once upon a time in 1923/)).toBeVisible();
});
it('omits the excerpt paragraph when body is empty', async () => {
render(ReaderRecentStories, {
props: { stories: [makeStory({ body: '' })] }
});
const paragraphs = document.querySelectorAll('p');
expect(paragraphs.length).toBe(0);
});
});

View File

@@ -1,93 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import CommentMessage from './CommentMessage.svelte';
afterEach(cleanup);
const baseMessage = (overrides: Record<string, unknown> = {}) => ({
id: 'm1',
authorId: 'u1',
authorName: 'Anna Schmidt',
content: 'Tolle Geschichte!',
createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T10:00:00Z',
mentionDTOs: [] as unknown[],
...overrides
});
const baseProps = (overrides: Record<string, unknown> = {}) => ({
message: baseMessage(),
isOwn: false,
isEditing: false,
editText: '',
onEdit: () => {},
onDelete: () => {},
onEditTextChange: () => {},
onEditKeydown: () => {},
...overrides
});
describe('CommentMessage', () => {
it('renders the author name and avatar initials', async () => {
render(CommentMessage, { props: baseProps() });
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByText('AS')).toBeVisible();
});
it('renders the comment body', async () => {
render(CommentMessage, { props: baseProps() });
await expect.element(page.getByText('Tolle Geschichte!')).toBeVisible();
});
it('shows the edited label when updatedAt > createdAt', async () => {
render(CommentMessage, {
props: baseProps({
message: baseMessage({
createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T11:00:00Z'
})
})
});
await expect.element(page.getByText('(Bearbeitet)')).toBeVisible();
});
it('hides the edited label when updatedAt equals createdAt', async () => {
render(CommentMessage, { props: baseProps() });
await expect.element(page.getByText('(Bearbeitet)')).not.toBeInTheDocument();
});
it('shows the textarea when in edit mode', async () => {
render(CommentMessage, {
props: baseProps({ isOwn: true, isEditing: true, editText: 'Editing content' })
});
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('Editing content');
});
it('shows the delete button only when isOwn is true', async () => {
render(CommentMessage, { props: baseProps({ isOwn: true }) });
await expect.element(page.getByRole('button', { name: /löschen anna schmidt/i })).toBeVisible();
});
it('hides the delete button when isOwn is false', async () => {
render(CommentMessage, { props: baseProps() });
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
});
it('calls onDelete when the delete button is clicked', async () => {
const onDelete = vi.fn();
render(CommentMessage, { props: baseProps({ isOwn: true, onDelete }) });
await page.getByRole('button', { name: /löschen anna schmidt/i }).click();
expect(onDelete).toHaveBeenCalledOnce();
});
});

View File

@@ -1,391 +0,0 @@
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();
});
});

View File

@@ -1,106 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import MentionDropdown from './MentionDropdown.svelte';
afterEach(cleanup);
const makePerson = (id: string, name: string, overrides: Record<string, unknown> = {}) => ({
id,
firstName: name.split(' ')[0] ?? null,
lastName: name.split(' ').slice(1).join(' ') || name,
displayName: name,
birthYear: null as number | null,
deathYear: null as number | null,
...overrides
});
const baseModel = (overrides: Record<string, unknown> = {}) => ({
items: [] as ReturnType<typeof makePerson>[],
command: vi.fn(),
clientRect: () => new DOMRect(100, 100, 0, 24),
...overrides
});
describe('MentionDropdown', () => {
it('renders the listbox with the mention label', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
});
it('renders the empty placeholder when items is empty', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
await expect.element(page.getByText('Keine Personen gefunden')).toBeVisible();
});
it('shows the create-new escape hatch link in the empty state', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const link = (await page
.getByRole('link', { name: /neue person anlegen/i })
.element()) as HTMLAnchorElement;
expect(link.href).toContain('/persons/new');
expect(link.target).toBe('_blank');
expect(link.rel).toContain('noopener');
});
it('renders one option per item when populated', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
})
}
});
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByText('Bert Meier')).toBeVisible();
});
it('marks the first item as aria-selected by default', async () => {
render(MentionDropdown, {
props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) }
});
const option = document.querySelector('[role="option"]');
expect(option?.getAttribute('aria-selected')).toBe('true');
});
it('renders the life-date range when birthYear or deathYear is present', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [makePerson('p1', 'Anna', { birthYear: 1899, deathYear: 1972 })]
})
}
});
await expect.element(page.getByText(/1899/)).toBeVisible();
});
it('falls back to a default position when clientRect returns null', async () => {
render(MentionDropdown, {
props: {
model: baseModel({ clientRect: () => null })
}
});
const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
expect(dropdown.style.left).toBe('0px');
});
it('positions itself based on the clientRect callback', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
clientRect: () => new DOMRect(123, 200, 50, 24)
})
}
});
const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
expect(dropdown.style.left).toBe('123px');
});
});

View File

@@ -1,260 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import MentionEditor from './MentionEditor.svelte';
afterEach(cleanup);
describe('MentionEditor', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url: RequestInfo | URL) => {
const u = url.toString();
if (u.includes('/api/users/search')) {
return new Response(
JSON.stringify([
{ id: 'u1', firstName: 'Anna', lastName: 'Schmidt' },
{ id: 'u2', firstName: 'Bertha', lastName: 'Müller' }
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response('not found', { status: 404 });
});
});
afterEach(() => {
fetchSpy?.mockRestore();
});
function fireAtMention(ta: HTMLTextAreaElement, text: string) {
ta.focus();
ta.value = text;
ta.selectionStart = text.length;
ta.selectionEnd = text.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
it('renders the textarea with the placeholder', async () => {
render(MentionEditor, {
props: {
value: '',
mentionCandidates: [],
placeholder: 'Schreibe etwas…'
}
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
expect(ta).not.toBeNull();
expect(ta.placeholder).toBe('Schreibe etwas…');
});
it('honours the rows prop', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [], rows: 7 }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
expect(ta.rows).toBe(7);
});
it('disables the textarea when disabled is true', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [], disabled: true }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
expect(ta.disabled).toBe(true);
});
it('does not show the popup initially', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const popup = document.querySelector('[role="listbox"]');
expect(popup).toBeNull();
});
it('opens the popup when typing @ followed by a query', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, 'Hi @An');
// Debounce fires (200ms), fetch resolves, popup opens — vi.waitFor polls until ready.
await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).not.toBeNull();
});
});
it('renders the empty-popup label when fetch returns no results', async () => {
fetchSpy.mockImplementationOnce(
async () =>
new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } })
);
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@Zzzz');
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
});
it('clears results when fetch is not OK', async () => {
fetchSpy.mockImplementationOnce(async () => new Response('error', { status: 500 }));
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@Anna');
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
});
it('calls onsubmit when Enter is pressed without a popup open', async () => {
const onsubmit = vi.fn();
render(MentionEditor, {
props: { value: 'Hello', mentionCandidates: [], onsubmit }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onsubmit).toHaveBeenCalledOnce();
});
it('does not call onsubmit when Shift+Enter is pressed', async () => {
const onsubmit = vi.fn();
render(MentionEditor, {
props: { value: 'Hello', mentionCandidates: [], onsubmit }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(onsubmit).not.toHaveBeenCalled();
});
it('closes the popup when Escape is pressed', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@An');
await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).not.toBeNull();
});
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
});
it('navigates results with ArrowDown and ArrowUp', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@An');
await vi.waitFor(() => {
expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(1);
});
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await vi.waitFor(() => {
const opts = document.querySelectorAll('[role="option"]');
expect(opts[1]?.getAttribute('aria-selected')).toBe('true');
});
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await vi.waitFor(() => {
const opts = document.querySelectorAll('[role="option"]');
expect(opts[0]?.getAttribute('aria-selected')).toBe('true');
});
});
it('keeps the popup open when Enter is hit and no results are present', async () => {
fetchSpy.mockImplementationOnce(
async () =>
new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } })
);
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@Zz');
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
// Enter with no results does not close the popup and does not submit.
expect(document.querySelector('[role="listbox"]')).not.toBeNull();
});
it('selects a user via mousedown click and closes the popup', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@An');
await vi.waitFor(() => {
expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0);
});
const firstOption = document.querySelector('[role="option"]') as HTMLElement;
firstOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
});
it('selects via Enter when results are present and closes the popup', async () => {
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@An');
await vi.waitFor(() => {
expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0);
});
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
});
it('handles fetch network throw gracefully (empty popup label)', async () => {
fetchSpy.mockImplementationOnce(async () => {
throw new Error('network down');
});
render(MentionEditor, {
props: { value: '', mentionCandidates: [] }
});
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireAtMention(ta, '@An');
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
});
});

View File

@@ -1,50 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import BackButton from './BackButton.svelte';
afterEach(cleanup);
describe('BackButton', () => {
it('renders the visible label by default (showLabel=true)', async () => {
render(BackButton, { props: {} });
await expect.element(page.getByRole('button', { name: /^zurück$/i })).toBeVisible();
});
it('hides the visible label when showLabel is false', async () => {
render(BackButton, { props: { showLabel: false } });
const btn = (await page.getByRole('button').element()) as HTMLButtonElement;
// The label is exposed via aria-label only when showLabel=false.
expect(btn.getAttribute('aria-label')).toBe('Zurück');
expect(btn.textContent?.trim()).toBe('');
});
it('does not set an aria-label when the visible label is shown', async () => {
render(BackButton, { props: { showLabel: true } });
const btn = (await page.getByRole('button').element()) as HTMLButtonElement;
expect(btn.getAttribute('aria-label')).toBeNull();
});
it('applies the supplied class string to the button', async () => {
render(BackButton, { props: { class: 'custom-class' } });
const btn = (await page.getByRole('button').element()) as HTMLButtonElement;
expect(btn.classList.contains('custom-class')).toBe(true);
});
it('calls history.back() when clicked', async () => {
const backSpy = vi.spyOn(globalThis.history, 'back').mockImplementation(() => {});
try {
render(BackButton, { props: {} });
await page.getByRole('button').click();
expect(backSpy).toHaveBeenCalledOnce();
} finally {
backSpy.mockRestore();
}
});
});

View File

@@ -1,34 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import DistributionBar from './DistributionBar.svelte';
afterEach(cleanup);
describe('DistributionBar — total=0 branch', () => {
it('shows 0% / 0% widths when both counts are zero (avoids NaN)', async () => {
render(DistributionBar, {
outCount: 0,
inCount: 0,
senderName: 'Anna',
receiverName: 'Bert'
});
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
expect(segments).toHaveLength(2);
expect((segments[0] as HTMLElement).style.width).toBe('0%');
expect((segments[1] as HTMLElement).style.width).toBe('100%');
});
it('shows 100% / 0% widths when only outCount is non-zero', async () => {
render(DistributionBar, {
outCount: 5,
inCount: 0,
senderName: 'Anna',
receiverName: 'Bert'
});
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
expect((segments[0] as HTMLElement).style.width).toBe('100%');
expect((segments[1] as HTMLElement).style.width).toBe('0%');
});
});

View File

@@ -1,57 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ExpandableText from './ExpandableText.svelte';
afterEach(cleanup);
const longText = Array.from({ length: 60 }, (_, i) => `Zeile ${i + 1}.`).join('\n');
const shortText = 'Zeile 1';
describe('ExpandableText', () => {
it('renders the supplied text inside the clamped block', async () => {
render(ExpandableText, { props: { text: shortText, maxLines: 2 } });
await expect.element(page.getByText('Zeile 1')).toBeVisible();
});
it('does not show a toggle button when the content fits inside maxLines', async () => {
render(ExpandableText, { props: { text: shortText, maxLines: 100 } });
await expect
.element(page.getByRole('button', { name: /mehr anzeigen/i }))
.not.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /weniger anzeigen/i }))
.not.toBeInTheDocument();
});
it('shows the "Mehr anzeigen" button when the content overflows the line clamp', async () => {
render(ExpandableText, { props: { text: longText, maxLines: 2 } });
await expect.element(page.getByRole('button', { name: /mehr anzeigen/i })).toBeVisible();
});
it('switches the toggle label to "Weniger anzeigen" after expanding', async () => {
render(ExpandableText, { props: { text: longText, maxLines: 2 } });
await page.getByRole('button', { name: /mehr anzeigen/i }).click();
await expect.element(page.getByRole('button', { name: /weniger anzeigen/i })).toBeVisible();
});
it('collapses again when the toggle is clicked while expanded', async () => {
render(ExpandableText, { props: { text: longText, maxLines: 2 } });
await page.getByRole('button', { name: /mehr anzeigen/i }).click();
await page.getByRole('button', { name: /weniger anzeigen/i }).click();
await expect.element(page.getByRole('button', { name: /mehr anzeigen/i })).toBeVisible();
});
it('uses the default maxLines (10) when the prop is omitted', async () => {
render(ExpandableText, { props: { text: shortText } });
await expect.element(page.getByText('Zeile 1')).toBeVisible();
});
});

View File

@@ -1,61 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OverflowPillButton from './OverflowPillButton.svelte';
afterEach(cleanup);
const persons = [
{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'p2', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' }
];
describe('OverflowPillButton', () => {
it('renders the +N pill labelled with the count', async () => {
render(OverflowPillButton, { props: { extraCount: 3, persons } });
await expect.element(page.getByText(/\+3/)).toBeVisible();
});
it('starts with aria-expanded=false', async () => {
render(OverflowPillButton, { props: { extraCount: 2, persons } });
await expect
.element(page.getByRole('button', { name: /weitere empfänger/i }))
.toHaveAttribute('aria-expanded', 'false');
});
it('opens the dropdown when the pill is clicked', async () => {
render(OverflowPillButton, { props: { extraCount: 2, persons } });
await page.getByRole('button', { name: /weitere empfänger/i }).click();
await expect
.element(page.getByRole('button', { name: /weitere empfänger/i }))
.toHaveAttribute('aria-expanded', 'true');
});
it('renders one link per person inside the open dropdown', async () => {
render(OverflowPillButton, { props: { extraCount: 2, persons } });
await page.getByRole('button', { name: /weitere empfänger/i }).click();
await expect
.element(page.getByRole('link', { name: 'Anna Schmidt' }))
.toHaveAttribute('href', '/persons/p1');
await expect
.element(page.getByRole('link', { name: 'Bert Meier' }))
.toHaveAttribute('href', '/persons/p2');
});
it('closes the dropdown when Escape is pressed', async () => {
render(OverflowPillButton, { props: { extraCount: 2, persons } });
const btn = page.getByRole('button', { name: /weitere empfänger/i });
await btn.click();
const btnEl = (await btn.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
});
});

View File

@@ -1,28 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
afterEach(cleanup);
describe('OverflowPillDisplay', () => {
it('renders the +N count', async () => {
render(OverflowPillDisplay, { props: { extraCount: 3 } });
const span = document.querySelector('span') as HTMLElement;
expect(span.textContent?.trim()).toBe('+3');
});
it('renders +0 when extraCount is 0', async () => {
render(OverflowPillDisplay, { props: { extraCount: 0 } });
const span = document.querySelector('span') as HTMLElement;
expect(span.textContent?.trim()).toBe('+0');
});
it('marks the pill as aria-hidden (decorative)', async () => {
render(OverflowPillDisplay, { props: { extraCount: 5 } });
const span = document.querySelector('span') as HTMLElement;
expect(span.getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -1,111 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import Pagination from './Pagination.svelte';
afterEach(cleanup);
const makeHref = (p: number) => `/?page=${p}`;
describe('Pagination', () => {
it('renders nothing when totalPages is 1 or less', async () => {
render(Pagination, { props: { page: 0, totalPages: 1, makeHref } });
expect(document.querySelector('nav')).toBeNull();
});
it('renders the nav when totalPages > 1', async () => {
render(Pagination, { props: { page: 0, totalPages: 5, makeHref } });
expect(document.querySelector('nav')).not.toBeNull();
});
it('disables the prev control on the first page', async () => {
render(Pagination, { props: { page: 0, totalPages: 5, makeHref } });
const prev = document.querySelector('[data-testid="pagination-prev"]') as HTMLElement;
expect(prev.tagName).toBe('SPAN');
});
it('renders the prev control as a link when not on the first page', async () => {
render(Pagination, { props: { page: 2, totalPages: 5, makeHref } });
const prev = document.querySelector('[data-testid="pagination-prev"]') as HTMLAnchorElement;
expect(prev.tagName).toBe('A');
expect(prev.href).toContain('page=1');
});
it('disables the next control on the last page', async () => {
render(Pagination, { props: { page: 4, totalPages: 5, makeHref } });
const next = document.querySelector('[data-testid="pagination-next"]') as HTMLElement;
expect(next.tagName).toBe('SPAN');
});
it('renders the next control as a link when not on the last page', async () => {
render(Pagination, { props: { page: 0, totalPages: 5, makeHref } });
const next = document.querySelector('[data-testid="pagination-next"]') as HTMLAnchorElement;
expect(next.tagName).toBe('A');
expect(next.href).toContain('page=1');
});
it('marks the active page button with aria-current=page', async () => {
render(Pagination, { props: { page: 2, totalPages: 5, makeHref } });
const active = document.querySelector('[data-testid="pagination-page-3"]') as HTMLElement;
expect(active.getAttribute('aria-current')).toBe('page');
});
it('renders the mobile page label', async () => {
render(Pagination, { props: { page: 1, totalPages: 5, makeHref } });
const label = document.querySelector('[data-testid="pagination-page-label"]');
expect(label?.textContent).toContain('2');
expect(label?.textContent).toContain('5');
});
it('renders left ellipsis when current page is far enough from page 1', async () => {
render(Pagination, { props: { page: 7, totalPages: 10, makeHref } });
expect(document.querySelector('[data-testid="pagination-ellipsis-left"]')).not.toBeNull();
});
it('renders right ellipsis when current page is far from last', async () => {
render(Pagination, { props: { page: 1, totalPages: 10, makeHref } });
expect(document.querySelector('[data-testid="pagination-ellipsis-right"]')).not.toBeNull();
});
it('uses the supplied ariaLabel when provided', async () => {
render(Pagination, {
props: { page: 0, totalPages: 5, makeHref, ariaLabel: 'Custom pagination' }
});
const nav = document.querySelector('nav');
expect(nav?.getAttribute('aria-label')).toBe('Custom pagination');
});
it('renders the bridge page (no ellipsis) when window is exactly 2 pages from start', async () => {
// page=3 (1-indexed=4), totalPages=10 → windowStart=3, first+2=3 → bridge to page 2
render(Pagination, { props: { page: 3, totalPages: 10, makeHref } });
// Should have page 2 directly, not an ellipsis
const ellipsisLeft = document.querySelector('[data-testid="pagination-ellipsis-left"]');
expect(ellipsisLeft).toBeNull();
});
it('renders the bridge page (no ellipsis) when window is exactly 2 pages from end', async () => {
// page=6 (1-indexed=7), totalPages=10 → windowEnd=8, last-2=8 → bridge to page 9
render(Pagination, { props: { page: 6, totalPages: 10, makeHref } });
// Should have page 9 directly, not an ellipsis on the right
const ellipsisRight = document.querySelector('[data-testid="pagination-ellipsis-right"]');
expect(ellipsisRight).toBeNull();
});
it('returns no result when totalPages is 0', async () => {
render(Pagination, { props: { page: 0, totalPages: 0, makeHref } });
expect(document.querySelector('nav')).toBeNull();
});
});

View File

@@ -1,29 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UnsavedWarningBanner from './UnsavedWarningBanner.svelte';
afterEach(cleanup);
describe('UnsavedWarningBanner', () => {
it('renders the warning text', async () => {
render(UnsavedWarningBanner, { props: { onDiscard: () => {} } });
await expect.element(page.getByText(/ungespeicherte änderungen/i)).toBeVisible();
});
it('renders the discard action button', async () => {
render(UnsavedWarningBanner, { props: { onDiscard: () => {} } });
await expect.element(page.getByRole('button', { name: /verwerfen/i })).toBeVisible();
});
it('calls onDiscard when the discard button is clicked', async () => {
const onDiscard = vi.fn();
render(UnsavedWarningBanner, { props: { onDiscard } });
await page.getByRole('button', { name: /verwerfen/i }).click();
expect(onDiscard).toHaveBeenCalledOnce();
});
});

View File

@@ -199,125 +199,4 @@ describe('TagParentPicker parent name subtitle', () => {
// Only the tag name should appear (no subtitle)
await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument();
});
it('shows the parentId as subtitle when allTags omits the parent', async () => {
mockFetchWithTags([{ id: 't2', name: 'Keller', parentId: 'unknown-parent-id' }]);
render(TagParentPicker, { name: 'parentId', allTags: [] });
const input = page.getByRole('combobox');
await input.fill('K');
await vi.advanceTimersByTimeAsync(300);
// When parent not found in allTags, fallback shows the parentId itself
await expect.element(page.getByText('unknown-parent-id')).toBeInTheDocument();
});
});
describe('TagParentPicker keyboard navigation', () => {
it('ArrowUp wraps around to last option', async () => {
mockFetchWithTags([
{ id: 't1', name: 'Haus' },
{ id: 't2', name: 'Garten' }
]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('a');
await vi.advanceTimersByTimeAsync(300);
const el = await input.element();
// Without prior arrow-down, ArrowUp from -1 wraps via modular arithmetic
el.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })
);
await vi.advanceTimersByTimeAsync(0);
expect(el.getAttribute('aria-activedescendant')).toBeTruthy();
});
it('Escape closes the dropdown', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
const el = await input.element();
el.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
);
await vi.advanceTimersByTimeAsync(0);
// Listbox should be gone
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('Enter without active selection does nothing', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
const el = await input.element();
el.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
await vi.advanceTimersByTimeAsync(0);
// Hidden input should still be empty
expect(hiddenInput('parentId')?.value).toBe('');
});
it('keydown with no active dropdown is a no-op', async () => {
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
const el = await input.element();
expect(() =>
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
).not.toThrow();
});
it('Enter with active selection selects the highlighted tag', async () => {
mockFetchWithTags([
{ id: 't1', name: 'Haus' },
{ id: 't2', name: 'Garten' }
]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('a');
await vi.advanceTimersByTimeAsync(300);
const el = await input.element();
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await vi.advanceTimersByTimeAsync(0);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await vi.advanceTimersByTimeAsync(0);
// Some tag selected
expect(hiddenInput('parentId')?.value).toBeTruthy();
});
});
describe('TagParentPicker excludeIds filter', () => {
it('filters out tags whose id is in excludeIds', async () => {
mockFetchWithTags([
{ id: 't1', name: 'Haus' },
{ id: 't2', name: 'Keller' }
]);
render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] });
const input = page.getByRole('combobox');
await input.fill('a');
await vi.advanceTimersByTimeAsync(300);
// Only Keller should be visible
const options = document.querySelectorAll('[role="option"]');
expect(options.length).toBe(1);
expect(options[0].textContent).toContain('Keller');
});
});

View File

@@ -1,54 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import UserGroupsSection from './UserGroupsSection.svelte';
afterEach(cleanup);
const groups = [
{ id: 'g1', name: 'Familie' },
{ id: 'g2', name: 'Admins' },
{ id: 'g3', name: 'Lesetransport' }
];
describe('UserGroupsSection', () => {
it('renders one checkbox per group', async () => {
render(UserGroupsSection, { props: { groups } });
const checkboxes = document.querySelectorAll('input[name="groupIds"]');
expect(checkboxes.length).toBe(3);
});
it('renders each group label', async () => {
render(UserGroupsSection, { props: { groups } });
expect(document.body.textContent).toContain('Familie');
expect(document.body.textContent).toContain('Admins');
expect(document.body.textContent).toContain('Lesetransport');
});
it('preselects checkboxes for ids in selectedGroupIds', async () => {
render(UserGroupsSection, { props: { groups, selectedGroupIds: ['g1', 'g3'] } });
const checkboxes = Array.from(
document.querySelectorAll('input[name="groupIds"]')
) as HTMLInputElement[];
const checkedValues = checkboxes.filter((c) => c.checked).map((c) => c.value);
expect(checkedValues.sort()).toEqual(['g1', 'g3']);
});
it('renders nothing when groups is empty', async () => {
render(UserGroupsSection, { props: { groups: [] } });
const checkboxes = document.querySelectorAll('input[name="groupIds"]');
expect(checkboxes.length).toBe(0);
});
it('handles a missing selectedGroupIds prop by defaulting to none selected', async () => {
render(UserGroupsSection, { props: { groups } });
const checkboxes = Array.from(
document.querySelectorAll('input[name="groupIds"]')
) as HTMLInputElement[];
expect(checkboxes.every((c) => !c.checked)).toBe(true);
});
});

View File

@@ -1,43 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UserPasswordSection from './UserPasswordSection.svelte';
afterEach(cleanup);
describe('UserPasswordSection', () => {
it('renders both password labels', async () => {
render(UserPasswordSection, { props: {} });
await expect.element(page.getByLabelText('Neues Passwort (Wiederholung)')).toBeVisible();
const inputs = document.querySelectorAll('input[type="password"]');
expect(inputs.length).toBe(2);
});
it('exposes both inputs as type=password', async () => {
render(UserPasswordSection, { props: {} });
const newPwd = document.querySelector('input[name="newPassword"]') as HTMLInputElement;
const confirm = document.querySelector('input[name="confirmPassword"]') as HTMLInputElement;
expect(newPwd.type).toBe('password');
expect(confirm.type).toBe('password');
});
it('marks both inputs as required when required prop is true', async () => {
render(UserPasswordSection, { props: { required: true } });
const newPwd = document.querySelector('input[name="newPassword"]') as HTMLInputElement;
const confirm = document.querySelector('input[name="confirmPassword"]') as HTMLInputElement;
expect(newPwd.required).toBe(true);
expect(confirm.required).toBe(true);
});
it('leaves both inputs optional when required is false (default)', async () => {
render(UserPasswordSection, { props: {} });
const newPwd = document.querySelector('input[name="newPassword"]') as HTMLInputElement;
const confirm = document.querySelector('input[name="confirmPassword"]') as HTMLInputElement;
expect(newPwd.required).toBe(false);
expect(confirm.required).toBe(false);
});
});

View File

@@ -1,65 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import UserProfileSection from './UserProfileSection.svelte';
afterEach(cleanup);
describe('UserProfileSection', () => {
it('renders all four input fields by default', async () => {
render(UserProfileSection, { props: {} });
expect(document.querySelector('input[name="firstName"]')).not.toBeNull();
expect(document.querySelector('input[name="lastName"]')).not.toBeNull();
expect(document.querySelector('input[name="email"]')).not.toBeNull();
expect(document.querySelector('input[name="birthDate"]')).not.toBeNull();
});
it('hydrates inputs from props when provided', async () => {
render(UserProfileSection, {
props: {
firstName: 'Anna',
lastName: 'Schmidt',
email: 'anna@example.com',
contact: 'Telefon 123'
}
});
const first = document.querySelector('input[name="firstName"]') as HTMLInputElement;
const last = document.querySelector('input[name="lastName"]') as HTMLInputElement;
const email = document.querySelector('input[name="email"]') as HTMLInputElement;
expect(first.value).toBe('Anna');
expect(last.value).toBe('Schmidt');
expect(email.value).toBe('anna@example.com');
});
it('converts the birthDate ISO value to German display format', async () => {
render(UserProfileSection, { props: { birthDate: '1923-04-15' } });
const dateInputs = document.querySelectorAll('input[type="text"][placeholder*="TT"]');
expect((dateInputs[0] as HTMLInputElement).value).toBe('15.04.1923');
});
it('renders the hidden ISO birthDate input', async () => {
render(UserProfileSection, { props: { birthDate: '1923-04-15' } });
const hidden = document.querySelector('input[name="birthDate"]') as HTMLInputElement;
expect(hidden.type).toBe('hidden');
expect(hidden.value).toBe('1923-04-15');
});
it('hydrates the contact textarea from prop', async () => {
render(UserProfileSection, { props: { contact: 'Telefon: 030-12345' } });
const textarea = document.querySelector('textarea[name="contact"]') as HTMLTextAreaElement;
expect(textarea.value).toBe('Telefon: 030-12345');
});
it('renders empty values when no props are supplied', async () => {
render(UserProfileSection, { props: {} });
const first = document.querySelector('input[name="firstName"]') as HTMLInputElement;
const email = document.querySelector('input[name="email"]') as HTMLInputElement;
expect(first.value).toBe('');
expect(email.value).toBe('');
});
});

View File

@@ -1,155 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page as browserPage } from 'vitest/browser';
const mockPage = { url: new URL('http://localhost/documents') };
vi.mock('$app/state', () => ({
get page() {
return mockPage;
}
}));
afterEach(cleanup);
async function loadComponent() {
return (await import('./AppNav.svelte')).default;
}
describe('AppNav', () => {
it('renders the brand link', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await expect.element(browserPage.getByRole('link', { name: /familienarchiv/i })).toBeVisible();
});
it('renders all four primary nav links by default', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await expect.element(browserPage.getByRole('link', { name: /^dokumente$/i })).toBeVisible();
await expect.element(browserPage.getByRole('link', { name: /^personen$/i })).toBeVisible();
await expect.element(browserPage.getByRole('link', { name: /^stammbaum$/i })).toBeVisible();
await expect.element(browserPage.getByRole('link', { name: /^geschichten$/i })).toBeVisible();
});
it('hides the admin link when isAdmin is false', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await expect
.element(browserPage.getByRole('link', { name: /^admin$/i }))
.not.toBeInTheDocument();
});
it('shows the admin link when isAdmin is true', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: true } });
await expect.element(browserPage.getByRole('link', { name: /^admin$/i })).toBeVisible();
});
it('shows the open-menu hamburger by default', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await expect
.element(browserPage.getByRole('button', { name: /menü öffnen/i }))
.toHaveAttribute('aria-expanded', 'false');
});
it('opens the mobile nav and switches the hamburger label when clicked', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await browserPage.getByRole('button', { name: /menü öffnen/i }).click();
await expect
.element(browserPage.getByRole('button', { name: /menü schließen/i }))
.toHaveAttribute('aria-expanded', 'true');
});
it('marks the documents link as active when pathname starts with /documents', async () => {
mockPage.url = new URL('http://localhost/documents/abc');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
const link = Array.from(document.querySelectorAll('nav a[href="/documents"]'))[0];
expect(link?.className).toContain('border-accent');
});
it('marks the persons link as active when pathname starts with /persons', async () => {
mockPage.url = new URL('http://localhost/persons/123');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
const link = Array.from(document.querySelectorAll('nav a[href="/persons"]'))[0];
expect(link?.className).toContain('border-accent');
});
it('marks the stammbaum link as active when pathname starts with /stammbaum', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
const link = Array.from(document.querySelectorAll('nav a[href="/stammbaum"]'))[0];
expect(link?.className).toContain('border-accent');
});
it('marks the geschichten link as active when pathname starts with /geschichten', async () => {
mockPage.url = new URL('http://localhost/geschichten/x');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
const link = Array.from(document.querySelectorAll('nav a[href="/geschichten"]'))[0];
expect(link?.className).toContain('border-accent');
});
it('marks the admin link as active when pathname starts with /admin and isAdmin is true', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: true } });
const link = Array.from(document.querySelectorAll('nav a[href="/admin"]'))[0];
expect(link?.className).toContain('border-accent');
});
it('closes the mobile nav when the backdrop is clicked', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await browserPage.getByRole('button', { name: /menü öffnen/i }).click();
// Mobile nav is now open
const backdrop = document.querySelector('.bg-black\\/20') as HTMLElement;
expect(backdrop).not.toBeNull();
backdrop.click();
await expect
.element(browserPage.getByRole('button', { name: /menü öffnen/i }))
.toHaveAttribute('aria-expanded', 'false');
});
it('closes the mobile nav when Escape is pressed on the overlay', async () => {
mockPage.url = new URL('http://localhost/');
const AppNav = await loadComponent();
render(AppNav, { props: { isAdmin: false } });
await browserPage.getByRole('button', { name: /menü öffnen/i }).click();
const overlay = document.querySelector('.fixed.inset-0') as HTMLElement;
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect
.element(browserPage.getByRole('button', { name: /menü öffnen/i }))
.toHaveAttribute('aria-expanded', 'false');
});
});

View File

@@ -1,22 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AuthHeader from './AuthHeader.svelte';
afterEach(cleanup);
describe('AuthHeader', () => {
it('renders the brand link to /', async () => {
render(AuthHeader, { props: {} });
await expect
.element(page.getByRole('link', { name: /familienarchiv/i }))
.toHaveAttribute('href', '/');
});
it('renders the brand wordmark', async () => {
render(AuthHeader, { props: {} });
await expect.element(page.getByText('Familienarchiv')).toBeVisible();
});
});

View File

@@ -1,129 +0,0 @@
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: DocumentList } = await import('./DocumentList.svelte');
afterEach(cleanup);
const sender = { id: 's1', displayName: 'Anna Schmidt' };
const receiver = { id: 'r1', displayName: 'Bert Meier' };
const makeItem = (overrides: Record<string, unknown> = {}) => ({
document: {
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
},
matchData: null,
completionPercentage: 0,
contributors: []
});
describe('DocumentList', () => {
it('renders the empty state when items is empty', async () => {
render(DocumentList, { props: { items: [], canWrite: false } });
await expect
.element(page.getByRole('heading', { name: /keine dokumente gefunden/i }))
.toBeVisible();
});
it('renders the term-specific empty message when q is set', async () => {
render(DocumentList, { props: { items: [], canWrite: false, q: 'Helene' } });
await expect.element(page.getByText(/Keine Dokumente für "Helene"/i)).toBeVisible();
});
it('renders the error banner when error prop is set', async () => {
render(DocumentList, {
props: { items: [], canWrite: false, error: 'Server unreachable' }
});
await expect.element(page.getByText('Server unreachable')).toBeVisible();
});
it('renders one group card per year by default', async () => {
render(DocumentList, {
props: {
items: [
makeItem({ id: 'd1', documentDate: '1923-04-15' }),
makeItem({ id: 'd2', documentDate: '1925-06-20' })
],
canWrite: false
}
});
const groups = document.querySelectorAll('[data-testid="group-card"]');
expect(groups.length).toBe(2);
});
it('groups by sender when sort=SENDER', async () => {
render(DocumentList, {
props: {
items: [
makeItem({ id: 'd1', sender: { id: 's1', displayName: 'Anna Schmidt' } }),
makeItem({ id: 'd2', sender: { id: 's2', displayName: 'Bert Meier' } })
],
canWrite: false,
sort: 'SENDER' as const
}
});
const groupHeaders = document.querySelectorAll('[data-testid="group-header"]');
expect(groupHeaders.length).toBe(2);
});
it('uses "Undatiert" group label for items without documentDate', async () => {
render(DocumentList, {
props: { items: [makeItem({ documentDate: null })], canWrite: false }
});
await expect.element(page.getByText('Undatiert')).toBeVisible();
});
it('uses "Unbekannter Absender" when sort=SENDER and no sender', async () => {
render(DocumentList, {
props: {
items: [makeItem({ sender: null })],
canWrite: false,
sort: 'SENDER' as const
}
});
await expect.element(page.getByText('Unbekannter Absender')).toBeVisible();
});
it('renders the result count when total is provided and > 0', async () => {
render(DocumentList, {
props: { items: [makeItem()], canWrite: false, total: 42 }
});
await expect.element(page.getByText('42 Dokumente')).toBeVisible();
});
});

View File

@@ -1,209 +0,0 @@
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: DropZone } = await import('./DropZone.svelte');
afterEach(cleanup);
describe('DropZone', () => {
it('renders the drop hint and accepted types by default', async () => {
render(DropZone, { props: {} });
await expect.element(page.getByText(/einzeln oder mehrere/i)).toBeVisible();
await expect.element(page.getByText('PDF, JPEG, PNG, TIFF')).toBeVisible();
});
it('does not render the progress bar by default', async () => {
render(DropZone, { props: {} });
expect(document.querySelector('.bg-primary.h-full')).toBeNull();
});
it('rejects files with unaccepted MIME types and shows an error message', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const badFile = new File(['bad'], 'doc.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
Object.defineProperty(input, 'files', { value: [badFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
await expect.element(page.getByText(/Dateiformat nicht unterstützt/i)).toBeVisible();
});
it('accepts a PDF file as a valid type and renders no "invalid type" message', async () => {
const onUploadComplete = vi.fn();
render(DropZone, { props: { onUploadComplete } });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const pdfFile = new File(['%PDF'], 'brief.pdf', { type: 'application/pdf' });
Object.defineProperty(input, 'files', { value: [pdfFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
// The validation guard never raises an invalid-type error for application/pdf.
expect(document.body.textContent).not.toMatch(/Dateiformat nicht unterstützt/i);
});
it('returns early when no files are selected', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', { value: [], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
const errors = document.querySelectorAll('.text-red-600');
expect(errors.length).toBe(0);
});
it('opens the file input when the drop zone is clicked', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
dropZone.click();
expect(clickSpy).toHaveBeenCalled();
});
it('opens the file input when Enter is pressed on the drop zone', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
dropZone.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(clickSpy).toHaveBeenCalled();
});
it('exposes file input as multi-file with accept whitelist', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.multiple).toBe(true);
expect(input.accept).toContain('.pdf');
});
it('applies the dragging style on dragover', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
dropZone.dispatchEvent(new DragEvent('dragover', { bubbles: true }));
// isDragging=true switches the class to border-primary bg-accent-bg — wait for the next paint.
await vi.waitFor(() => {
expect(dropZone.className).toMatch(/bg-accent-bg/);
});
});
it('drops dragging style on dragleave', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
dropZone.dispatchEvent(new DragEvent('dragover', { bubbles: true }));
await vi.waitFor(() => {
expect(dropZone.className).toMatch(/bg-accent-bg/);
});
dropZone.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
await vi.waitFor(() => {
expect(dropZone.className).not.toMatch(/bg-accent-bg/);
});
});
it('drop event with no files is a no-op (no error message)', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const dropEvent = new DragEvent('drop', { bubbles: true });
Object.defineProperty(dropEvent, 'dataTransfer', { value: { files: [] }, writable: false });
dropZone.dispatchEvent(dropEvent);
expect(document.querySelectorAll('.text-red-600')).toHaveLength(0);
});
it('rejects multiple invalid files and lists one error message per file', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const f1 = new File(['x'], 'a.docx', { type: 'application/x-bad' });
const f2 = new File(['y'], 'b.txt', { type: 'text/plain' });
Object.defineProperty(input, 'files', { value: [f1, f2], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelectorAll('.text-red-600')).toHaveLength(2);
});
});
it('mixed valid+invalid files raises an error only for the invalid one', async () => {
render(DropZone, { props: {} });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const f1 = new File(['x'], 'a.docx', { type: 'application/x-bad' });
const f2 = new File(['%PDF'], 'b.pdf', { type: 'application/pdf' });
Object.defineProperty(input, 'files', { value: [f1, f2], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
await expect.element(page.getByText(/Dateiformat nicht unterstützt/i)).toBeVisible();
});
it('Enter handler ignores other keys', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
dropZone.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
expect(clickSpy).not.toHaveBeenCalled();
});
it('responds to window-level dragenter only when dataTransfer.types includes "Files"', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
// Non-files dragenter should not trigger the windowDragging style.
const evt1 = new DragEvent('dragenter', { bubbles: true });
Object.defineProperty(evt1, 'dataTransfer', { value: { types: ['text/html'] } });
window.dispatchEvent(evt1);
expect(dropZone.className).not.toMatch(/bg-accent-bg/);
// Files dragenter flips windowDragging=true → drop-zone gains the border-primary style.
const evt2 = new DragEvent('dragenter', { bubbles: true });
Object.defineProperty(evt2, 'dataTransfer', { value: { types: ['Files'] } });
window.dispatchEvent(evt2);
await vi.waitFor(() => {
expect(dropZone.className).toMatch(/bg-accent-bg/);
});
// Trailing window drop to clean up.
window.dispatchEvent(new DragEvent('drop', { bubbles: true }));
});
it('window-level dragleave without prior dragenter is safe (counter does not go negative)', async () => {
render(DropZone, { props: {} });
const dropZone = document.querySelector('div[role="button"]') as HTMLElement;
// Two consecutive dragleaves on the window without dragenters should leave the drop-zone
// in its idle (non-highlighted) state.
window.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
window.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
expect(dropZone.className).not.toMatch(/bg-accent-bg/);
});
});

View File

@@ -1,59 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UserMenu from './UserMenu.svelte';
afterEach(cleanup);
describe('UserMenu', () => {
it('renders the avatar button when userInitials is set', async () => {
render(UserMenu, { props: { userInitials: 'AS' } });
await expect.element(page.getByRole('button', { name: 'AS' })).toBeVisible();
});
it('renders the icon-only button when userInitials is null', async () => {
render(UserMenu, { props: { userInitials: null } });
await expect.element(page.getByRole('button', { name: /profil/i })).toBeVisible();
});
it('starts with the menu closed (aria-expanded=false)', async () => {
render(UserMenu, { props: { userInitials: 'AS' } });
await expect
.element(page.getByRole('button', { name: 'AS' }))
.toHaveAttribute('aria-expanded', 'false');
});
it('opens the menu when the trigger is clicked', async () => {
render(UserMenu, { props: { userInitials: 'AS' } });
await page.getByRole('button', { name: 'AS' }).click();
await expect
.element(page.getByRole('button', { name: 'AS' }))
.toHaveAttribute('aria-expanded', 'true');
});
it('renders the profile link and logout button when the menu is open', async () => {
render(UserMenu, { props: { userInitials: 'AS' } });
await page.getByRole('button', { name: 'AS' }).click();
await expect
.element(page.getByRole('link', { name: /profil/i }))
.toHaveAttribute('href', '/profile');
await expect.element(page.getByRole('button', { name: /abmelden/i })).toBeVisible();
});
it('declares POST and /logout on the logout form', async () => {
render(UserMenu, { props: { userInitials: 'AS' } });
await page.getByRole('button', { name: 'AS' }).click();
const form = document.querySelector('form[action="/logout"]') as HTMLFormElement;
expect(form).not.toBeNull();
expect(form.method.toLowerCase()).toBe('post');
});
});

View File

@@ -1,148 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page as browserPage } from 'vitest/browser';
const mockPage = { url: new URL('http://localhost/admin/users') };
vi.mock('$app/state', () => ({
get page() {
return mockPage;
}
}));
afterEach(cleanup);
async function loadComponent() {
return (await import('./EntityNav.svelte')).default;
}
const baseProps = (overrides: Record<string, unknown> = {}) => ({
userCount: 5,
groupCount: 3,
tagCount: 12,
inviteCount: 1,
canManageUsers: true,
canManageTags: true,
canManagePermissions: true,
canRunMaintenance: true,
...overrides
});
describe('EntityNav', () => {
it('renders all sections when all permissions are granted', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps() });
const links = document.querySelectorAll('a[href^="/admin/"]');
// Sidebar renders: users, groups, invites, tags, system, ocr — 6 links
expect(links.length).toBeGreaterThanOrEqual(6);
});
it('hides users / invites links when canManageUsers is false', async () => {
mockPage.url = new URL('http://localhost/admin/groups');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps({ canManageUsers: false }) });
const userLinks = document.querySelectorAll('a[href="/admin/users"]');
const inviteLinks = document.querySelectorAll('a[href="/admin/invites"]');
expect(userLinks.length).toBe(0);
expect(inviteLinks.length).toBe(0);
});
it('hides the groups link when canManagePermissions is false', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps({ canManagePermissions: false }) });
const groupLinks = document.querySelectorAll('a[href="/admin/groups"]');
expect(groupLinks.length).toBe(0);
});
it('hides the tags link when canManageTags is false', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps({ canManageTags: false }) });
const tagLinks = document.querySelectorAll('a[href="/admin/tags"]');
expect(tagLinks.length).toBe(0);
});
it('hides the system and ocr links when canRunMaintenance is false', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps({ canRunMaintenance: false }) });
const systemLinks = document.querySelectorAll('a[href="/admin/system"]');
const ocrLinks = document.querySelectorAll('a[href="/admin/ocr"]');
expect(systemLinks.length).toBe(0);
expect(ocrLinks.length).toBe(0);
});
it('does not render the flyout panel by default', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps() });
await expect.element(browserPage.getByRole('dialog')).not.toBeInTheDocument();
});
it('marks the active section with brand-mint icon color', async () => {
mockPage.url = new URL('http://localhost/admin/groups/abc');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps() });
// At least one icon SVG should have the brand-mint class
const mintIcons = document.querySelectorAll('svg.text-brand-mint');
expect(mintIcons.length).toBeGreaterThan(0);
});
it('opens the flyout dialog when a tablet section trigger is clicked', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps() });
// The first <button> in the DOM is the tablet trigger of the first section.
const triggerButton = document.querySelector('button') as HTMLButtonElement;
expect(triggerButton).not.toBeNull();
triggerButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
});
it('Escape closes the open flyout', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps() });
// Open the flyout first.
(document.querySelector('button') as HTMLButtonElement).click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
// Then Escape should close it (handler is bound via svelte:document).
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
});
it('renders the user count badge on the users link', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps({ userCount: 42 }) });
expect(document.body.textContent).toContain('42');
});
it('renders the invite count badge on the invites link', async () => {
mockPage.url = new URL('http://localhost/admin/users');
const EntityNav = await loadComponent();
render(EntityNav, { props: baseProps({ inviteCount: 7 }) });
expect(document.body.textContent).toContain('7');
});
});

View File

@@ -1,109 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page as browserPage } from 'vitest/browser';
const mockPage = { url: new URL('http://localhost/admin/groups') };
vi.mock('$app/state', () => ({
get page() {
return mockPage;
}
}));
beforeEach(() => {
localStorage.clear();
});
afterEach(cleanup);
async function loadComponent() {
return (await import('./GroupsListPanel.svelte')).default;
}
const baseGroups = [
{ id: 'g1', name: 'Familie', permissions: ['READ_ALL'] },
{ id: 'g2', name: 'Admins', permissions: ['ADMIN', 'WRITE_ALL'] }
];
describe('GroupsListPanel', () => {
it('renders the expanded list with header by default', async () => {
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups } });
await expect.element(browserPage.getByText('Alle Gruppen')).toBeVisible();
});
it('renders one row per group with permission count', async () => {
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups } });
await expect.element(browserPage.getByText('Familie')).toBeVisible();
await expect.element(browserPage.getByText('Admins')).toBeVisible();
await expect.element(browserPage.getByText('1 Berechtigungen')).toBeVisible();
await expect.element(browserPage.getByText('2 Berechtigungen')).toBeVisible();
});
it('renders the empty placeholder when groups is empty', async () => {
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: [] } });
await expect.element(browserPage.getByText('Keine Gruppen vorhanden.')).toBeVisible();
});
it('renders the new-group link to /admin/groups/new', async () => {
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups } });
await expect
.element(browserPage.getByRole('link', { name: /neue gruppe/i }))
.toHaveAttribute('href', '/admin/groups/new');
});
it('marks the active group with aria-current=page', async () => {
mockPage.url = new URL('http://localhost/admin/groups/g2');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups } });
const links = Array.from(
document.querySelectorAll('a[href^="/admin/groups/"]')
) as HTMLAnchorElement[];
const adminsLink = links.find((a) => a.href.endsWith('/admin/groups/g2'));
expect(adminsLink?.getAttribute('aria-current')).toBe('page');
});
it('renders collapsed view when autocollapse is true', async () => {
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups, autocollapse: true } });
await expect
.element(browserPage.getByRole('button', { name: /liste ausklappen/i }))
.toBeVisible();
});
it('honours the localStorage manual-collapse preference', async () => {
localStorage.setItem('admin_groups_list_collapsed', 'true');
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups } });
await expect
.element(browserPage.getByRole('button', { name: /liste ausklappen/i }))
.toBeVisible();
});
it('expands the panel when the collapsed handle is clicked', async () => {
localStorage.setItem('admin_groups_list_collapsed', 'true');
mockPage.url = new URL('http://localhost/admin/groups');
const Panel = await loadComponent();
render(Panel, { props: { groups: baseGroups } });
await browserPage.getByRole('button', { name: /liste ausklappen/i }).click();
await expect.element(browserPage.getByText('Alle Gruppen')).toBeVisible();
});
});

View File

@@ -1,125 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
const { default: AdminGroupEditPage } = await import('./+page.svelte');
afterEach(cleanup);
const baseGroup = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
name: 'Familie',
permissions: ['READ_ALL'] as string[],
...overrides
});
describe('admin/groups/[id] page', () => {
it('renders the edit heading with the group name', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
await expect.element(page.getByRole('heading', { name: /familie/i })).toBeVisible();
});
it('hydrates the name input from data.group.name', async () => {
render(AdminGroupEditPage, {
props: { data: { group: baseGroup({ name: 'Admins' }) }, form: undefined }
});
const input = document.querySelector('input[name="name"]') as HTMLInputElement;
expect(input.value).toBe('Admins');
});
it('checks the permission checkboxes that are in data.group.permissions', async () => {
render(AdminGroupEditPage, {
props: {
data: { group: baseGroup({ permissions: ['READ_ALL', 'ADMIN_TAG'] }) },
form: undefined
}
});
const readAll = document.querySelector(
'input[name="permissions"][value="READ_ALL"]'
) as HTMLInputElement;
const adminTag = document.querySelector(
'input[name="permissions"][value="ADMIN_TAG"]'
) as HTMLInputElement;
const writeAll = document.querySelector(
'input[name="permissions"][value="WRITE_ALL"]'
) as HTMLInputElement;
expect(readAll.checked).toBe(true);
expect(adminTag.checked).toBe(true);
expect(writeAll.checked).toBe(false);
});
it('shows the success banner when form.success is true', async () => {
render(AdminGroupEditPage, {
props: { data: { group: baseGroup() }, form: { success: true } }
});
await expect.element(page.getByText('Gruppe gespeichert.')).toBeVisible();
});
it('shows the error banner when form.error is set', async () => {
render(AdminGroupEditPage, {
props: {
data: { group: baseGroup() },
form: { error: 'Name darf nicht leer sein.' }
}
});
await expect.element(page.getByText('Name darf nicht leer sein.')).toBeVisible();
});
it('renders the cancel link to /admin/groups', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
const links = document.querySelectorAll('a[href="/admin/groups"]');
expect(links.length).toBeGreaterThan(0);
});
it('renders the delete and save buttons', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /speichern/i })).toBeVisible();
});
it('does not render success banner when form is undefined', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
const banner = document.querySelector('.bg-green-50');
expect(banner).toBeNull();
});
it('does not render error-banner div when form.success is true (success path only)', async () => {
render(AdminGroupEditPage, {
props: { data: { group: baseGroup() }, form: { success: true } }
});
// Error banner is the <div> with bg-red-50 — the delete button is also red but is a button
const errorBanner = document.querySelector('div.bg-red-50');
expect(errorBanner).toBeNull();
});
it('renders all 8 permission checkboxes (4 standard + 4 admin)', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
const checkboxes = document.querySelectorAll('input[name="permissions"]');
expect(checkboxes.length).toBe(8);
});
it('handles a group with empty permissions array', async () => {
render(AdminGroupEditPage, {
props: { data: { group: baseGroup({ permissions: [] }) }, form: undefined }
});
const checkboxes = Array.from(
document.querySelectorAll('input[name="permissions"]')
) as HTMLInputElement[];
expect(checkboxes.every((c) => !c.checked)).toBe(true);
});
});

View File

@@ -1,109 +0,0 @@
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: AdminGroupNewPage } = await import('./+page.svelte');
afterEach(cleanup);
describe('admin/groups/new page', () => {
it('renders the page heading', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
await expect.element(page.getByRole('heading', { name: /neue gruppe anlegen/i })).toBeVisible();
});
it('renders all four standard permission checkboxes', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const standardPerms = ['READ_ALL', 'ANNOTATE_ALL', 'WRITE_ALL', 'BLOG_WRITE'];
for (const perm of standardPerms) {
const checkbox = document.querySelector(`input[name="permissions"][value="${perm}"]`);
expect(checkbox).not.toBeNull();
}
});
it('renders all four administrative permission checkboxes', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const adminPerms = ['ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
for (const perm of adminPerms) {
const checkbox = document.querySelector(`input[name="permissions"][value="${perm}"]`);
expect(checkbox).not.toBeNull();
}
});
it('shows the form error banner when form.error is set', async () => {
render(AdminGroupNewPage, { props: { form: { error: 'Name is required' } } });
await expect.element(page.getByText('Name is required')).toBeVisible();
});
it('does not show the form error banner when form is undefined', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
await expect.element(page.getByText(/^Name is required$/)).not.toBeInTheDocument();
});
it('renders cancel link to /admin/groups', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const cancelLink = document.querySelector('a[href="/admin/groups"]');
expect(cancelLink).not.toBeNull();
});
it('renders the submit button labelled "Erstellen" tied to the form via the form attribute', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const submit = (await page
.getByRole('button', { name: /erstellen/i })
.element()) as HTMLButtonElement;
expect(submit.getAttribute('form')).toBe('new-group-form');
});
it('renders the name input with placeholder text', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement;
expect(nameInput.placeholder).not.toBe('');
expect(nameInput.required).toBe(true);
});
it('does not show the unsaved-warning banner before any input', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
// The unsaved warning specifically contains the German phrase
expect(document.body.textContent).not.toMatch(/ungespeicherte/i);
});
it('keeps the form mounted after an input event (oninput handler does not unmount)', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const form = document.querySelector('form') as HTMLFormElement;
expect(form).not.toBeNull();
form.dispatchEvent(new Event('input', { bubbles: true }));
expect(document.querySelector('form')).not.toBeNull();
});
it('hides the form error banner when form is undefined (already covered, branch 2)', async () => {
render(AdminGroupNewPage, { props: { form: undefined } });
const banner = document.querySelector('.bg-red-50');
expect(banner).toBeNull();
});
});

View File

@@ -1,14 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AdminGroupsIndexPage from './+page.svelte';
afterEach(cleanup);
describe('admin/groups index page', () => {
it('renders the select-from-list prompt', async () => {
render(AdminGroupsIndexPage, { props: {} });
await expect.element(page.getByText('Wähle eine Gruppe aus der Liste.')).toBeVisible();
});
});

View File

@@ -1,256 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AdminInvitesPage from './+page.svelte';
afterEach(cleanup);
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'i-1',
displayCode: 'XYZ-1234',
label: 'Familie',
useCount: 0,
maxUses: 5,
expiresAt: '2027-01-01T00:00:00Z',
status: 'active' as string,
shareableUrl: 'http://example.com/i/i-1',
...overrides
});
const baseData = (
overrides: Partial<{
invites: ReturnType<typeof makeInvite>[];
status: string;
loadError: string | null;
}> = {}
) => ({
invites: [],
status: 'active',
loadError: null,
...overrides
});
describe('admin/invites page', () => {
it('renders the page heading and the new-invite button', async () => {
render(AdminInvitesPage, { props: { data: baseData() } });
await expect.element(page.getByRole('heading', { name: /einladungen/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /neue einladung/i })).toBeVisible();
});
it('renders the empty placeholder when the invite list is empty', async () => {
render(AdminInvitesPage, { props: { data: baseData() } });
await expect.element(page.getByText('Keine aktiven Einladungen vorhanden.')).toBeVisible();
});
it('marks the active filter chip as selected when status is "active"', async () => {
render(AdminInvitesPage, { props: { data: baseData({ status: 'active' }) } });
const activeChip = (await page
.getByRole('link', { name: /^aktiv$/i })
.element()) as HTMLAnchorElement;
expect(activeChip.classList.contains('bg-primary')).toBe(true);
});
it('marks the show-all filter chip as selected when status is "all"', async () => {
render(AdminInvitesPage, { props: { data: baseData({ status: 'all' }) } });
const showAllChip = (await page
.getByRole('link', { name: /alle anzeigen/i })
.element()) as HTMLAnchorElement;
expect(showAllChip.classList.contains('bg-primary')).toBe(true);
});
it('renders the load-error banner when data.loadError is set', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ loadError: 'INVITE_LOAD_FAILED' }) }
});
const banner = document.querySelector('.bg-red-50');
expect(banner).not.toBeNull();
});
it('shows the new-invite form when the new-invite button is clicked', async () => {
render(AdminInvitesPage, { props: { data: baseData() } });
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
await expect.element(page.getByLabelText(/bezeichnung|label/i)).toBeVisible();
});
it('shows the createError message inside the form when form.createError is set and the form is open', async () => {
render(AdminInvitesPage, {
props: { data: baseData(), form: { createError: 'INVALID_INVITE' } }
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
const banners = document.querySelectorAll('.text-red-600');
expect(banners.length).toBeGreaterThan(0);
});
it('shows the created-invite success card with the shareable URL when form.created is set', async () => {
render(AdminInvitesPage, {
props: {
data: baseData(),
form: { created: makeInvite({ id: 'new', shareableUrl: 'http://example.com/i/new' }) }
}
});
await expect.element(page.getByText('Einladung erstellt')).toBeVisible();
await expect.element(page.getByText('http://example.com/i/new')).toBeVisible();
});
it('renders one row per invite in the table', async () => {
render(AdminInvitesPage, {
props: {
data: baseData({
invites: [
makeInvite({ id: 'a', displayCode: 'AAA-1111', label: 'Eltern' }),
makeInvite({ id: 'b', displayCode: 'BBB-2222', label: 'Geschwister' })
]
})
}
});
await expect.element(page.getByText('AAA-1111')).toBeVisible();
await expect.element(page.getByText('BBB-2222')).toBeVisible();
});
it('renders "Aktiv" status with the active visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'active' })] }) }
});
const statusBadge = document.querySelector('tbody [aria-label="Aktiv"]') as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-green-50')).toBe(true);
});
it('renders "Widerrufen" status with the revoked visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'revoked' })] }) }
});
const statusBadge = document.querySelector(
'tbody [aria-label="Widerrufen"]'
) as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-red-50')).toBe(true);
});
it('renders "Erschöpft" status with the exhausted visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'exhausted' })] }) }
});
const statusBadge = document.querySelector(
'tbody [aria-label="Erschöpft"]'
) as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-gray-100')).toBe(true);
});
it('renders "Abgelaufen" status with the expired visual treatment', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'expired' })] }) }
});
const statusBadge = document.querySelector(
'tbody [aria-label="Abgelaufen"]'
) as HTMLElement | null;
expect(statusBadge?.classList.contains('bg-amber-50')).toBe(true);
});
it('renders the revoke button only for active invites', async () => {
render(AdminInvitesPage, {
props: {
data: baseData({
invites: [
makeInvite({ id: 'a', status: 'active' }),
makeInvite({ id: 'b', status: 'revoked' })
]
})
}
});
const revokeButtons = document.querySelectorAll('button[type="submit"]');
// The new-invite form is hidden by default, so all submit buttons are revoke buttons.
expect(revokeButtons.length).toBe(1);
});
it('renders the unlimited symbol when an invite has no maxUses', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ maxUses: null, useCount: 7 })] }) }
});
await expect.element(page.getByText(/7\s*\/\s*∞/)).toBeVisible();
});
it('renders "Kein Ablauf" when an invite has no expiresAt', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ expiresAt: null })] }) }
});
await expect.element(page.getByText('Kein Ablauf')).toBeVisible();
});
it('renders the exhausted status with the correct color class', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'exhausted' })] }) }
});
// gray color for exhausted
const pill = Array.from(document.querySelectorAll('.bg-gray-100'));
expect(pill.length).toBeGreaterThan(0);
});
it('renders the expired status with the correct color class', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'expired' })] }) }
});
// amber color for expired
const pill = Array.from(document.querySelectorAll('.bg-amber-50'));
expect(pill.length).toBeGreaterThan(0);
});
it('renders the revoked status with the correct color class', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ invites: [makeInvite({ status: 'revoked' })] }) }
});
const pill = Array.from(document.querySelectorAll('.bg-red-50'));
// May have other red elements (like loadError) — at least one
expect(pill.length).toBeGreaterThan(0);
});
it('toggles the new-invite form when the button is clicked', async () => {
render(AdminInvitesPage, { props: { data: baseData(), form: undefined } });
const formBefore = document.querySelector('form[action="?/create"]');
expect(formBefore).toBeNull();
const newBtn = Array.from(document.querySelectorAll('button')).find((b) =>
/neue|invite|einladung/i.test(b.textContent ?? '')
) as HTMLButtonElement | undefined;
newBtn?.click();
await vi.waitFor(() => {
expect(document.querySelector('form[action="?/create"]')).not.toBeNull();
});
});
it('shows the load error banner when data.loadError is set', async () => {
render(AdminInvitesPage, {
props: { data: baseData({ loadError: 'INTERNAL_ERROR' }), form: undefined }
});
const banner = document.querySelector('.bg-red-50');
expect(banner).not.toBeNull();
});
});

View File

@@ -1,88 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import OcrModelsTable from './OcrModelsTable.svelte';
import type { components } from '$lib/generated/api';
afterEach(cleanup);
type SenderModel = components['schemas']['SenderModel'];
const baseModel = (overrides: Partial<SenderModel> = {}): SenderModel =>
({
id: 'm1',
personId: '123e4567-e89b-12d3-a456-426614174000',
correctedLinesAtTraining: 100,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
cer: 0.05,
accuracy: 0.95,
...overrides
}) as SenderModel;
describe('OcrModelsTable — null/em-dash branches', () => {
it('renders em-dash when cer is null', async () => {
render(OcrModelsTable, {
senderModels: [baseModel({ cer: null as unknown as undefined })],
personNames: {}
});
const tds = document.querySelectorAll('tbody td');
const cerCell = tds[1] as HTMLTableCellElement;
expect(cerCell.textContent?.trim()).toBe('—');
});
it('renders em-dash when accuracy is null', async () => {
render(OcrModelsTable, {
senderModels: [baseModel({ accuracy: null as unknown as undefined })],
personNames: {}
});
const tds = document.querySelectorAll('tbody td');
const accuracyCell = tds[2] as HTMLTableCellElement;
expect(accuracyCell.textContent?.trim()).toBe('—');
});
it('renders cer as percentage when set', async () => {
render(OcrModelsTable, {
senderModels: [baseModel({ cer: 0.0432 })],
personNames: {}
});
const tds = document.querySelectorAll('tbody td');
const cerCell = tds[1] as HTMLTableCellElement;
expect(cerCell.textContent?.trim()).toBe('4.3%');
});
it('renders accuracy as percentage when set', async () => {
render(OcrModelsTable, {
senderModels: [baseModel({ accuracy: 0.967 })],
personNames: {}
});
const tds = document.querySelectorAll('tbody td');
const accuracyCell = tds[2] as HTMLTableCellElement;
expect(accuracyCell.textContent?.trim()).toBe('96.7%');
});
it('renders the corrected-lines training count as raw number', async () => {
render(OcrModelsTable, {
senderModels: [baseModel({ correctedLinesAtTraining: 247 })],
personNames: {}
});
expect(document.body.textContent).toContain('247');
});
it('renders multiple models as separate rows', async () => {
render(OcrModelsTable, {
senderModels: [
baseModel({ id: 'm1', personId: 'p1' }),
baseModel({ id: 'm2', personId: 'p2' })
],
personNames: { p1: 'Anna', p2: 'Bertha' }
});
const rows = document.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
});
});

View File

@@ -1,68 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AdminOcrPersonPage from './+page.svelte';
afterEach(cleanup);
describe('admin/ocr/[personId] page', () => {
it('renders the person name from personNames lookup', async () => {
render(AdminOcrPersonPage, {
props: {
data: {
personId: 'p-1',
history: {
runs: [],
personNames: { 'p-1': 'Anna Schmidt' }
}
}
}
});
await expect.element(page.getByRole('heading', { name: /anna schmidt/i })).toBeVisible();
});
it('falls back to "Unknown" when the personNames lookup misses', async () => {
render(AdminOcrPersonPage, {
props: {
data: {
personId: 'p-1',
history: {
runs: [],
personNames: {}
}
}
}
});
await expect.element(page.getByRole('heading', { name: /unknown/i })).toBeVisible();
});
it('renders the back link to /admin/ocr', async () => {
render(AdminOcrPersonPage, {
props: {
data: {
personId: 'p-1',
history: { runs: [], personNames: { 'p-1': 'Anna Schmidt' } }
}
}
});
await expect
.element(page.getByRole('link', { name: /^ocr$/i }))
.toHaveAttribute('href', '/admin/ocr');
});
it('handles missing personNames object gracefully', async () => {
render(AdminOcrPersonPage, {
props: {
data: {
personId: 'p-1',
history: { runs: undefined, personNames: undefined }
}
}
});
await expect.element(page.getByRole('heading', { name: /unknown/i })).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More