Files
F-r-ldraledigplannerare/frontend/e2e/tests/drag-create-delete.spec.ts
T
jacobh2 2c46cf762d Stop two stale ids reaching the API, and steer the drag test off free days
Post-merge verification of the six merged branches turned up two real
frontend defects and one stale test expectation.

Settings mounts before WorkspaceContext has populated, so PartnerInvitePanel
asked for invitations with an empty workspace id. The empty path segment
collapses in Vite's proxy, FastAPI matches the single-segment workspace route
with workspace_id="invitations", and the UUID parse 422s. Guarded the same way
useWorkspaceMembers already does.

PlanSyncProvider could sync a workspace id seeded from localStorage before
WorkspaceContext refreshed it — a workspace a reseed had since deleted. It
logged the resulting 404 and kept retrying it on every planVersion bump. A 404
now refreshes the workspace context instead, correcting the id rather than
narrating the failure forever.

The drag-create-delete expectation was simply out of date: vacation-typed saves
correctly exclude free days, and targetRange() only skipped months ending on a
Sunday, so a Monday-ending month put a Sunday in the range. It now skips any
free day, computed independently of the calendar data the backend itself reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:22:30 +02:00

268 lines
14 KiB
TypeScript

/**
* The two authoring gestures the calendar rework is actually about:
* 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 opens the day popover pre-selected on every dragged date, same as
* before. What that popover does on save changed (todo.md #10/#11): by
* default it now creates a fixed, one-off `LeaveWish` and lets the planner
* fund it (a `calendar-band-wish:` band, not `adhoc:`) — see
* `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc
* `planned_leave_day` rows" behaviour still exists (`handleSavePinnedDay`):
* a solo household reaches it by picking a concrete type in the modal's
* type select (a couple through the "Advanced" disclosure) — the first
* test below does so explicitly to keep exercising that path.
*
* 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, 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, addDays, isSwedishFreeDay } 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.
*
* None of the three dates may be a free day (weekend or red day): the
* first test below saves them with an explicit `vacation` day type, and
* the second lets the planner fund them as a wish — both deliberately drop
* free days from what actually gets persisted (see `handleSavePinnedDay`
* in `src/pages/Calendar.tsx` and the backend's `PlanningService.
* _reject_illegal_free_day`/`is_free_day`). Checking only the month's last
* day for a Sunday missed two cases: (1) that day being a Monday instead,
* which makes `dates[0]` (the day before it) a Sunday, and (2) a red day
* landing on any of the three regardless of weekday (e.g. the month rolls
* into a New Year's Day) — either would silently persist/fund fewer than
* three days, which the assertions below don't expect.
*/
function targetRange(): { month: Date; dates: [string, string, string] } {
const now = new Date()
let month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1))
let lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
let candidateDates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)]
while (candidateDates.some(isSwedishFreeDay)) {
month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1))
lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
candidateDates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)]
}
const dates = candidateDates.map(toIsoDate) as [string, string, string]
return { month, dates }
}
test('dragging a range and pinning an exact type plans every day exactly, 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 [firstDate, , lastDate] = dates
await goToCalendarMonth(page, month)
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 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')
// The default save path ("let the planner choose") would create a fixed
// wish instead (see the next test) — picking a concrete type keeps this
// test on the exact-pinned-days path todo.md #10/#11 kept as an escape
// hatch. A solo account has no "Advanced" toggle: the select is right
// there.
await page.getByTestId('day-type-select').selectOption('vacation')
await page.getByTestId('save-button').click()
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
// 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) gets the full
// editor too (§"every calendar band is a real event"), seeded from its own
// pinned days; saving it would promote it to a real `LeaveWish`. This test
// only exercises delete, so it stops at the ad-hoc notice/seeded dates.
// `.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-adhoc-notice')).toBeVisible()
await expect(page.getByTestId('period-start-input')).toHaveValue(firstDate)
await expect(page.getByTestId('period-end-input')).toHaveValue(lastDate)
// todo.md #9 — the three delete scopes sit behind "Ta bort…". An ad-hoc
// band has no recurring plan, so "every year" is disabled; "this day"/
// "this period" are the only meaningful choices, and "this period"
// removes the whole ad-hoc range (today's only behaviour).
await page.getByTestId('period-delete-menu-button').click()
await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled()
await page.getByTestId('period-delete-year-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()
// 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-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.
await calendarDayCell(page, dates[1]).click()
await expect(page.getByTestId('modal-title')).toHaveText('Planera en dag')
})
test('a drag saved without opening Advanced creates a planner-funded wish, and the three-way remove deletes it', async ({
page,
}) => {
// todo.md #10/#11 — the default path no longer writes pinned days
// directly: it creates a fixed, one-off `LeaveWish` and lets the sync
// fund it, so the resulting band is wish-linked (`calendar-band-wish:`),
// not `adhoc:`.
await registerAndReachOnboarding(page, uniqueEmail('drag-wish'), 'Robin Testsson')
await fillChildStep(page, 'Nova')
await continueSoloAndSkipBalances(page)
const { month, dates } = targetRange()
await goToCalendarMonth(page, month)
const wishBand = page.locator('[data-testid^="calendar-band-wish:"]')
await expect(wishBand).toHaveCount(0)
await dragSelectDates(page, dates)
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
// A solo account has no parent-mode selector to touch — save directly.
await page.getByTestId('save-button').click()
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
await expect(wishBand.first()).toBeVisible()
// Open the wish-linked band. Its own three-way remove: "this day" now
// works on a planner-placed day too (the removal is recorded as a
// `DayAssertion` with `dayType: 'none'` — see the next test); "this period" on a
// standalone (non-repeating) wish — one row, no series — removes the whole
// thing, same as "every year".
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-menu-button').click()
await expect(page.getByTestId('period-delete-day-button')).toBeEnabled()
await page.getByTestId('period-delete-year-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()
await page.reload()
await goToCalendarMonth(page, month)
await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0)
})
test('removing a single day out of a planner-placed period keeps it removed across syncs', async ({
page,
}) => {
// The user's requirement: "removing a single day should stay permanent.
// Even if it's an auto added period day." The wish behind the band still
// asks for the whole window, so without a stored `DayAssertion` (`dayType:
// 'none'`) the next `POST /api/plan/sync` — which the calendar runs on every load —
// would simply put the day back.
await registerAndReachOnboarding(page, uniqueEmail('remove-day'), 'Robin Testsson')
await fillChildStep(page, 'Nova')
await continueSoloAndSkipBalances(page)
const { month, dates } = targetRange()
await goToCalendarMonth(page, month)
const wishBand = page.locator('[data-testid^="calendar-band-wish:"]')
await expect(wishBand).toHaveCount(0)
await dragSelectDates(page, dates)
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()
await expect(wishBand.first()).toBeVisible()
// Clicking the band itself targets its first placed date; clicking one of
// its day slots would target that date instead (see CalendarWeek's
// `clickedDate`). Either way the popover's "this day" removes exactly one.
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-menu-button').click()
await page.getByTestId('period-delete-day-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()
// There is a way back, offered immediately.
await expect(page.getByTestId('calendar-toast-undo')).toBeVisible()
// The removed date is marked rather than silently empty — a hole in an
// otherwise-funded week would read as a planner bug.
await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1)
// The rest of the period survives: this removed one day, not the wish.
await expect(wishBand.first()).toBeVisible()
// The real assertion: a reload re-syncs the plan from the wish, and the
// day must still be gone afterwards.
await page.reload()
await goToCalendarMonth(page, month)
await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1)
await expect(page.locator('[data-testid^="calendar-band-wish:"]').first()).toBeVisible()
})