diff --git a/frontend/e2e/support/calendarNav.ts b/frontend/e2e/support/calendarNav.ts index 892e23b..6e38fe3 100644 --- a/frontend/e2e/support/calendarNav.ts +++ b/frontend/e2e/support/calendarNav.ts @@ -36,12 +36,31 @@ export function calendarDayCell(page: Page, isoDate: string) { * 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. + * + * The whole dragged range is scrolled into view ONCE, before the drag + * starts — never mid-drag. A default-viewport (1280×720) run of a 6-row + * month puts the trailing week's cells below the fold (`getBoundingClientRect` + * still reports their real, off-screen `y`); moving the mouse to those + * coordinates without scrolling first sends it past the bottom of the + * actually-rendered viewport, where there is no element to receive + * `pointerenter` at all — the browser just stops delivering the event, and + * the drag's `dragCurrent` freezes on the last on-screen cell it entered + * while still climbing toward the target. Scrolling once up front (via the + * last, typically lowest, target date) keeps every waypoint's precomputed + * center valid for the whole gesture; scrolling again after `mouse.down()` + * would invalidate the coordinates of any cell already computed and risk + * disrupting the in-progress native pointer sequence. */ 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 lastDate = isoDates[isoDates.length - 1] + if (lastDate) { + await calendarDayCell(page, lastDate).scrollIntoViewIfNeeded() + } + 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?`) diff --git a/frontend/e2e/support/onboarding.ts b/frontend/e2e/support/onboarding.ts index 1a8dc7c..7c60e72 100644 --- a/frontend/e2e/support/onboarding.ts +++ b/frontend/e2e/support/onboarding.ts @@ -43,12 +43,38 @@ export async function fillChildStep(page: Page, childName: string): 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 page.getByTestId('onboarding-finish').click() await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 }) } + +/** From step 2 (family), selects "Vi är två föräldrar", fills in the other + * parent's name and continues — landing on step 3 (balances) with both + * parents' cards visible. */ +export async function chooseTwoParents(page: Page, partnerName: string): Promise { + await page.getByTestId('onboarding-family-couple').click() + await page.getByLabel('Den andra förälderns namn').fill(partnerName) + await page.getByRole('button', { name: 'Fortsätt' }).click() + await expect(page.getByTestId('onboarding-step-balances')).toBeVisible() +} + +/** In step 3 (balances), opens "Ange själv" on the parent card matching + * `name` and fills in + saves the three balance fields shown there. */ +export async function enterBalancesFor( + page: Page, + name: string, + balances: { sjukpenning: number; lagsta: number; dubbeldagar: number }, +): Promise { + const card = page.getByTestId('onboarding-parent-card').filter({ hasText: name }) + await card.getByRole('button', { name: 'Ange själv' }).click() + await page.getByLabel(/Sjukpenningnivå/).fill(String(balances.sjukpenning)) + await page.getByLabel(/Lägstanivå/).fill(String(balances.lagsta)) + await page.getByLabel(/Dubbeldagar/).fill(String(balances.dubbeldagar)) + await page.getByRole('button', { name: 'Spara' }).click() +} diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index be806a9..73beda7 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -1,74 +1,126 @@ /** * 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. + * dragging a range to create planned days, and deleting them again through + * the period 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. + * + * A drag no longer creates a `LeaveWish`/synced period on its own — it opens + * the day popover pre-selected on every dragged date, and saving writes + * exactly those days (ad-hoc `planned_leave_day` rows, no wish). See + * `Calendar.tsx`'s pointerup handler and `handleSavePlannedDay`. + * + * The drag deliberately crosses a month boundary (the last two days of one + * month into the first day of the next) — a regression guard for the + * leading/trailing-week gating bug, where `CalendarWeek.tsx` used to wire + * pointer handlers only for cells matching `weekDates[0]`'s month, so a + * trailing day belonging to the next month was inert and a drag could never + * reach it. * * 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 shared demo workspace, so the calendar starts with nothing on it. * * 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 { toIsoDate, addDays } from '../support/dates' import { uniqueEmail, registerAndReachOnboarding, fillChildStep, continueSoloAndSkipBalances } from '../support/onboarding' test.use({ storageState: { cookies: [], origins: [] } }) +/** + * A month, at least two months out from today, whose last day is NOT a + * Sunday — i.e. the grid actually renders a trailing-week row bleeding into + * the following month (a month that ends on Sunday has no trailing row at + * all after the "no phantom week" fix; see `Calendar.tsx`'s week-building + * loop). Returns that month plus the last two of its days and the first day + * of the next month — the exact range the drag below crosses. + */ 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] + let month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1)) + let lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0)) + while (lastDayOfMonth.getUTCDay() === 0 /* Sunday */) { + month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1)) + lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0)) + } + const dates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)].map(toIsoDate) as [ + string, + string, + string, + ] return { month, dates } } -test('dragging a range creates a period, and deleting it removes the days for good', async ({ page }) => { +test('dragging a range across a month boundary plans every day, 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 + const [firstDate, , lastDate] = 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 + const adhocBand = page.locator('[data-testid^="calendar-band-adhoc:"]') + await expect(adhocBand).toHaveCount(0) // a brand-new account — nothing here yet + // The drag spans the month's last two days plus the first day of the + // following month, rendered as a trailing cell in this same grid. 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) + // A multi-day drag opens the day popover pre-selected on every dragged + // date — it does not create a period/wish by itself. + await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar') + await page.getByTestId('save-button').click() + await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() - // 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() + // All three dragged days are one continuous ad-hoc PERIOD, but the grid + // renders a multi-week band as one DOM element per week ROW it crosses + // (see CalendarWeek.tsx's `computeBandSegments` and + // calendar-band.spec.ts) — here Nov 29 (a Sunday, the last column of its + // week) and Nov 30 (a Monday, the first column of the next) fall in + // different rows even though they're one calendar day apart. Both + // elements share the same `data-testid` (derived from the band's key + + // overall start date), which IS the "one period" assertion: a real + // second period would get its own id. + await expect(adhocBand.first()).toBeVisible() + await expect + .poll(async () => { + const testIds = await adhocBand.evaluateAll((els) => els.map((el) => el.getAttribute('data-testid'))) + return new Set(testIds).size + }) + .toBe(1) + + // Open it through its band — an ad-hoc band (no wishId) shows the plain + // date range and can only be deleted, not edited. `.first()` because the + // band may render as more than one DOM segment (see above); either one + // opens the same underlying period. + await adhocBand.first().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 expect(page.getByTestId('period-adhoc-notice')).toBeVisible() + await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`) await page.getByTestId('period-delete-button').click() await expect(page.getByTestId('period-delete-confirm')).toBeVisible() + + // Regression guard for the "Yes, delete" footer bug: the confirm button's + // label is long enough to wrap without `white-space: nowrap` on `.btn` + // (src/styles/primitives.css), which used to make it visibly taller than + // "Cancel" sitting next to it. Assert both the CSS guard and the rendered + // heights actually matching. + const cancelButton = page.getByTestId('period-delete-cancel-button') + const confirmButton = page.getByTestId('period-delete-confirm-button') + await expect(cancelButton).toHaveCSS('white-space', 'nowrap') + await expect(confirmButton).toHaveCSS('white-space', 'nowrap') + const [cancelBox, confirmBox] = await Promise.all([cancelButton.boundingBox(), confirmButton.boundingBox()]) + expect(cancelBox).not.toBeNull() + expect(confirmBox).not.toBeNull() + expect(confirmBox?.height).toBe(cancelBox?.height) + await page.getByTestId('period-delete-confirm-button').click() await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible() @@ -78,7 +130,7 @@ test('dragging a range creates a period, and deleting it removes the days for go // 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) + await expect(page.locator('[data-testid^="calendar-band-adhoc:"]')).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. diff --git a/frontend/e2e/tests/header-nav-hover.spec.ts b/frontend/e2e/tests/header-nav-hover.spec.ts new file mode 100644 index 0000000..1d53640 --- /dev/null +++ b/frontend/e2e/tests/header-nav-hover.spec.ts @@ -0,0 +1,48 @@ +/** + * Regression for a specificity bug: `.header__nav-link:hover` (a class + + * pseudo-class, specificity (0,2,0)) outranked `.header__nav-link--active` + * (a single class, (0,1,0)), so hovering the *active* tab kept the active + * rule's white (`--c-surface`) text but reverted the background to the + * hover rule's `--c-bg` — near-white text on a near-white background. + * `.header__nav-link[aria-current='page']:hover` now wins on specificity + * (attribute selector + pseudo-class, (0,2,0)) over the plain hover rule, + * and repaints the background with `--c-accent-hover` so the surface-coloured + * text stays readable. The active state is marked by `aria-current="page"` + * (set automatically by react-router-dom when the route matches). See `header.css`. + */ +import { test, expect, type Page } from '@playwright/test' + +/** + * Resolves a CSS custom property (declared on :root in styles/global.css) to + * the computed `rgb(...)` string the browser would report for an element + * actually painted with it — see the identical helper (and its rationale) + * in `red-days.spec.ts`. + */ +async function resolveCssVar(page: Page, varName: string): Promise { + return page.evaluate((name) => { + const probe = document.createElement('div') + probe.style.backgroundColor = `var(${name})` + probe.style.color = `var(${name})` + document.body.appendChild(probe) + const style = getComputedStyle(probe) + const resolved = style.backgroundColor + probe.remove() + return resolved + }, varName) +} + +test.describe('header nav — active tab stays readable on hover', () => { + test('hovering the active nav link keeps the text readable', async ({ page }) => { + await page.goto('/') + const activeLink = page.getByTestId('header-nav').getByRole('link', { name: 'Översikt' }) + await expect(activeLink).toHaveAttribute('aria-current', 'page') + + const accentHover = await resolveCssVar(page, '--c-accent-hover') + const surface = await resolveCssVar(page, '--c-surface') + + await activeLink.hover() + + await expect(activeLink).toHaveCSS('background-color', accentHover) + await expect(activeLink).toHaveCSS('color', surface) + }) +}) diff --git a/frontend/e2e/tests/onboarding.spec.ts b/frontend/e2e/tests/onboarding.spec.ts index 99f6828..2c5e584 100644 --- a/frontend/e2e/tests/onboarding.spec.ts +++ b/frontend/e2e/tests/onboarding.spec.ts @@ -1,9 +1,10 @@ /** * 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. + * optional partner -> 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). @@ -14,6 +15,8 @@ import { registerAndReachOnboarding, fillChildStep, continueSoloAndSkipBalances, + chooseTwoParents, + enterBalancesFor, } from '../support/onboarding' test.use({ storageState: { cookies: [], origins: [] } }) @@ -23,7 +26,7 @@ test.describe('onboarding wizard', () => { await registerAndReachOnboarding(page, uniqueEmail('onboarding-skip'), 'Alex Testsson') await fillChildStep(page, 'Nova') - // Solo is the wizard's default planning mode — nothing to change here. + // Adding a partner is optional — nothing to change here. await continueSoloAndSkipBalances(page) await expect(page.getByTestId('dashboard-estimate-badge')).toHaveText('Uppskattat') @@ -33,15 +36,9 @@ test.describe('onboarding wizard', () => { 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() + await chooseTwoParents(page, 'Kim') + await expect(page.getByTestId('onboarding-parent-card')).toHaveCount(2) + await page.getByTestId('onboarding-finish').click() // The regression this guards: finishing the wizard flips // WorkspaceContext's `children` from empty to non-empty, which @@ -59,4 +56,16 @@ test.describe('onboarding wizard', () => { await expect(age4Bars).toContainText('Kim') await expect(age4Bars).not.toContainText('Förälder') }) + + test('entering balances for both parents during the couple path carries through to Översikt', async ({ page }) => { + await registerAndReachOnboarding(page, uniqueEmail('onboarding-couple-balances'), 'Robin Testsson') + await fillChildStep(page, 'Nova') + + await chooseTwoParents(page, 'Kim') + await enterBalancesFor(page, 'Robin Testsson', { sjukpenning: 150, lagsta: 30, dubbeldagar: 20 }) + await enterBalancesFor(page, 'Kim', { sjukpenning: 100, lagsta: 20, dubbeldagar: 10 }) + await page.getByTestId('onboarding-finish').click() + + await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 }) + }) }) diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index 20a5e0b..2a3f7e2 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -166,20 +166,21 @@ export function PeriodPopover({ return (
e.stopPropagation()} data-testid="period-popover__content" > -
+

{title}

-
+
{(error || localError) && ( -
+
{error || localError}
)} @@ -206,9 +207,9 @@ export function PeriodPopover({ )} {wish && ( - <> -
-