diff --git a/.gitignore b/.gitignore
index 1306e1bd..60d3f1e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,5 +18,11 @@ scripts/large-data.sql
.claude/worktrees/
.claude/scheduled_tasks.lock
+# Run artifacts from verification tooling
+proofshot-artifacts/
+
+# Root-level Node.js tooling artifacts
+node_modules/
+
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
frontend/yarn.lock
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
index 7aecf88f..e99acfdd 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
import lombok.extern.slf4j.Slf4j;
+// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java
index 9a3a729d..035c0b9b 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java
@@ -7,6 +7,7 @@ import org.springframework.security.core.Authentication;
import java.util.UUID;
+// Cross-cutting auth helper; no domain home — "Utils" is the correct suffix here.
public final class SecurityUtils {
private SecurityUtils() {}
diff --git a/frontend/.gitignore b/frontend/.gitignore
index b05dec47..8617ce82 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -36,6 +36,10 @@ src/lib/paraglide_bak*
e2e/.auth/
**/test-results/**
+**/test-results.locked/
+
+# Stale SvelteKit build artifacts
+**/.svelte-kit.old/
# Proofshot browser verification artifacts
proofshot-artifacts/
diff --git a/frontend/.svelte-kit.old/types/src/routes/.stammbaum-stale/$types.d.ts b/frontend/.svelte-kit.old/types/src/routes/.stammbaum-stale/$types.d.ts
deleted file mode 100644
index 75782266..00000000
--- a/frontend/.svelte-kit.old/types/src/routes/.stammbaum-stale/$types.d.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type * as Kit from '@sveltejs/kit';
-
-type Expand
{ it('shows drop hint text', async () => { render(BulkDropZone, { onFilesAdded: vi.fn() }); - await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument(); + await expect.element(page.getByText(/Dateien ablegen/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts index a9b11698..c5562630 100644 --- a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts @@ -116,8 +116,8 @@ describe('TranscriptionBlock — interactions', () => { it('calls onFocus when textarea is focused', async () => { const onFocus = vi.fn(); renderBlock({ onFocus }); - const textarea = page.getByRole('textbox'); - await textarea.click(); + const textboxEl = (await page.getByRole('textbox').element()) as HTMLElement; + textboxEl.dispatchEvent(new FocusEvent('focus', { bubbles: false })); expect(onFocus).toHaveBeenCalled(); }); @@ -152,16 +152,20 @@ describe('TranscriptionBlock — reorder controls', () => { it('calls onMoveUp when up arrow clicked', async () => { const onMoveUp = vi.fn(); renderBlock({ onMoveUp, isFirst: false }); - const btn = page.getByRole('button', { name: 'Nach oben' }); - await btn.click(); + const btnEl = (await page + .getByRole('button', { name: 'Nach oben' }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onMoveUp).toHaveBeenCalled(); }); it('calls onMoveDown when down arrow clicked', async () => { const onMoveDown = vi.fn(); renderBlock({ onMoveDown, isLast: false }); - const btn = page.getByRole('button', { name: 'Nach unten' }); - await btn.click(); + const btnEl = (await page + .getByRole('button', { name: 'Nach unten' }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onMoveDown).toHaveBeenCalled(); }); }); @@ -227,16 +231,17 @@ describe('TranscriptionBlock — delete confirmation', () => { describe('TranscriptionBlock — quote selection', () => { it('shows quote hint after text is selected in the editor', async () => { renderBlock({ text: 'Breslau, den 12. August' }); - await page.getByRole('textbox').click(); - // Select all text in the contenteditable via the native Selection API. - // Tiptap fires selectionUpdate which the block forwards as onSelectionChange. - const editorEl = document.querySelector('[role="textbox"]') as HTMLElement; + // Native .focus() activates ProseMirror's DOMObserver so it listens for selectionchange. + const editorEl = (await page.getByRole('textbox').element()) as HTMLElement; + editorEl.focus(); + // Let ProseMirror's focus handler complete before we overwrite the selection. + await new Promise((r) => setTimeout(r, 0)); const range = document.createRange(); range.selectNodeContents(editorEl); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - editorEl.dispatchEvent(new Event('input', { bubbles: true })); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + document.dispatchEvent(new Event('selectionchange')); await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts index 701cb288..ec53e911 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import TranscriptionEditView from './TranscriptionEditView.svelte'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; @@ -148,24 +148,28 @@ describe('TranscriptionEditView — auto-save debounce', () => { }); it('passes the block mentionedPersons array as the 3rd save argument', async () => { - vi.useFakeTimers(); const onSaveBlock = vi.fn().mockResolvedValue(undefined); const blockWithMention = { ...block1, + // text must contain the @displayName token so deserialize() creates a mention node; + // fill() replaces the whole content with plain text and would destroy the node + text: '@Auguste Raddatz', mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] }; renderView({ blocks: [blockWithMention], onSaveBlock }); - const textarea = page.getByRole('textbox').first(); - await textarea.fill('Hallo @Auguste Raddatz'); + // type() focuses the element (cursor at position 0) then inserts without replacing the + // existing mention node. Fake timers interfere with keyboard CDP so use real timers + // + vi.waitFor to catch the 1500 ms debounce. + await userEvent.type(page.getByRole('textbox').first(), 'Hallo '); - vi.advanceTimersByTime(1500); - await vi.runAllTimersAsync(); - - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ - { personId: 'p-aug', displayName: 'Auguste Raddatz' } - ]); - vi.useRealTimers(); + await vi.waitFor( + () => + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]), + { timeout: 3000 } + ); }); it('resets debounce timer on rapid successive changes', async () => { @@ -238,8 +242,9 @@ describe('TranscriptionEditView — flush on blur', () => { const textarea = page.getByRole('textbox').first(); await textarea.fill('Blur text'); - // Blur before 1500ms debounce fires — locator.blur() not available, use native DOM - const el = document.querySelector('textarea') as HTMLTextAreaElement; + // Blur before 1500ms debounce fires — locator.blur() not available, use native DOM. + // PersonMentionEditor uses a contenteditable div (role=textbox), not a