refactor: move geschichte domain to lib/geschichte/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:20:07 +02:00
parent 1e656d2db4
commit 8ff5d6f842
7 changed files with 4 additions and 4 deletions

View File

@@ -1,329 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from './PersonMultiSelect.svelte';
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
type Geschichte = components['schemas']['Geschichte'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
initialPersons?: Person[];
initialDocuments?: Document[];
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
// `geschichte` to reset (consistent with how form components in this codebase
// behave — see DocumentEdit page).
let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
);
let dirty = $state(false);
let titleTouched = $state(false);
const titleEmpty = $derived(title.trim().length === 0);
const isDraft = $derived(status === 'DRAFT');
const showTitleError = $derived(titleEmpty && titleTouched);
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
let toolbarVersion = $state(0);
onMount(() => {
editor = new Editor({
element: editorEl,
content: body,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
code: false,
codeBlock: false,
blockquote: false,
strike: false,
horizontalRule: false,
hardBreak: false
})
],
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.geschichte_editor_body_placeholder(),
class:
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
}
},
onUpdate({ editor: ed }) {
body = ed.getHTML();
dirty = true;
},
onSelectionUpdate() {
toolbarVersion++;
},
onTransaction() {
toolbarVersion++;
}
});
});
onDestroy(() => {
editor?.destroy();
});
beforeNavigate(({ cancel }) => {
if (dirty && !submitting) {
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
if (!ok) cancel();
}
});
function handleTitleBlur() {
titleTouched = true;
}
function handleTitleInput() {
dirty = true;
}
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
});
dirty = false;
}
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
void toolbarVersion;
return editor?.isActive(name, attrs) ?? false;
}
function exec(action: () => void) {
if (!editor) return;
action();
editor.commands.focus();
}
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
type="text"
bind:value={title}
oninput={handleTitleInput}
onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'title-error' : undefined}
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if showTitleError}
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Toolbar -->
<div
role="toolbar"
aria-label="Formatierung"
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
aria-label={m.geschichte_editor_toolbar_bold()}
aria-pressed={isActive('bold')}
title={m.geschichte_editor_toolbar_bold()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
B
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
aria-label={m.geschichte_editor_toolbar_italic()}
aria-pressed={isActive('italic')}
title={m.geschichte_editor_toolbar_italic()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
I
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
aria-label={m.geschichte_editor_toolbar_h2()}
aria-pressed={isActive('heading', { level: 2 })}
title={m.geschichte_editor_toolbar_h2()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H2
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
aria-label={m.geschichte_editor_toolbar_h3()}
aria-pressed={isActive('heading', { level: 3 })}
title={m.geschichte_editor_toolbar_h3()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H3
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
aria-label={m.geschichte_editor_toolbar_ul()}
aria-pressed={isActive('bulletList')}
title={m.geschichte_editor_toolbar_ul()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
aria-label={m.geschichte_editor_toolbar_ol()}
aria-pressed={isActive('orderedList')}
title={m.geschichte_editor_toolbar_ol()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
1.
</button>
</div>
<!-- Editor surface -->
<div
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
>
<div bind:this={editorEl} class="min-h-[260px]"></div>
</div>
</div>
<!-- Sidebar -->
<aside class="flex flex-col gap-6">
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft
? m.geschichte_editor_status_draft()
: m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_save_hint_draft()
: m.geschichte_editor_save_hint_published()}
</p>
<div class="flex gap-2">
{#if isDraft}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save_draft()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_publish()}
</button>
{:else}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_unpublish()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save()}
</button>
{/if}
</div>
</div>

View File

@@ -1,150 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import GeschichteEditor from './GeschichteEditor.svelte';
const personFactory = (id: string, displayName: string) => ({
id,
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const
});
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED' as const,
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
persons: [],
documents: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
afterEach(() => cleanup());
describe('GeschichteEditor — title-required guard', () => {
it('disables both DRAFT save buttons when the title is empty', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
expect(draft).toHaveProperty('disabled', true);
expect(publish).toHaveProperty('disabled', true);
});
it('shows the inline error after the title field is blurred while empty', async () => {
const onSubmit = vi.fn();
render(GeschichteEditor, { onSubmit });
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
await userEvent.tab(); // blur
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
});
});
describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
await expect
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
});
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
onSubmit: vi.fn()
});
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
.toBeInTheDocument();
});
});
describe('GeschichteEditor — pre-fill', () => {
it('renders initial persons as chips', async () => {
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
onSubmit: vi.fn()
});
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('renders initial documents as chips', async () => {
render(GeschichteEditor, {
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
onSubmit: vi.fn()
});
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
onSubmit: vi.fn()
});
const input = await page.getByPlaceholder('Titel der Geschichte').element();
expect((input as HTMLInputElement).value).toBe('My existing story');
});
});
describe('GeschichteEditor — onSubmit payload', () => {
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.title).toBe('My title');
expect(payload.status).toBe('DRAFT');
});
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
initialDocuments: [docFactory('d1', 'Brief A')],
onSubmit
});
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
expect(payload.documentIds).toEqual(['d1']);
});
});

View File

@@ -1,89 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/utils/extractText';
import { formatDate } from '$lib/utils/date';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
geschichten: Geschichte[];
personId: string;
personName: string;
canWrite: boolean;
}
let { geschichten, personId, personName, canWrite }: Props = $props();
const visible = $derived(geschichten.slice(0, 3));
const hasOverflow = $derived(geschichten.length >= 3);
function formatPublishedDate(g: Geschichte): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
function authorName(g: Geschichte): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
</script>
{#if geschichten.length > 0}
<section
aria-labelledby="geschichten-card-heading"
class="rounded-sm border border-line bg-surface p-6 shadow-sm"
>
<header class="mb-5 flex items-center justify-between">
<h2
id="geschichten-card-heading"
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.geschichten_card_heading()}
</h2>
{#if canWrite}
<a
href="/geschichten/new?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
>
{m.geschichten_card_write_action()}
</a>
{/if}
</header>
<ul class="-mx-2">
{#each visible as g (g.id)}
<li>
<a
href="/geschichten/{g.id}"
class="group flex flex-col gap-1 border-b border-line px-2 py-3 transition-colors last:border-b-0 hover:bg-muted"
>
<span class="font-serif text-base font-bold text-ink group-hover:underline">
{g.title}
</span>
<span class="font-sans text-xs text-ink-3">
{authorName(g)}
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
</span>
{#if g.body}
<span class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</span>
{/if}
</a>
</li>
{/each}
</ul>
{#if hasOverflow}
<footer class="mt-4 border-t border-line pt-3">
<a
href="/geschichten?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all_for_person({ name: personName })}
</a>
</footer>
{/if}
</section>
{/if}

View File

@@ -1,140 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
id,
title,
body,
status: 'PUBLISHED' as const,
publishedAt: '2024-04-01T12:00:00',
createdAt: '2024-03-01T12:00:00',
updatedAt: '2024-04-01T12:00:00',
persons: [],
documents: [],
author: {
id: 'u1',
email: 'marcel@example.com',
firstName: 'Marcel',
lastName: 'Raddatz',
enabled: true,
notifyOnReply: false,
notifyOnMention: false,
groups: [],
createdAt: '2024-01-01T00:00:00',
color: '#000'
}
});
afterEach(() => cleanup());
describe('GeschichtenCard', () => {
it('renders nothing when geschichten is empty', async () => {
render(GeschichtenCard, {
geschichten: [],
personId: 'p1',
personName: 'Franz',
canWrite: true
});
// No heading, no list — the entire <section> should not exist
expect(
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
).toBeNull();
});
it('renders the section heading and stories when geschichten is non-empty', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
// The whole row is one link to the story; matching on the title text via
// a partial regex tolerates trailing author/date metadata in the
// accessible name.
const link = await page
.getByRole('link', { name: /Erinnerung an Franz/ })
.first()
.element();
expect(link.getAttribute('href')).toBe('/geschichten/g1');
});
it('makes the entire story row a single clickable link', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A title', '<p>Some body excerpt text</p>')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
// The body-excerpt text is inside the same <a> as the title.
const links = await page.getByRole('link', { name: /A title/ }).all();
expect(links.length).toBeGreaterThan(0);
const linkEl = await links[0].element();
expect(linkEl.tagName).toBe('A');
expect(linkEl.textContent).toContain('Some body excerpt text');
});
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A story')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
expect(writeLinks).toHaveLength(0);
});
it('shows the write-action link only when canWrite is true', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A story')],
personId: 'p1',
personName: 'Franz',
canWrite: true
});
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
});
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const overflow = await page.getByText(/Alle Geschichten zu/).all();
expect(overflow).toHaveLength(0);
});
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [
makeStory(
'g1',
'Mit HTML',
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
)
],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
// Body excerpt appears once as plain text — no <strong> rendered, no script
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
expect(document.body.innerHTML).not.toContain('<script>');
});
});