RED: page does not yet render a drafts section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import { page } from 'vitest/browser';
|
||
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
||
|
||
import Page from './+page.svelte';
|
||
import type { PageData } from './$types';
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
function person(id: string, displayName: string) {
|
||
return {
|
||
id,
|
||
firstName: displayName.split(' ')[0] ?? displayName,
|
||
lastName: displayName.split(' ').slice(1).join(' ') || 'X',
|
||
displayName,
|
||
personType: 'PERSON'
|
||
};
|
||
}
|
||
|
||
function makeData(overrides: Partial<PageData> = {}): PageData {
|
||
return {
|
||
geschichten: [],
|
||
drafts: [],
|
||
personFilters: [],
|
||
documentFilter: null,
|
||
canBlogWrite: false,
|
||
...overrides
|
||
} as unknown as PageData;
|
||
}
|
||
|
||
function makeDocumentFilter(overrides: { id?: string; title?: string | null } = {}): {
|
||
id: string;
|
||
title: string | null;
|
||
} {
|
||
return {
|
||
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||
title: 'Brief an Oma',
|
||
...overrides
|
||
};
|
||
}
|
||
|
||
describe('geschichten page — multi-person filter chips', () => {
|
||
it('renders one chip per person in personFilters', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
|
||
})
|
||
});
|
||
|
||
await expect
|
||
.element(page.getByRole('button', { name: /Anna A aus Filter entfernen/ }))
|
||
.toBeVisible();
|
||
await expect
|
||
.element(page.getByRole('button', { name: /Bertha B aus Filter entfernen/ }))
|
||
.toBeVisible();
|
||
});
|
||
|
||
it('renders the "All" pill in pressed state when no filters are active', async () => {
|
||
render(Page, { data: makeData() });
|
||
await expect
|
||
.element(page.getByRole('button', { name: 'Alle' }))
|
||
.toHaveAttribute('aria-pressed', 'true');
|
||
});
|
||
|
||
it('renders the "All" pill in unpressed state when at least one filter is active', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||
})
|
||
});
|
||
await expect
|
||
.element(page.getByRole('button', { name: 'Alle' }))
|
||
.toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
|
||
it('clicking × on a chip removes only that person from the URL', async () => {
|
||
const { goto } = await import('$app/navigation');
|
||
vi.mocked(goto).mockClear();
|
||
|
||
// Seed window.location so the chip-removal logic builds the new URL deterministically.
|
||
const originalHref = window.location.href;
|
||
window.history.replaceState({}, '', '/geschichten?personId=a&personId=b');
|
||
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
|
||
})
|
||
});
|
||
|
||
const chipBtn = (await page
|
||
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
|
||
.element()) as HTMLElement;
|
||
chipBtn.click();
|
||
|
||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||
expect(url).toContain('personId=b');
|
||
expect(url).not.toContain('personId=a');
|
||
|
||
window.history.replaceState({}, '', originalHref);
|
||
});
|
||
|
||
it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
geschichten: [
|
||
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
|
||
] as PageData['geschichten']
|
||
})
|
||
});
|
||
|
||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||
expect(badge).not.toBeNull();
|
||
});
|
||
|
||
it('shows the "+ Person wählen" button even when filters are already active', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||
})
|
||
});
|
||
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
|
||
});
|
||
|
||
describe('document filter chip', () => {
|
||
it('renders the document chip when documentFilter is set', async () => {
|
||
render(Page, {
|
||
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
|
||
});
|
||
|
||
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
|
||
});
|
||
|
||
it('does not render the document chip when documentFilter is null', async () => {
|
||
render(Page, { data: makeData() });
|
||
|
||
await expect.element(page.getByText(/Gefiltert nach Brief/)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('clicking the document chip remove button navigates without documentId', async () => {
|
||
const { goto } = await import('$app/navigation');
|
||
vi.mocked(goto).mockClear();
|
||
|
||
window.history.replaceState(
|
||
{},
|
||
'',
|
||
'/geschichten?documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||
);
|
||
|
||
render(Page, {
|
||
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
|
||
});
|
||
|
||
const removeBtn = (await page
|
||
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||
.element()) as HTMLElement;
|
||
removeBtn.click();
|
||
|
||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||
expect(url).not.toContain('documentId');
|
||
});
|
||
|
||
it('document chip removal preserves active person filters', async () => {
|
||
const { goto } = await import('$app/navigation');
|
||
vi.mocked(goto).mockClear();
|
||
|
||
window.history.replaceState(
|
||
{},
|
||
'',
|
||
'/geschichten?personId=p1&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||
);
|
||
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('p1', 'Anna A')] as PageData['personFilters'],
|
||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||
})
|
||
});
|
||
|
||
const removeBtn = (await page
|
||
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||
.element()) as HTMLElement;
|
||
removeBtn.click();
|
||
|
||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||
expect(url).toContain('personId=p1');
|
||
expect(url).not.toContain('documentId');
|
||
});
|
||
|
||
it('marks the "All" pill as unpressed when document filter is active', async () => {
|
||
render(Page, {
|
||
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
|
||
});
|
||
|
||
await expect
|
||
.element(page.getByRole('button', { name: 'Alle' }))
|
||
.toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
});
|
||
|
||
it('removing a person chip preserves an active document filter in the URL', async () => {
|
||
const { goto } = await import('$app/navigation');
|
||
vi.mocked(goto).mockClear();
|
||
|
||
window.history.replaceState(
|
||
{},
|
||
'',
|
||
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||
);
|
||
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
|
||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||
})
|
||
});
|
||
|
||
const chipBtn = (await page
|
||
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
|
||
.element()) as HTMLElement;
|
||
chipBtn.click();
|
||
|
||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||
expect(url).not.toContain('personId=a');
|
||
expect(url).toContain('documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
||
|
||
window.history.replaceState({}, '', '/');
|
||
});
|
||
|
||
it('clearAll removes both person and document filters from the URL', async () => {
|
||
const { goto } = await import('$app/navigation');
|
||
vi.mocked(goto).mockClear();
|
||
|
||
window.history.replaceState(
|
||
{},
|
||
'',
|
||
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||
);
|
||
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
|
||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||
})
|
||
});
|
||
|
||
const allBtn = (await page.getByRole('button', { name: 'Alle' }).element()) as HTMLElement;
|
||
allBtn.click();
|
||
|
||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||
expect(url).not.toContain('personId');
|
||
expect(url).not.toContain('documentId');
|
||
|
||
window.history.replaceState({}, '', '/');
|
||
});
|
||
|
||
describe('empty state precedence', () => {
|
||
it('shows geschichten_empty_for_document when only document filter is active', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
geschichten: [],
|
||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||
})
|
||
});
|
||
|
||
await expect.element(page.getByText('Noch keine Geschichten zu diesem Brief')).toBeVisible();
|
||
});
|
||
|
||
it('shows geschichten_empty_for_persons when only person filter is active', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
geschichten: [],
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||
})
|
||
});
|
||
|
||
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
|
||
});
|
||
|
||
it('shows geschichten_empty_no_filter when no filter is active', async () => {
|
||
render(Page, { data: makeData({ geschichten: [] }) });
|
||
|
||
await expect
|
||
.element(page.getByText('Es gibt noch keine veröffentlichten Geschichten.'))
|
||
.toBeVisible();
|
||
});
|
||
|
||
it('person-wins: shows persons message when both person and document filters are active', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
geschichten: [],
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
|
||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||
})
|
||
});
|
||
|
||
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
|
||
await expect
|
||
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
|
||
.not.toBeInTheDocument();
|
||
});
|
||
|
||
it('chip renders alongside results (empty state not shown when results exist)', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
geschichten: [
|
||
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
|
||
] as PageData['geschichten'],
|
||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||
})
|
||
});
|
||
|
||
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||
await expect.element(page.getByText(/Lesereise Berlin/)).toBeVisible();
|
||
await expect
|
||
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
|
||
.not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('renders all filter pills with a 44px touch target (h-11)', async () => {
|
||
render(Page, {
|
||
data: makeData({
|
||
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||
})
|
||
});
|
||
|
||
// All three pill variants must use h-11 (44px) per the senior-author touch-target rule
|
||
const all = page.getByRole('button', { name: 'Alle' });
|
||
const chip = page.getByRole('button', { name: /Anna A aus Filter entfernen/ });
|
||
const add = page.getByRole('button', { name: /Person wählen/ });
|
||
|
||
const allEl = (await all.element()) as HTMLElement;
|
||
const chipEl = (await chip.element()) as HTMLElement;
|
||
const addEl = (await add.element()) as HTMLElement;
|
||
|
||
expect(allEl.className).toContain('h-11');
|
||
expect(chipEl.className).toContain('h-11');
|
||
expect(addEl.className).toContain('h-11');
|
||
});
|
||
});
|
||
|
||
// ─── Entwürfe section ─────────────────────────────────────────────────────────
|
||
|
||
describe('geschichten page — Entwürfe section', () => {
|
||
const draft = () =>
|
||
({
|
||
id: 'draft-1',
|
||
title: 'Mein Entwurf',
|
||
body: '<p>test</p>',
|
||
type: 'STORY',
|
||
status: 'DRAFT',
|
||
author: { firstName: 'Max', lastName: 'Muster' },
|
||
publishedAt: null
|
||
}) as unknown as PageData['geschichten'][0];
|
||
|
||
it('Entwürfe section is hidden when drafts array is empty', async () => {
|
||
render(Page, { data: makeData({ drafts: [] }) });
|
||
const heading = Array.from(document.querySelectorAll('h2')).find(
|
||
(h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts')
|
||
);
|
||
expect(heading).toBeUndefined();
|
||
});
|
||
|
||
it('Entwürfe section is visible when drafts are present', async () => {
|
||
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
|
||
const heading = Array.from(document.querySelectorAll('h2')).find(
|
||
(h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts')
|
||
);
|
||
expect(heading).not.toBeUndefined();
|
||
});
|
||
|
||
it('renders a row for each draft story', async () => {
|
||
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
|
||
const link = document.querySelector('a[href="/geschichten/draft-1"]');
|
||
expect(link).not.toBeNull();
|
||
});
|
||
|
||
it('draft row shows the draft badge', async () => {
|
||
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
|
||
const badge = document.querySelector('[data-testid="draft-badge"]');
|
||
expect(badge).not.toBeNull();
|
||
});
|
||
});
|