dorfteich/apps/web/e2e/tasks.spec.ts
Claude Fable 5 e164370691 #154: Aufgabenübersicht als Kern-Block (Seite + Unterseiten)
Neuer Block-Atom task_overview (Markdown-Fence dorfteich-tasks,
HTML-Placeholder). Shared extractTaskRows liest Task-Zeilen mit Text,
Mentions (#150) und Start-/Zieldaten (#152); TasksService sammelt zur
Lesezeit den Teilbaum (rekursiv via collectSubtreeIds, canAccessPage-
Filter je Quellseite) aus Basis-State + page_updates-Log — KEINE
abgeleitete Tabelle nötig (Teilbäume sind klein, kein Drift). Neuer
auth-Endpoint GET /read/:pond/:slug/tasks; die öffentliche Ansicht
expandiert den Placeholder serverseitig zur statischen Tabelle
(Instanz-Sprache). NodeView mit Live-Tabelle und Rückschreib-Checkboxen
(optimistisch, Override bis der debounced Collab-Persist nachzieht);
Einfügen über die Block-Auswahl (eingebauter Eintrag). Unit- + DB-Tests,
neuer CI-Pack tasks.spec (voller Loop inkl. Rückschreiben end-to-end),
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 01:20:30 +02:00

120 lines
4.8 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Tasks pack (issues #150/#152/#153/#154): task lines get stable ids, the
* task overview block collects the page + subtree into a table with mention
* and date columns, and checking a box in the overview writes back to the
* source page through the collab server. Language-independent selectors.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
test('task overview collects subtree tasks and toggles write back', async ({ browser }) => {
test.setTimeout(120_000);
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
const parentRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Tasks Parent ${ts}` },
});
const parent = await parentRes.json();
const childRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Tasks Child ${ts}`, parentId: parent.id },
});
const child = await childRes.json();
const page = await context.newPage();
// Child: one task line with a due date (typed, so it gets an id, #153).
await page.goto(`/p/${pond.slug}/${child.slug}`);
await page.locator('.editor-page__mode-toggle').click();
const body = page.locator('.editor-content .ProseMirror');
await body.click();
await page.locator('.editor-toolbar button', { hasText: '☑' }).click();
await page.keyboard.type('Kabel prüfen >>31.12.2026 ');
await expect(page.locator('.editor-content .dt-date--due')).toBeVisible();
// The collab server persists debounced (~2 s) — wait until the task shows
// up in the collection before moving on.
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`);
return JSON.stringify(await res.json());
},
{ timeout: 20_000 },
)
.toContain('Kabel prüfen');
// Parent: a task line plus the overview block.
await page.goto(`/p/${pond.slug}/${parent.slug}`);
await page.locator('.editor-page__mode-toggle').click();
await page.locator('.editor-content .ProseMirror').click();
await page.locator('.editor-toolbar button', { hasText: '☑' }).click();
await page.keyboard.type('Bühne buchen');
await page.keyboard.press('Enter');
// Leave the task list (an empty item converts back to a paragraph).
await page.keyboard.press('Enter');
await page.locator('.editor-toolbar__block-select').selectOption('builtin/tasks');
await expect(page.locator('.dt-transclusion-card')).toBeVisible();
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${parent.slug}/tasks`);
return ((await res.json()) as { tasks: unknown[] }[]).reduce(
(sum, p) => sum + p.tasks.length,
0,
);
},
{ timeout: 20_000 },
)
.toBe(2);
// Read mode: the overview table lists both tasks with the date column.
await page.reload();
const table = page.locator('.dt-task-overview-table');
await expect(table).toBeVisible();
await expect(table).toContainText('Bühne buchen');
await expect(table).toContainText('Kabel prüfen');
await expect(table.locator('tbody tr')).toHaveCount(2);
// Toggle the child's task from the overview — it writes back through the
// collab server; after the refetch the box stays checked.
const childRow = table.locator('tbody tr', { hasText: 'Kabel prüfen' });
const checkbox = childRow.locator('input[type="checkbox"]');
await expect(checkbox).toBeEnabled();
await checkbox.check();
// Write-back travels api → NOTIFY → collab → debounced persist.
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`);
const pages = (await res.json()) as { tasks: { checked: boolean }[] }[];
return pages[0]?.tasks[0]?.checked ?? false;
},
{ timeout: 20_000 },
)
.toBe(true);
await page.reload();
await expect(
page
.locator('.dt-task-overview-table tbody tr', { hasText: 'Kabel prüfen' })
.locator('input[type="checkbox"]'),
).toBeChecked();
// The source page itself now shows the checked box.
await page.goto(`/p/${pond.slug}/${child.slug}`);
await expect(page.locator('.editor-content li[data-type="task_item"] input')).toBeChecked();
await context.close();
});