// shared helpers for chore-scheduler spec files import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' // ─── Date Helpers ───────────────────────────────────────────────────────────── export function todayISO(): string { const d = new Date() return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } export function yesterdayISO(): string { const d = new Date() d.setDate(d.getDate() - 1) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } export function futureDateISO(daysAhead = 7): string { const d = new Date() d.setDate(d.getDate() + daysAhead) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } /** Today's weekday (0 = Sunday, 6 = Saturday) — matches DayConfig.day */ export function todayDayIndex(): number { return new Date().getDay() } /** A weekday guaranteed not to be today */ export function nonTodayDayIndex(): number { return (new Date().getDay() + 1) % 7 } // ─── API Helpers ────────────────────────────────────────────────────────────── export async function createChild(request: APIRequestContext, name: string): Promise { const pre = await request.get('/api/child/list') for (const c of (await pre.json()).children ?? []) { if (c.name === name) await request.delete(`/api/child/${c.id}`) } await request.put('/api/child/add', { data: { name, age: 8 } }) const list = await request.get('/api/child/list') return ( (await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ?? '' ) } export async function createChoreTask(request: APIRequestContext, name: string): Promise { const pre = await request.get('/api/task/list') for (const t of (await pre.json()).tasks ?? []) { if (t.name === name) await request.delete(`/api/task/${t.id}`) } await request.put('/api/task/add', { data: { name, points: 5, type: 'chore' } }) const list = await request.get('/api/task/list') return ( (await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? '' ) } export async function assignChore( request: APIRequestContext, childId: string, choreIds: string[], ): Promise { await request.put(`/api/child/${childId}/set-tasks`, { data: { task_ids: choreIds, type: 'chore' }, }) } export interface DaysScheduleOpts { hour?: number minute?: number hasDeadline?: boolean enabled?: boolean } export async function setDaysSchedule( request: APIRequestContext, childId: string, taskId: string, days: number[], opts: DaysScheduleOpts = {}, ): Promise { const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts const day_configs = days.map((day) => ({ day, hour, minute })) await request.put(`/api/child/${childId}/task/${taskId}/schedule`, { data: { mode: 'days', day_configs, default_hour: hour, default_minute: minute, default_has_deadline: hasDeadline, enabled, }, }) } export interface IntervalScheduleOpts { hour?: number minute?: number hasDeadline?: boolean enabled?: boolean } export async function setIntervalSchedule( request: APIRequestContext, childId: string, taskId: string, intervalDays: number, anchorDate: string, opts: IntervalScheduleOpts = {}, ): Promise { const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts await request.put(`/api/child/${childId}/task/${taskId}/schedule`, { data: { mode: 'interval', interval_days: intervalDays, anchor_date: anchorDate, interval_has_deadline: hasDeadline, interval_hour: hour, interval_minute: minute, enabled, }, }) } export async function deleteSchedule( request: APIRequestContext, childId: string, taskId: string, ): Promise { // 404 is acceptable (no schedule existed) await request.delete(`/api/child/${childId}/task/${taskId}/schedule`) } // ─── UI Helpers ─────────────────────────────────────────────────────────────── /** Locator for the Chores section */ export function choreSection(page: Page): Locator { return page.locator('.child-list-container').filter({ has: page.locator('h3', { hasText: 'Chores' }), }) } /** Locator for a named chore card in the Chores section */ export function choreCard(page: Page, name: string): Locator { return choreSection(page) .locator('.item-card') .filter({ has: page.locator('.item-name').getByText(name, { exact: true }), }) } /** Click the chore card (select it), wait for ready state, then click Options */ export async function openChoreOptions(page: Page, card: Locator): Promise { await card.click() await expect(card).toHaveClass(/item-ready/, { timeout: 3000 }) await card.getByRole('button', { name: 'Options' }).click() } /** Open the schedule modal for a named chore card */ export async function openScheduleModal(page: Page, card: Locator): Promise { await openChoreOptions(page, card) await page.getByRole('button', { name: 'Schedule' }).click() await expect(page.getByText('Schedule Chore')).toBeVisible() } /** * Set a TimePickerPopover to a specific time. * @param timePicker - the `.time-picker-popover` locator wrapping the popover */ export async function setTimePicker( page: Page, timePicker: Locator, hour12: number, minute: number, period: 'AM' | 'PM', ): Promise { await timePicker.locator('.time-display').click() // Scope panel to this specific time picker to avoid strict-mode violations const panel = timePicker.locator('.popover-panel') await panel.waitFor() // Use exact regex patterns — getByRole name uses substring matching by default, // so "3" would also match "30" and "1" would match "10", "11", "12" etc. await panel.getByRole('button', { name: new RegExp(`^${period}$`) }).click() await panel.getByRole('button', { name: new RegExp(`^${hour12}$`) }).click() await panel .getByRole('button', { name: new RegExp(`^${String(minute).padStart(2, '0')}$`) }) .click() // Close popover by clicking the modal title (outside popover bounds) await page.locator('.modal-title').click() await panel.waitFor({ state: 'hidden' }) } /** Set the hidden date input and fire a change event */ export async function setDateInput(page: Page, isoDate: string): Promise { await page.locator('input.date-input-hidden').evaluate((el: HTMLInputElement, val: string) => { el.value = val el.dispatchEvent(new Event('change', { bubbles: true })) }, isoDate) } /** * Navigate to the child view for a given child. * Parent-authenticated users are blocked from /child by the auth guard, so we * must clear the parentAuth localStorage key before navigating there. */ export async function goToChildView(page: Page, childId: string): Promise { // Go to the app domain first so we can access its localStorage await page.goto(`/parent/${childId}`) // Clear the parent auth token — router will now allow /child/:id await page.evaluate(() => localStorage.removeItem('parentAuth')) // Navigate to child view and wait for the chore section to appear await page.goto(`/child/${childId}`) await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 }) } /** * Navigate (back) to the parent view from any page. * Restores the parentAuth localStorage key so the router allows /parent/:id. */ export async function goToParentView(page: Page, childId: string): Promise { // Restore parent auth so the router guard allows /parent/:id await page.evaluate(() => { localStorage.setItem( 'parentAuth', JSON.stringify({ expiresAt: Date.now() + 2 * 24 * 60 * 60 * 1000 }), ) }) await page.goto(`/parent/${childId}`) await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 }) }