feat(geschichten): blog-like family memory stories (closes #381) #382

Merged
marcel merged 30 commits from feat/issue-381-geschichten into main 2026-05-04 15:02:45 +02:00
Showing only changes of commit aae005d5e6 - Show all commits

View File

@@ -68,43 +68,71 @@ test.describe('Geschichten — writer + reader journey', () => {
await page.goto('/geschichten'); await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
// We need two persons to filter by. Open the picker and pick one whose name // We need two distinct persons to filter by, but we don't want to couple this
// the dev seed reliably contains. Then open the picker again and pick a // test to specific seed names. Strategy: type a single broadly-occurring vowel
// second one. Picking is via the typeahead — we type, wait for the listbox, // ("e" is present in the vast majority of German names), open the listbox,
// click the first option. // and pick whichever option matches the predicate.
async function pickPerson(query: string) { //
// option DOM ids encode the person id as `${listboxId}-option-${personId}`,
// so we can identify the *first different* option without knowing the seed.
const PROBE = 'e';
async function openPicker() {
await page.getByRole('button', { name: /Person wählen/ }).click(); await page.getByRole('button', { name: /Person wählen/ }).click();
const input = page.getByRole('combobox', { name: /Person wählen/ }); const input = page.getByRole('combobox', { name: /Person wählen/ });
await input.fill(query); await input.fill(PROBE);
// Wait for at least one option in the listbox, then click it // Wait for the listbox to be populated.
const firstOption = page.getByRole('option').first(); await expect(page.getByRole('option').first()).toBeVisible();
await expect(firstOption).toBeVisible();
await firstOption.click();
} }
await pickPerson('a'); async function pickFirstOption(): Promise<string> {
await page.waitForURL(/personId=/); const opt = page.getByRole('option').first();
const firstUrl = new URL(page.url()); const optId = (await opt.getAttribute('id')) ?? '';
const firstIds = firstUrl.searchParams.getAll('personId'); const personId = optId.split('-option-')[1] ?? '';
expect(firstIds).toHaveLength(1); expect(personId).not.toEqual('');
await opt.click();
return personId;
}
await pickPerson('b'); async function pickFirstOptionDifferentFrom(excludeId: string): Promise<string> {
// Iterate through visible options and return the first whose person id != excludeId.
const optionCount = await page.getByRole('option').count();
for (let i = 0; i < optionCount; i++) {
const candidate = page.getByRole('option').nth(i);
const optId = (await candidate.getAttribute('id')) ?? '';
const personId = optId.split('-option-')[1] ?? '';
if (personId && personId !== excludeId) {
await candidate.click();
return personId;
}
}
throw new Error(
`Expected at least two distinct persons matching "${PROBE}" in the seed, found only one.`
);
}
await openPicker();
const firstId = await pickFirstOption();
await page.waitForURL(/personId=/);
const firstIds = new URL(page.url()).searchParams.getAll('personId');
expect(firstIds).toEqual([firstId]);
await openPicker();
const secondId = await pickFirstOptionDifferentFrom(firstId);
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2); await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
const secondUrl = new URL(page.url()); const secondIds = new URL(page.url()).searchParams.getAll('personId');
const secondIds = secondUrl.searchParams.getAll('personId'); expect(secondIds).toEqual([firstId, secondId]);
expect(secondIds).toHaveLength(2); expect(secondId).not.toEqual(firstId);
expect(secondIds[0]).toBe(firstIds[0]); // first one persists
expect(secondIds[1]).not.toBe(firstIds[0]); // second is different
// Two chips visible — find them by their remove-aria-label pattern // Two chips visible — find them by their remove-aria-label pattern
const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ }); const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ });
await expect(chipButtons).toHaveCount(2); await expect(chipButtons).toHaveCount(2);
// Remove the first chip — URL drops to one param // Remove the first chip — URL drops to one param, only the second id remains
await chipButtons.first().click(); await chipButtons.first().click();
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1); await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1);
const finalIds = new URL(page.url()).searchParams.getAll('personId'); const finalIds = new URL(page.url()).searchParams.getAll('personId');
expect(finalIds).toEqual([secondIds[1]]); expect(finalIds).toEqual([secondId]);
}); });
test('AxeBuilder finds no critical violations on the index', async ({ page }) => { test('AxeBuilder finds no critical violations on the index', async ({ page }) => {