dorfteich/apps/web/e2e/reorder.spec.ts
Claude Opus 4.8 69b00fcf2f
All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add manual page ordering with drag-and-drop (#45)
Enable the third sidebar sort mode — a freely defined order.

- api: `PATCH /pages/:id/position` (before/after neighbour) recomputes only
  the moved page's fractional `sort_key`. Pure `sort-key.ts` helpers
  (`nextKeyOrRebalance`, `evenlySpacedKeys`) decide between the cheap
  single-key path and a full pond rebalance to evenly-spaced keys when a key
  would exceed MAX_SORT_KEY_LENGTH or the client's neighbours are stale;
  rebalance runs in one transaction. Order is server-authoritative.
- web: enable 'manual' in the sort-mode switch; in manual mode the owner can
  reorder via native drag-and-drop (drop above/below by pointer half) or the
  keyboard (per-row up/down buttons), each announced through an aria-live
  region. Reordering is hidden while a label filter narrows the list. New
  pages already append at the end (create uses generateKeyBetween(last, null)).
  Pure `reorder.ts` neighbour helpers, unit-tested.
- i18n: manual sort mode + reorder strings (de + en).
- tests: sort-key property test (10.000 adversarial reorders never collide or
  overflow — rebalance verified); reposition db test (persist, server-order,
  sort-mode switch keeps manual order); reorder e2e pack (keyboard reorder
  persists across reload + identical on a fresh read; aria-live announced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 12:18:44 +02:00

93 lines
4.1 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Manual page ordering pack (issue #45). Exercises the keyboard reorder path
* (up/down buttons) — the same `moveTo` → `PATCH /pages/:id/position` code the
* drag-and-drop uses — because native HTML5 drag events are unreliable to
* simulate. Verifies the order is server-authoritative (persists across a
* reload and is identical on a fresh read) and that moves are announced via
* aria-live. Selectors are language-independent (CSS classes + arrow glyphs).
*/
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 };
}
async function createPage(context: Ctx, pondId: string, title: string): Promise<{ slug: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
return created.json();
}
/** Relative order of the given titles among the sidebar's page links. */
function relativeOrder(listTexts: string[], titles: string[]): number[] {
return titles.map((title) => listTexts.findIndex((text) => text === title));
}
test('manual reorder persists server-side and is announced', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'manual' } });
const ts = Date.now();
const first = `R-First ${ts}`;
const second = `R-Second ${ts}`;
const third = `R-Third ${ts}`;
const created = await createPage(context, pond.id, first);
await createPage(context, pond.id, second);
await createPage(context, pond.id, third);
const page = await context.newPage();
try {
await page.goto(`/p/${pond.slug}/${created.slug}`);
const links = page.locator('.sidebar__pages .sidebar__page');
await expect(links.filter({ hasText: third }).first()).toBeVisible();
// Manual mode shows creation order: first, second, third (relatively).
const before = relativeOrder(await links.allTextContents(), [first, second, third]);
expect(before[0]! < before[1]! && before[1]! < before[2]!).toBe(true);
// Move "first" down once via the keyboard button → order second, first, third.
const firstRow = page.locator('.sidebar__page-item', {
has: page.getByText(first, { exact: true }),
});
await firstRow.locator('.sidebar__reorder-btn', { hasText: '↓' }).click();
// aria-live announced the move (contains the title and a position number).
const status = page.locator('.sidebar__announce');
await expect(status).toContainText(first);
await expect(status).toContainText(/\d/);
await expect(async () => {
const order = relativeOrder(await links.allTextContents(), [second, first, third]);
expect(order[0]! < order[1]! && order[1]! < order[2]!).toBe(true);
}).toPass();
// Persisted server-side: a reload keeps the new order…
await page.reload();
await expect(links.filter({ hasText: first }).first()).toBeVisible();
await expect(async () => {
const order = relativeOrder(await links.allTextContents(), [second, first, third]);
expect(order[0]! < order[1]! && order[1]! < order[2]!).toBe(true);
}).toPass();
// …and a fresh read (any other user's view) sees the identical order.
const reread = await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json();
const titles = reread.map((p: { title: string }) => p.title);
const idx = (t: string) => titles.indexOf(t);
expect(idx(second) < idx(first) && idx(first) < idx(third)).toBe(true);
} finally {
// Reset so this fixture pond does not leak a non-default sort mode.
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'alpha' } });
}
await context.close();
});