Test the two gestures the rework is actually about
The browser suite checked what the app renders but nothing about authoring, which is the part that changed. It now registers a fresh account and walks the wizard, including the case where a partner added mid-wizard has to appear by name without navigating away first, and it drags a range on the calendar, deletes the period, and reloads to confirm the days really went. Both are regressions that were found by hand within the last day and would have been found here instead. Doing this surfaced that the age-four spec had been asserting on figures that moved inside a collapsed section, so it had stopped checking anything. The stale note about the retired tips page went too.
This commit is contained in:
@@ -21,3 +21,44 @@ export async function goToCalendarMonth(page: Page, date: Date): Promise<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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 })
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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" <details> disclosure (Dashboard.tsx's
|
||||
// `dashboard-more-details`) — open it before asserting anything inside,
|
||||
// otherwise every element in there is genuinely hidden (native <details>
|
||||
// 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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user