diff --git a/frontend/e2e/support/calendarNav.ts b/frontend/e2e/support/calendarNav.ts index 798f030..892e23b 100644 --- a/frontend/e2e/support/calendarNav.ts +++ b/frontend/e2e/support/calendarNav.ts @@ -21,3 +21,44 @@ export async function goToCalendarMonth(page: Page, date: Date): Promise { export function calendarDayCell(page: Page, isoDate: string) { return page.locator(`[data-testid="calendar-day"][aria-label^="${isoDate}"]`) } + +/** + * Drives a real drag-select across the given ISO dates (in order), the way + * `Calendar.tsx` expects it: `CalendarDay` wires plain `onPointerDown`/ + * `onPointerEnter` handlers (see `CalendarWeek.tsx`), and the page only + * treats the gesture as a multi-day drag if `pointerenter` actually fired + * for every intermediate cell before `pointerup` — see the `dates.length + * === 1` branch in `Calendar.tsx`'s `pointerup` handler, which opens the + * single-day modal instead of creating a period. + * + * A single fast `mouse.move` (or Playwright's default `hover()`/`dragTo()`) + * can collapse into one native pointer event and skip the intermediate + * cells entirely, which reads as a click on the anchor date. Moving in + * several steps per cell, with a short pause between cells, is what + * reliably fires a `pointerenter` on each one. + */ +export async function dragSelectDates(page: Page, isoDates: string[]): Promise { + if (isoDates.length < 2) { + throw new Error('dragSelectDates needs at least two dates to actually drag across') + } + + const centerOf = async (isoDate: string): Promise<{ x: number; y: number }> => { + const box = await calendarDayCell(page, isoDate).boundingBox() + if (!box) throw new Error(`calendar day cell not visible for ${isoDate} — is it in the current month view?`) + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } + } + + const [firstDate, ...restDates] = isoDates + if (!firstDate) throw new Error('dragSelectDates needs at least two dates to actually drag across') + const start = await centerOf(firstDate) + await page.mouse.move(start.x, start.y) + await page.mouse.down() + + for (const isoDate of restDates) { + const point = await centerOf(isoDate) + await page.mouse.move(point.x, point.y, { steps: 10 }) + await page.waitForTimeout(50) + } + + await page.mouse.up() +} diff --git a/frontend/e2e/support/onboarding.ts b/frontend/e2e/support/onboarding.ts new file mode 100644 index 0000000..1a8dc7c --- /dev/null +++ b/frontend/e2e/support/onboarding.ts @@ -0,0 +1,54 @@ +/** + * Shared registration/onboarding steps for specs that need a brand-new, + * empty account rather than the shared demo workspace (`global-setup.ts`'s + * seeded data) — e.g. because the gesture under test needs a clean + * calendar, or because the demo user's own balance is deliberately drained + * (see `backend/scripts/seed_demo.py`'s "near-drained FP pool" for Alex, + * which a fresh drag-created period pinned to the logged-in parent would + * otherwise fail to fund). + * + * Every date here is derived from "today", never a hardcoded year. + */ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { toIsoDate, addDays } from './dates' + +export function uniqueEmail(tag: string): string { + return `e2e-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com` +} + +export const ONBOARDING_PASSWORD = 'E2ePassOnboard123!' + +/** Registers a brand-new account through the real UI and waits for the + * onboarding wizard's first step. */ +export async function registerAndReachOnboarding(page: Page, email: string, displayName: string): Promise { + await page.goto('/auth') + await page.getByRole('button', { name: 'Skapa konto' }).click() + await page.getByPlaceholder('Namn').fill(displayName) + await page.getByTestId('auth-email').fill(email) + await page.getByTestId('auth-password').fill(ONBOARDING_PASSWORD) + await page.getByTestId('auth-submit').click() + + await expect(page.getByTestId('onboarding')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('onboarding-step-child')).toBeVisible() +} + +/** Step 1 of the wizard: add the child, landing on step 2 (family). */ +export async function fillChildStep(page: Page, childName: string): Promise { + // Safely in the past relative to "today", regardless of when this runs. + const birthDate = toIsoDate(addDays(new Date(), -400)) + await page.getByLabel('Barnets namn').fill(childName) + await page.getByTestId('birth-date-input').fill(birthDate) + await page.getByRole('button', { name: 'Lägg till barn' }).click() + await expect(page.getByTestId('onboarding-step-family')).toBeVisible() +} + +/** From step 2 (family), keeps the default "solo" planning mode, continues + * to step 3 (balances) and skips it — landing on the Dashboard with the + * statutory-assumption balance (no manual fetch, no drained pool). */ +export async function continueSoloAndSkipBalances(page: Page): Promise { + await page.getByRole('button', { name: 'Fortsätt' }).click() + await expect(page.getByTestId('onboarding-step-balances')).toBeVisible() + await page.getByRole('button', { name: 'Hoppa över för nu' }).click() + await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 }) +} diff --git a/frontend/e2e/support/pages.ts b/frontend/e2e/support/pages.ts index 066f912..5614c55 100644 --- a/frontend/e2e/support/pages.ts +++ b/frontend/e2e/support/pages.ts @@ -8,18 +8,15 @@ export interface MainPage { * The authenticated pages scanned by the "no raw UUIDs" and "no console * errors" guards. * - * TODO(tips-wire-format): Tips (`/tips`, root testid `tips-page`) is - * deliberately left out. Its wire-format mapping (backend snake_case -> - * frontend camelCase) was never written, so today it renders blank — a fix - * is in flight in a parallel task and touching it here would collide. Once - * that fix lands, add: - * { path: '/tips', testId: 'tips-page', name: 'Tips' } - * to this list. Both `no-uuids.spec.ts` and `console-errors.spec.ts` - * iterate this array, so nothing else needs to change. + * Plan and Tips were removed as standalone pages/tabs (leave is now + * authored directly on the calendar, and Översikt carries the headline + * figures); `/plan` and `/tips` now fall through App.tsx's catch-all route + * and redirect to `/` instead of rendering a page. That redirect is covered + * at the unit level in `src/App.test.tsx` ("retired /plan and /tips + * routes"), so neither belongs in this list. */ export const MAIN_PAGES: MainPage[] = [ { path: '/', testId: 'dashboard', name: 'Dashboard' }, { path: '/calendar', testId: 'calendar', name: 'Calendar' }, - { path: '/plan', testId: 'plan-page', name: 'Plan' }, { path: '/settings', testId: 'settings-page', name: 'Settings' }, ] diff --git a/frontend/e2e/tests/age4.spec.ts b/frontend/e2e/tests/age4.spec.ts index 521647a..e182fa0 100644 --- a/frontend/e2e/tests/age4.spec.ts +++ b/frontend/e2e/tests/age4.spec.ts @@ -43,6 +43,13 @@ test('per-parent expiring/savable figures respect the 48/96 age-4 cap, and the D await page.goto('/') await expect(page.getByTestId('dashboard')).toBeVisible() + // The per-parent/combined metrics live inside the collapsed "Fler + // detaljer"
disclosure (Dashboard.tsx's + // `dashboard-more-details`) — open it before asserting anything inside, + // otherwise every element in there is genuinely hidden (native
+ // collapse, not just scrolled out of view). + await page.locator('[data-testid="dashboard-more-details"] summary').click() + for (const projection of overview.parent_projections) { const metric = page.getByTestId(`metric-expiring-parent-${projection.personal_data_uuid}`) await expect(metric).toBeVisible() diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts new file mode 100644 index 0000000..be806a9 --- /dev/null +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -0,0 +1,87 @@ +/** + * The two authoring gestures the calendar rework is actually about: + * dragging a range to create a period, and deleting one again through its + * popover. Neither was covered end-to-end before — the existing specs only + * check rendering (bands, red days, age-4 figures) of state the API seeded + * directly. + * + * Uses a brand-new, empty account (see `support/onboarding.ts`) rather than + * the shared demo workspace: `Calendar.tsx`'s drag-to-create always pins the + * new period to the CURRENTLY LOGGED-IN parent + * (`parentMode: 'specific', parentPersonalDataUuid: personalDataUuid`), and + * the demo user (Alex) is deliberately seeded with a fully drained + * sjukpenning/lägstanivå pool (see `backend/scripts/seed_demo.py`'s "near- + * drained FP pool") so that its one low-priority wish always shows a real + * shortfall — a 3-day drag by Alex would fail to fund for the same reason, + * which isn't what this test is about. A fresh account with no manual fetch + * gets the full statutory-assumption pool instead (see + * `AllocationInputLoader.load`'s "No FK snapshot yet" fallback), which is + * always enough for a 3-day period. + * + * The target month is derived from "today" (never a hardcoded year). + */ +import { test, expect } from '@playwright/test' +import { goToCalendarMonth, calendarDayCell, dragSelectDates } from '../support/calendarNav' +import { toIsoDate } from '../support/dates' +import { uniqueEmail, registerAndReachOnboarding, fillChildStep, continueSoloAndSkipBalances } from '../support/onboarding' + +test.use({ storageState: { cookies: [], origins: [] } }) + +function targetRange(): { month: Date; dates: [string, string, string] } { + const now = new Date() + // Two months out and mid-month (the 10th–12th), so the range can never + // land on a leading/trailing day of an adjacent month — `CalendarWeek.tsx` + // only wires pointer handlers for cells belonging to the currently + // displayed month. + const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1)) + const dates = [10, 11, 12].map((day) => + toIsoDate(new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), day))), + ) as [string, string, string] + return { month, dates } +} + +test('dragging a range creates a period, and deleting it removes the days for good', async ({ page }) => { + await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson') + await fillChildStep(page, 'Nova') + await continueSoloAndSkipBalances(page) + + const { month, dates } = targetRange() + const [startDate, , endDate] = dates + + await goToCalendarMonth(page, month) + + const wishBand = page.locator('[data-testid^="calendar-band-wish:"]') + await expect(wishBand).toHaveCount(0) // a brand-new account — nothing here yet + + await dragSelectDates(page, dates) + + // A toast reports the change (Calendar.tsx's `buildSyncToastMessage`), + // and the dragged range now renders as one band. + await expect(page.getByTestId('calendar-toast')).toBeVisible() + await expect(wishBand).toHaveCount(1) + + // Open the new period through its band and delete it — via the in-app + // confirmation from PeriodPopover.tsx, not a native `window.confirm`. + await wishBand.click() + await expect(page.getByTestId('period-popover__content')).toBeVisible() + await expect(page.getByTestId('period-start-input')).toHaveValue(startDate) + await expect(page.getByTestId('period-end-input')).toHaveValue(endDate) + + await page.getByTestId('period-delete-button').click() + await expect(page.getByTestId('period-delete-confirm')).toBeVisible() + await page.getByTestId('period-delete-confirm-button').click() + await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible() + + // The regression this guards ("Remove a period's days along with the + // period"): the underlying planned days must actually be gone + // server-side, not merely dropped from client state. A reload refetches + // from the API, so this is the assertion a surviving-days bug would fail. + await page.reload() + await goToCalendarMonth(page, month) + await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0) + + // And the day itself is empty again — clicking it opens the "plan a new + // day" modal, not an edit of a day that supposedly no longer exists. + await calendarDayCell(page, dates[1]).click() + await expect(page.getByTestId('modal-title')).toHaveText('Planera en dag') +}) diff --git a/frontend/e2e/tests/onboarding.spec.ts b/frontend/e2e/tests/onboarding.spec.ts new file mode 100644 index 0000000..99f6828 --- /dev/null +++ b/frontend/e2e/tests/onboarding.spec.ts @@ -0,0 +1,62 @@ +/** + * The other authoring gesture nothing end-to-end covers yet: registering a + * brand-new account and walking the 3-step onboarding wizard (child -> + * solo/couple -> balances). Runs with an empty `storageState` — deliberately + * NOT the shared demo session `auth.setup.ts` produces for the rest of the + * suite — since registering a fresh account is exactly what's under test. + * + * Every date used here is derived from "today", never a hardcoded year (see + * `support/dates.ts`'s module doc for why). + */ +import { test, expect } from '@playwright/test' +import { + uniqueEmail, + registerAndReachOnboarding, + fillChildStep, + continueSoloAndSkipBalances, +} from '../support/onboarding' + +test.use({ storageState: { cookies: [], origins: [] } }) + +test.describe('onboarding wizard', () => { + test('skipping the balances step still reaches the dashboard with the estimated badge', async ({ page }) => { + await registerAndReachOnboarding(page, uniqueEmail('onboarding-skip'), 'Alex Testsson') + await fillChildStep(page, 'Nova') + + // Solo is the wizard's default planning mode — nothing to change here. + await continueSoloAndSkipBalances(page) + + await expect(page.getByTestId('dashboard-estimate-badge')).toHaveText('Uppskattat') + }) + + test('a partner added mid-wizard shows their name on Översikt with no navigation in between', async ({ page }) => { + await registerAndReachOnboarding(page, uniqueEmail('onboarding-couple'), 'Sam Testsson') + await fillChildStep(page, 'Nova') + + await page.getByTestId('planning-mode-couple').check() + await expect(page.getByTestId('onboarding-partner-section')).toBeVisible() + await page.getByLabel('Partnerns namn').fill('Kim') + await page.getByRole('button', { name: 'Lägg till partner' }).click() + await expect(page.getByTestId('onboarding-partner-added')).toHaveText('Partner tillagd: Kim') + + await page.getByRole('button', { name: 'Fortsätt' }).click() + await expect(page.getByTestId('onboarding-step-balances')).toBeVisible() + await page.getByRole('button', { name: 'Hoppa över för nu' }).click() + + // The regression this guards: finishing the wizard flips + // WorkspaceContext's `children` from empty to non-empty, which + // re-renders this SAME Dashboard mount straight past the onboarding + // branch — there is no `navigate()` call in between. A previously + // fetched, now-stale members list used to leave the partner showing as + // the generic "Förälder N" fallback until a manual navigation away and + // back refetched it (see Dashboard.tsx's `wasAwaitingFirstChildRef` + // effect, and `nameResolver`/`parentDisplayName`). Asserting + // immediately — no reload, no extra navigation — is what would have + // caught that. + await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 }) + const age4Bars = page.getByTestId('age4-bars') + await expect(age4Bars).toBeVisible() + await expect(age4Bars).toContainText('Kim') + await expect(age4Bars).not.toContainText('Förälder') + }) +})