feat: add enable/disable toggle for chore scheduling in ScheduleModal
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
- Introduced a toggle button to enable or disable chores in the scheduler. - The toggle will be available in both types of schedulers. - Added UI design proposal and considerations for mobile dimensions. - Included E2E tests to ensure toggle state persistence and correct behavior in child view.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 6: Child Mode Chore Visibility & States
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
choreSection,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ChildCardChild'
|
||||
const CHILD_ALWAYS = 'ChildAlwaysChore'
|
||||
const CHILD_TODAY = 'ChildTodayChore'
|
||||
const CHILD_NOT_TODAY = 'ChildNotTodayChore'
|
||||
const CHILD_EXPIRED = 'ChildExpiredChore'
|
||||
const CHILD_ANYTIME = 'ChildAnytimeChore'
|
||||
|
||||
test.describe('Child Mode — Chore Visibility and Schedule States', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let alwaysId = ''
|
||||
let todayId = ''
|
||||
let notTodayId = ''
|
||||
let expiredId = ''
|
||||
let anytimeId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
alwaysId = await createChoreTask(request, CHILD_ALWAYS)
|
||||
todayId = await createChoreTask(request, CHILD_TODAY)
|
||||
notTodayId = await createChoreTask(request, CHILD_NOT_TODAY)
|
||||
expiredId = await createChoreTask(request, CHILD_EXPIRED)
|
||||
anytimeId = await createChoreTask(request, CHILD_ANYTIME)
|
||||
|
||||
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// ChildTodayChore: scheduled for today, deadline 23:59
|
||||
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
|
||||
|
||||
// ChildNotTodayChore: scheduled for a non-today day
|
||||
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
// ChildExpiredChore: scheduled for today, deadline 00:01 (guaranteed expired)
|
||||
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||
|
||||
// ChildAnytimeChore: scheduled for today, no deadline
|
||||
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
|
||||
|
||||
// ChildAlwaysChore: no schedule
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('6.1 Chore with no schedule — visible normally in child mode', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_ALWAYS)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('6.2 Chore scheduled for today — visible with "Due by" label', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_TODAY)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM')
|
||||
})
|
||||
|
||||
test('6.3 Chore not scheduled for today — HIDDEN in child mode', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
|
||||
// Wait for the chores section to fully load (uses other cards as anchor)
|
||||
const alwaysCard = choreCard(page, CHILD_ALWAYS)
|
||||
await expect(alwaysCard).toBeVisible()
|
||||
|
||||
// Off-day chore must not appear at all
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: CHILD_NOT_TODAY }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('6.4 Chore with expired deadline — grayed out with TOO LATE badge (not hidden)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_EXPIRED)
|
||||
|
||||
// Expired chore is still visible (was scheduled for today)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
})
|
||||
|
||||
test('6.5 Chore with "Anytime" — visible, no badge, no "Due by"', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_ANYTIME)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('6.6 No kebab menu or schedule controls in child mode', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_TODAY)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Click the card — no Options button should appear
|
||||
await card.click()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 8: Extend Time
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
openChoreOptions,
|
||||
todayDayIndex,
|
||||
todayISO,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ExtendTimeChild'
|
||||
const EXPIRED_NAME = 'ExtendTimeChore'
|
||||
const FUTURE_NAME = 'FutureDeadlineChore'
|
||||
|
||||
test.describe('Extend Time', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let expiredId = ''
|
||||
let futureId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
expiredId = await createChoreTask(request, EXPIRED_NAME)
|
||||
futureId = await createChoreTask(request, FUTURE_NAME)
|
||||
|
||||
await assignChore(request, childId, [expiredId, futureId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
|
||||
// ExpiredChore: scheduled for today, deadline at 00:01 (guaranteed past)
|
||||
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||
|
||||
// FutureChore: scheduled for today, deadline at 23:59 (not expired)
|
||||
await setDaysSchedule(request, childId, futureId, [today], { hour: 23, minute: 59 })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (expiredId) await request.delete(`/api/task/${expiredId}`)
|
||||
if (futureId) await request.delete(`/api/task/${futureId}`)
|
||||
})
|
||||
|
||||
test('8.1 "Extend Time" appears in kebab menu only when chore is expired', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Expired chore shows TOO LATE badge
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Extend Time' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('8.2 "Extend Time" is absent when chore is not expired', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, FUTURE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.3 Clicking "Extend Time" removes the TOO LATE badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
|
||||
await openChoreOptions(page, card)
|
||||
await page.getByRole('button', { name: 'Extend Time' }).click()
|
||||
|
||||
// After extension the badge disappears and card is no longer grayed out
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('8.4 Extended chore no longer shows "Due by" label', async ({ page }) => {
|
||||
// Card was extended in 8.3 so this verifies the extension persists
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.5 "Extend Time" disappears from kebab after extension', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Chore was extended in 8.3 — should no longer be expired
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.6 Extension effect visible in child mode', async ({ page }) => {
|
||||
// After extending in parent mode (done in 8.3), verify child view
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
|
||||
// Chore is still visible (was scheduled for today + extended) and no TOO LATE badge
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.6b Extending same chore twice via API returns 409', async ({ request }) => {
|
||||
// The chore was already extended in 8.3; a second API extension should fail
|
||||
const res = await request.post(`/api/child/${childId}/task/${expiredId}/extend`, {
|
||||
data: { date: todayISO() },
|
||||
})
|
||||
expect(res.status()).toBe(409)
|
||||
})
|
||||
})
|
||||
233
frontend/vue-app/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
233
frontend/vue-app/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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 })
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 9: Interval Mode Anchor Date Logic
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setIntervalSchedule,
|
||||
choreCard,
|
||||
choreSection,
|
||||
todayISO,
|
||||
yesterdayISO,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'IntervalAnchorChild'
|
||||
const CHORE_NAME = 'IntervalAnchorChore'
|
||||
|
||||
test.describe('Schedule Interval Mode — Anchor Date Logic', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test('9.1 Interval every 1 day starting today — chore active today', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
})
|
||||
|
||||
// Parent mode: not grayed out
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, CHORE_NAME)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: visible
|
||||
await goToChildView(page, childId)
|
||||
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('9.2 Interval every 2 days starting yesterday — chore NOT active today', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// anchor=yesterday, interval=2: yesterday+2=tomorrow → today is skipped
|
||||
await setIntervalSchedule(request, childId, choreId, 2, yesterdayISO(), {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
})
|
||||
|
||||
// Parent mode: grayed out (not hitting today)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, CHORE_NAME)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: hidden (not scheduled for today)
|
||||
await goToChildView(page, childId)
|
||||
// Wait for list to finish loading — the chore should be filtered out
|
||||
await expect(choreSection(page).locator('.empty, .scroll-wrapper')).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('9.3 Interval every 2 days starting today — active today', async ({ page, request }) => {
|
||||
// anchor=today, interval=2: diffDays=0, 0%2=0 → hits today
|
||||
await setIntervalSchedule(request, childId, choreId, 2, todayISO(), {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
})
|
||||
|
||||
// Parent mode: not grayed out
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, CHORE_NAME)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: visible
|
||||
await goToChildView(page, childId)
|
||||
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('9.4 Interval "Anytime" deadline — no "Due by" label', async ({ page, request }) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
|
||||
hasDeadline: false,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Anytime = no due label
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 5: Parent Mode Chore Card Schedule States
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ParentCardChild'
|
||||
const ALWAYS_ACTIVE = 'AlwaysActiveChore'
|
||||
const SCHEDULED_TODAY = 'ScheduledTodayChore'
|
||||
const NOT_SCHEDULED_TODAY = 'NotScheduledTodayChore'
|
||||
const EXPIRED_CHORE = 'ExpiredChore'
|
||||
const ANYTIME_CHORE = 'AnytimeChore'
|
||||
|
||||
test.describe('Parent Mode — Chore Card Schedule States', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let alwaysId = ''
|
||||
let todayId = ''
|
||||
let notTodayId = ''
|
||||
let expiredId = ''
|
||||
let anytimeId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
alwaysId = await createChoreTask(request, ALWAYS_ACTIVE)
|
||||
todayId = await createChoreTask(request, SCHEDULED_TODAY)
|
||||
notTodayId = await createChoreTask(request, NOT_SCHEDULED_TODAY)
|
||||
expiredId = await createChoreTask(request, EXPIRED_CHORE)
|
||||
anytimeId = await createChoreTask(request, ANYTIME_CHORE)
|
||||
|
||||
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// ScheduledTodayChore: scheduled for today, deadline 23:59 (far future)
|
||||
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
|
||||
|
||||
// NotScheduledTodayChore: scheduled for a non-today day
|
||||
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
// ExpiredChore: scheduled for today, deadline 00:01 (guaranteed past)
|
||||
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||
|
||||
// AnytimeChore: scheduled for today, no deadline
|
||||
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
|
||||
|
||||
// AlwaysActiveChore: no schedule (already default)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('5.1 Chore with no schedule — normal appearance, no annotations', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, ALWAYS_ACTIVE)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.chore-stamp')).not.toBeVisible()
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('5.2 Chore scheduled for today — shows "Due by" label, not grayed out', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, SCHEDULED_TODAY)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('5.3 Chore not scheduled for today — grayed out in parent mode but visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, NOT_SCHEDULED_TODAY)
|
||||
await card.waitFor()
|
||||
|
||||
// Always visible in parent mode
|
||||
await expect(card).toBeVisible()
|
||||
// Grayed out because off-day
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
// No TOO LATE badge (not expired — just off-day)
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('5.4 Chore with expired deadline — grayed out with TOO LATE badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_CHORE)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
})
|
||||
|
||||
test('5.5 Chore with "Anytime" deadline — no "Due by" label, no TOO LATE badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, ANYTIME_CHORE)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('5.6 Kebab menu always accessible on grayed-out chores', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, NOT_SCHEDULED_TODAY)
|
||||
await card.waitFor()
|
||||
|
||||
// Grayed out card still has Options button after clicking
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||
|
||||
// Schedule modal opens normally
|
||||
await page.getByRole('button', { name: 'Schedule' }).click()
|
||||
await expect(page.getByText('Schedule Chore')).toBeVisible()
|
||||
})
|
||||
|
||||
test('5.7 Expired chore cannot be triggered by tapping — no confirm dialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_CHORE)
|
||||
await card.waitFor()
|
||||
|
||||
// First click selects the card even when expired
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
|
||||
// Second click attempts to trigger — but expired chores are blocked in triggerTask()
|
||||
await card.click()
|
||||
// Confirm dialog should NOT appear for expired chores
|
||||
await expect(page.locator('.modal-dialog')).not.toBeVisible({ timeout: 1000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 7: Parent vs Child Mode Comparison
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
choreSection,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'CompareChild'
|
||||
const VISIBLE_BOTH = 'VisibleBothChore'
|
||||
const PARENT_ONLY = 'ParentOnlyChore'
|
||||
|
||||
test.describe('Parent vs Child Mode Comparison', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let visibleBothId = ''
|
||||
let parentOnlyId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
visibleBothId = await createChoreTask(request, VISIBLE_BOTH)
|
||||
parentOnlyId = await createChoreTask(request, PARENT_ONLY)
|
||||
|
||||
await assignChore(request, childId, [visibleBothId, parentOnlyId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// VisibleBothChore: scheduled for today (visible everywhere)
|
||||
await setDaysSchedule(request, childId, visibleBothId, [today], { hour: 23, minute: 59 })
|
||||
|
||||
// ParentOnlyChore: scheduled for a non-today day (grayed/hidden)
|
||||
await setDaysSchedule(request, childId, parentOnlyId, [nonToday], { hour: 8, minute: 0 })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (visibleBothId) await request.delete(`/api/task/${visibleBothId}`)
|
||||
if (parentOnlyId) await request.delete(`/api/task/${parentOnlyId}`)
|
||||
})
|
||||
|
||||
test('7.1 Parent mode shows all chores including off-day ones', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const visibleCard = choreCard(page, VISIBLE_BOTH)
|
||||
const parentOnlyCard = choreCard(page, PARENT_ONLY)
|
||||
|
||||
await visibleCard.waitFor()
|
||||
await parentOnlyCard.waitFor()
|
||||
|
||||
// Both visible in parent mode
|
||||
await expect(visibleCard).toBeVisible()
|
||||
await expect(parentOnlyCard).toBeVisible()
|
||||
|
||||
// parentOnly is grayed out (off-day), visible is not
|
||||
await expect(parentOnlyCard).toHaveClass(/chore-inactive/)
|
||||
await expect(visibleCard).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('7.2 Child mode hides off-day chores', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
|
||||
const visibleCard = choreCard(page, VISIBLE_BOTH)
|
||||
await expect(visibleCard).toBeVisible()
|
||||
|
||||
// Off-day chore is hidden entirely
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: PARENT_ONLY }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('7.3 Adding a schedule via parent mode hides chore in child mode on wrong day', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// Remove today's schedule from VisibleBothChore and set it to non-today
|
||||
await setDaysSchedule(request, childId, visibleBothId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
await goToChildView(page, childId)
|
||||
|
||||
// VisibleBothChore is now off-day — should be hidden in child mode
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: VISIBLE_BOTH }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('7.4 Removing a schedule via parent mode shows chore in child mode again', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Delete the schedule via API (acts as if saved with no days)
|
||||
await deleteSchedule(request, childId, visibleBothId)
|
||||
|
||||
await goToChildView(page, childId)
|
||||
|
||||
// No schedule = always visible in child mode
|
||||
const card = choreCard(page, VISIBLE_BOTH)
|
||||
await expect(card).toBeVisible()
|
||||
})
|
||||
|
||||
test('7.5 Paused schedule — chore visible in both modes', async ({ page, request }) => {
|
||||
// Re-add a non-today schedule, but paused (enabled=false)
|
||||
await setDaysSchedule(request, childId, parentOnlyId, [nonTodayDayIndex()], {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
// Parent mode: visible and NOT grayed out (paused = acts unscheduled)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, PARENT_ONLY)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).toBeVisible()
|
||||
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: also visible (paused = shows every day)
|
||||
await goToChildView(page, childId)
|
||||
const childCard = choreCard(page, PARENT_ONLY)
|
||||
await expect(childCard).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 2: Schedule Modal Specific Days Mode
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
setTimePicker,
|
||||
nonTodayDayIndex,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleDaysChild'
|
||||
const CHORE_NAME = 'ScheduleDaysChore'
|
||||
|
||||
test.describe('Schedule Modal — Specific Days mode', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('2.1 Clicking day chips toggles them active/inactive', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
const moChip = page.locator('.day-chips').getByRole('button', { name: 'Mo' })
|
||||
await moChip.click()
|
||||
await expect(moChip).toHaveClass(/active/)
|
||||
|
||||
await moChip.click()
|
||||
await expect(moChip).not.toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('2.2 Default deadline row appears when at least one day is selected', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await expect(page.locator('.default-deadline-row')).not.toBeVisible()
|
||||
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
|
||||
await expect(page.locator('.default-deadline-row')).toBeVisible()
|
||||
await expect(page.locator('.default-deadline-row')).toContainText('Deadline')
|
||||
})
|
||||
|
||||
test('2.3 Default deadline "Anytime" toggle hides and restores time picker', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select a day to show the deadline row
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
|
||||
await expect(page.locator('.default-deadline-row')).toBeVisible()
|
||||
|
||||
// Time picker is visible by default
|
||||
await expect(
|
||||
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||
).toBeVisible()
|
||||
|
||||
// Click "Clear (Anytime)" — time picker hides, "Anytime" label appears
|
||||
await page
|
||||
.locator('.default-deadline-row')
|
||||
.getByRole('button', { name: 'Clear (Anytime)' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||
).not.toBeVisible()
|
||||
await expect(page.locator('.default-deadline-row').getByText('Anytime')).toBeVisible()
|
||||
|
||||
// Click "Set deadline" — time picker reappears
|
||||
await page
|
||||
.locator('.default-deadline-row')
|
||||
.getByRole('button', { name: 'Set deadline' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('2.4 Exception time — "Set different time" creates per-day override', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select Monday and Wednesday
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
|
||||
|
||||
// Wednesday row: click "Set different time"
|
||||
const weRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
|
||||
})
|
||||
await weRow.getByRole('button', { name: 'Set different time' }).click()
|
||||
|
||||
// Wednesday now shows its own time picker
|
||||
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
|
||||
|
||||
// Monday row still shows the default label (no time picker override)
|
||||
const moRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
|
||||
})
|
||||
await expect(moRow.locator('.default-label')).toBeVisible()
|
||||
await expect(moRow.locator('.time-picker-popover')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('2.5 Exception time — "Reset to default" removes the override', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Set up Monday + Wednesday, add Wednesday exception
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
|
||||
const weRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
|
||||
})
|
||||
await weRow.getByRole('button', { name: 'Set different time' }).click()
|
||||
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
|
||||
|
||||
// Reset Wednesday to default
|
||||
await weRow.getByRole('button', { name: 'Reset to default' }).click()
|
||||
await expect(weRow.locator('.default-label')).toBeVisible()
|
||||
await expect(weRow.locator('.time-picker-popover')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('2.6 Save with specific days — schedule persists on reopen', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select Monday and Friday
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Fr' }).click()
|
||||
|
||||
// Set deadline to 03:00 PM
|
||||
const timePicker = page.locator('.default-deadline-row').locator('.time-picker-popover')
|
||||
await setTimePicker(page, timePicker, 3, 0, 'PM')
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
|
||||
'03:00 PM',
|
||||
)
|
||||
})
|
||||
|
||||
test('2.7 Save with zero days deletes the schedule', async ({ page, request }) => {
|
||||
// Seed: create a days schedule so the modal opens with existing days
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 9, minute: 0 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Deselect all active chips
|
||||
const chips = page.locator('.day-chips').getByRole('button')
|
||||
for (const chip of await chips.all()) {
|
||||
const cls = await chip.getAttribute('class')
|
||||
if (cls?.includes('active')) await chip.click()
|
||||
}
|
||||
|
||||
// Save (removing schedule)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify no days selected
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
for (const chip of await page.locator('.day-chips').getByRole('button').all()) {
|
||||
await expect(chip).not.toHaveClass(/active/)
|
||||
}
|
||||
})
|
||||
|
||||
test('2.8 Dirty detection — changing days enables Save', async ({ page, request }) => {
|
||||
const nonToday = nonTodayDayIndex()
|
||||
await setDaysSchedule(request, childId, choreId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save should be disabled (nothing changed yet)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
// Toggle a different day chip → dirty
|
||||
const otherDay = (nonToday + 1) % 7
|
||||
const dayLabels = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
await page.locator('.day-chips').getByRole('button', { name: dayLabels[otherDay] }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 4: Schedule Enable/Disable Toggle
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleToggleChild'
|
||||
const CHORE_NAME = 'ScheduleToggleChore'
|
||||
|
||||
test.describe('Schedule Enable/Disable Toggle', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('4.1 Toggle row is visible in the schedule modal', async ({ page, request }) => {
|
||||
// Seed a schedule so the modal has something to show
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await expect(page.locator('.schedule-toggle-row')).toBeVisible()
|
||||
await expect(page.getByRole('switch')).toBeVisible()
|
||||
await expect(page.locator('.toggle-label')).toBeVisible()
|
||||
})
|
||||
|
||||
test('4.2 Toggling OFF shows "Paused" label and dims the form body', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle is ON initially
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Toggle OFF
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
|
||||
// Form body dims
|
||||
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
|
||||
|
||||
// Cancel/Save buttons remain interactive
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('4.3 Toggling ON restores "Enabled" label and form body', async ({ page, request }) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle OFF then back ON
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
await page.getByRole('switch').click()
|
||||
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||
await expect(page.locator('.schedule-body')).not.toHaveClass(/disabled/)
|
||||
})
|
||||
|
||||
test('4.4 Save paused state — persists on reopen', async ({ page, request }) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle OFF and save
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
|
||||
})
|
||||
|
||||
test('4.5 Dirty detection — toggling enabled state enables/disables Save', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save is disabled initially
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
// Toggle OFF → dirty
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
|
||||
// Toggle back ON → not dirty again
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('4.6 Paused schedule in parent mode — chore is visible and not grayed out', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Schedule for a non-today weekday, but paused (enabled=false)
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Chore is visible
|
||||
await expect(card).toBeVisible()
|
||||
// Paused = acts like unscheduled = not grayed out
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('4.7 Paused schedule in child mode — chore is visible', async ({ page, request }) => {
|
||||
// Non-today schedule, paused
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
|
||||
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
// Paused = isScheduledToday returns true → chore is visible in child mode
|
||||
await expect(card).toBeVisible()
|
||||
})
|
||||
|
||||
test('4.8 Paused schedule shows no "Due by" label', async ({ page, request }) => {
|
||||
// Schedule for today with a deadline, but paused
|
||||
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Paused = getDueTimeToday returns null → no "Due by" label
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 3: Schedule Modal Every X Days Mode
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setIntervalSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
setTimePicker,
|
||||
setDateInput,
|
||||
todayISO,
|
||||
futureDateISO,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleIntervalChild'
|
||||
const CHORE_NAME = 'ScheduleIntervalChore'
|
||||
|
||||
test.describe('Schedule Modal — Every X Days mode', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('3.1 Interval stepper increments and decrements within 1–7', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Default value is 1
|
||||
await expect(page.locator('.stepper-value')).toHaveText('1')
|
||||
|
||||
// Decrement is disabled at 1
|
||||
const decBtn = page.locator('.stepper').getByRole('button').first()
|
||||
await expect(decBtn).toBeDisabled()
|
||||
|
||||
// Increment 6 times → value becomes 7
|
||||
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||
for (let i = 0; i < 6; i++) await incBtn.click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('7')
|
||||
|
||||
// Increment is disabled at 7
|
||||
await expect(incBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
test("3.2 Anchor date field shows today's date by default", async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
await expect(page.locator('input.date-input-hidden')).toHaveValue(todayISO())
|
||||
})
|
||||
|
||||
test('3.3 Next occurrence preview label updates correctly', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Set interval to 2 (click + once from default 1)
|
||||
await page.locator('.stepper').getByRole('button').last().click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('2')
|
||||
|
||||
// Ensure anchor is today
|
||||
await setDateInput(page, todayISO())
|
||||
|
||||
// Next occurrence row should be visible
|
||||
await expect(page.locator('.next-occurrence-label')).toBeVisible()
|
||||
await expect(page.locator('.next-occurrence-label')).toContainText('Next occurrence:')
|
||||
})
|
||||
|
||||
test('3.4 Interval deadline "Anytime" toggle works', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Time picker visible by default
|
||||
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
|
||||
|
||||
// Click "Clear (Anytime)"
|
||||
await page
|
||||
.locator('.interval-time-row')
|
||||
.getByRole('button', { name: 'Clear (Anytime)' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('.interval-time-row').locator('.time-picker-popover'),
|
||||
).not.toBeVisible()
|
||||
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
|
||||
|
||||
// Click "Set deadline" — time picker reappears
|
||||
await page.locator('.interval-time-row').getByRole('button', { name: 'Set deadline' }).click()
|
||||
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
|
||||
})
|
||||
|
||||
test('3.5 Save interval schedule — persists on reopen', async ({ page }) => {
|
||||
const anchor = futureDateISO(60) // 60 days ahead — well past today min constraint
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Set interval to 3 (click + twice from default 1)
|
||||
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||
await incBtn.click()
|
||||
await incBtn.click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('3')
|
||||
|
||||
// Set anchor date
|
||||
await setDateInput(page, anchor)
|
||||
|
||||
// Set deadline to 04:30 PM
|
||||
const timePicker = page.locator('.interval-time-row').locator('.time-picker-popover')
|
||||
await setTimePicker(page, timePicker, 4, 30, 'PM')
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||
await expect(page.locator('.stepper-value')).toHaveText('3')
|
||||
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchor)
|
||||
await expect(page.locator('.interval-time-row').locator('.time-display')).toHaveText('04:30 PM')
|
||||
})
|
||||
|
||||
test('3.6 Dirty detection — changing interval enables Save', async ({ page, request }) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 3, futureDateISO(30))
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save is disabled (no changes yet)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
// Change interval from 3 to 5 (click + twice)
|
||||
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||
await incBtn.click()
|
||||
await incBtn.click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('5')
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 11: Schedule Loading / Backward Compatibility
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setIntervalSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
futureDateISO,
|
||||
nonTodayDayIndex,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'LoadCompatChild'
|
||||
const CHORE_NAME = 'LoadCompatChore'
|
||||
|
||||
test.describe('Schedule Loading — Backward Compatibility', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('11.1 Schedule without "enabled" field defaults to enabled in modal', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Create schedule via raw API call — omit the `enabled` field
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
// enabled intentionally omitted — backend defaults to true
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle should default to "Enabled"
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||
})
|
||||
|
||||
test('11.2 Schedule with interval_has_deadline=false shows "Anytime" in modal', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 2, futureDateISO(30), {
|
||||
hasDeadline: false,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// "Every X Days" mode is active
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||
|
||||
// Anytime label is shown instead of time picker
|
||||
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.interval-time-row').locator('.time-picker-popover'),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('11.3 Days schedule with per-day exception loads deadline rows correctly', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Monday=08:00, Wednesday=08:00 (default), Friday=10:30 (exception)
|
||||
// Use raw API to set up mixed schedule
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [
|
||||
{ day: 1, hour: 8, minute: 0 }, // Monday
|
||||
{ day: 3, hour: 8, minute: 0 }, // Wednesday
|
||||
{ day: 5, hour: 10, minute: 30 }, // Friday — exception from default 08:00
|
||||
],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Monday, Wednesday, Friday chips should be active
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'We' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
|
||||
// Default deadline shows 08:00 AM
|
||||
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
|
||||
'08:00 AM',
|
||||
)
|
||||
|
||||
// Friday has its own time picker (exception) — "Reset to default" button visible
|
||||
const frRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Friday' }),
|
||||
})
|
||||
await expect(frRow.getByRole('button', { name: 'Reset to default' })).toBeVisible()
|
||||
await expect(frRow.locator('.time-display')).toHaveText('10:30 AM')
|
||||
|
||||
// Monday has no exception — shows default label
|
||||
const moRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
|
||||
})
|
||||
await expect(moRow.locator('.default-label')).toBeVisible()
|
||||
})
|
||||
|
||||
test('11.4 Interval schedule restores anchor date and interval on reopen', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const anchorDate = '2026-06-15'
|
||||
const intervalDays = 3
|
||||
|
||||
await setIntervalSchedule(request, childId, choreId, intervalDays, anchorDate, {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// "Every X Days" mode is active
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||
|
||||
// Stepper shows 3
|
||||
await expect(page.locator('.stepper-value')).toHaveText(`${intervalDays}`)
|
||||
|
||||
// Date input has the correct anchor date
|
||||
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchorDate)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 1: Schedule Modal Opening & Structure
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
choreCard,
|
||||
openChoreOptions,
|
||||
openScheduleModal,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleModalChild'
|
||||
const CHORE_NAME = 'ScheduleModalChore'
|
||||
|
||||
test.describe('Schedule Modal — Opening & Structure', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('1.1 Schedule option appears in kebab menu', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('1.2 Schedule modal opens with correct title and subtitle', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.locator('.modal-title')).toHaveText('Schedule Chore')
|
||||
await expect(page.locator('.modal-subtitle')).toHaveText(CHORE_NAME)
|
||||
})
|
||||
|
||||
test('1.3 Default state: Specific Days mode, no chips selected, toggle Enabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Specific Days mode button is active
|
||||
await expect(page.getByRole('button', { name: 'Specific Days' })).toHaveClass(/active/)
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).not.toHaveClass(/active/)
|
||||
|
||||
// No day chips are active
|
||||
const chips = page.locator('.day-chips').getByRole('button')
|
||||
for (const chip of await chips.all()) {
|
||||
await expect(chip).not.toHaveClass(/active/)
|
||||
}
|
||||
|
||||
// Toggle is ON and shows "Enabled"
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||
})
|
||||
|
||||
test('1.4 Mode toggle switches between Specific Days and Every X Days', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Switch to Every X Days
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
await expect(page.locator('.interval-form')).toBeVisible()
|
||||
await expect(page.locator('.days-form')).not.toBeVisible()
|
||||
|
||||
// Switch back to Specific Days
|
||||
await page.getByRole('button', { name: 'Specific Days' }).click()
|
||||
await expect(page.locator('.days-form')).toBeVisible()
|
||||
await expect(page.locator('.interval-form')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('1.5 Cancel closes the modal without saving', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select Monday chip
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
|
||||
// Cancel — modal closes
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen modal
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).not.toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
})
|
||||
|
||||
test('1.6 Save is disabled when form is not dirty', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save button is disabled — no changes made
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 10: SSE Real-Time Updates
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
deleteSchedule,
|
||||
choreCard,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
todayISO,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'SSEScheduleChild'
|
||||
const CHORE_NAME = 'SSEScheduleChore'
|
||||
|
||||
test.describe('SSE Real-Time Updates', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('10.1 Setting a schedule via API reflects in an already-open parent view', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// No schedule: card should be visible and not grayed out
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Set a non-today schedule via API (triggers SSE chore_schedule_modified)
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
|
||||
|
||||
// SSE fires → card should become grayed out (chore-inactive)
|
||||
await expect(card).toHaveClass(/chore-inactive/, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.2 Deleting a schedule via API un-grays the chore in parent view', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Seed a non-today schedule first
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
|
||||
// Delete schedule via API
|
||||
await request.delete(`/api/child/${childId}/task/${choreId}/schedule`)
|
||||
|
||||
// SSE fires → card returns to normal
|
||||
await expect(card).not.toHaveClass(/chore-inactive/, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.3 Extending time via API reflects in an already-open parent view', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Set today schedule with past deadline (chore starts expired)
|
||||
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], { hour: 0, minute: 1 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
|
||||
// Extend via API
|
||||
await request.post(`/api/child/${childId}/task/${choreId}/extend`, {
|
||||
data: { date: todayISO() },
|
||||
})
|
||||
|
||||
// SSE chore_time_extended fires → TOO LATE badge disappears
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { STORAGE_STATE } from '../../e2e-constants'
|
||||
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
@@ -68,7 +68,7 @@ test.describe('Create Child', () => {
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
|
||||
const childContext = await browser.newContext({ storageState: STORAGE_STATE })
|
||||
const childContext = await browser.newContext({ storageState: STORAGE_STATE_CC })
|
||||
const tab2 = await childContext.newPage()
|
||||
|
||||
// Load the app first (to ensure localStorage is seeded from storageState),
|
||||
|
||||
@@ -2,11 +2,32 @@
|
||||
// Merged from kindness-convert-default.spec.ts and kindness-delete-default.spec.ts.
|
||||
// Both tests touch "Be good for the day" so they MUST run serially.
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
async function cleanupBeGoodTasks(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.get(`${BACKEND}/task/list`)
|
||||
if (!res.ok()) return
|
||||
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
|
||||
await res.json()
|
||||
for (const task of tasks) {
|
||||
if (
|
||||
task.user_id !== null &&
|
||||
(task.name === 'Be good today (edited)' || task.name === 'Be good for the day')
|
||||
) {
|
||||
await request.delete(`${BACKEND}/task/${task.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Default kindness act management', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await cleanupBeGoodTasks(request)
|
||||
})
|
||||
|
||||
test('Convert a default kindness act to a user item by editing', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
@@ -45,23 +66,6 @@ test.describe('Default kindness act management', () => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByText('Kindness Acts').click()
|
||||
|
||||
// Cleanup: if a previous run left a modified 'Be good for the day' (with delete icon), remove it first
|
||||
while (
|
||||
(await page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.count()) > 0
|
||||
) {
|
||||
await page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
}
|
||||
|
||||
// 1. Verify 'Be good for the day' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Convert a default penalty to a user item by editing', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
// locate default penalty
|
||||
await expect(page.locator('text=Fighting')).toBeVisible()
|
||||
await expect(page.locator('text=Fighting >> .. >> button[aria-label="Delete item"]')).toHaveCount(
|
||||
0,
|
||||
)
|
||||
// edit it (click the item itself)
|
||||
await page.getByText('Fighting', { exact: true }).click()
|
||||
await page.locator('#name').fill('Fighting (custom)')
|
||||
await page.locator('#points').fill('15')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
// now should have delete option (match the updated name exactly)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -2,32 +2,33 @@
|
||||
// Merged from penalty-convert-default.spec.ts and penalty-delete-default.spec.ts.
|
||||
// Both tests touch "Fighting" so they MUST run serially.
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
async function cleanupFightingTasks(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.get(`${BACKEND}/task/list`)
|
||||
if (!res.ok()) return
|
||||
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
|
||||
await res.json()
|
||||
for (const task of tasks) {
|
||||
if (task.user_id !== null && (task.name === 'Fighting (custom)' || task.name === 'Fighting')) {
|
||||
await request.delete(`${BACKEND}/task/${task.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Default penalty management', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await cleanupFightingTasks(request)
|
||||
})
|
||||
|
||||
test('Convert a default penalty to a user item by editing', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
|
||||
// Cleanup: delete any leftover 'Fighting (custom)' from a prior run
|
||||
while (
|
||||
(await page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.count()) > 0
|
||||
) {
|
||||
await page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
}
|
||||
|
||||
// locate default penalty
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
@@ -67,23 +68,6 @@ test.describe('Default penalty management', () => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByText('Penalties').click()
|
||||
|
||||
// Cleanup: if a previous run left a modified 'Fighting' (with delete icon), remove it first
|
||||
while (
|
||||
(await page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.count()) > 0
|
||||
) {
|
||||
await page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
}
|
||||
|
||||
// 1. Verify 'Fighting' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_EMAIL, E2E_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
test.describe('User Profile – Delete Account', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Re-seed the e2e database to restore the test user for any subsequent test suites
|
||||
await request.post(`${BACKEND}/auth/e2e-seed`)
|
||||
})
|
||||
|
||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
@@ -46,7 +39,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill(E2E_EMAIL)
|
||||
await page.locator('#confirmEmail').fill(E2E_DELETE_EMAIL)
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
@@ -87,7 +80,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill(E2E_EMAIL)
|
||||
await page.locator('#confirmEmail').fill(E2E_DELETE_EMAIL)
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// Confirmation modal appears
|
||||
@@ -104,8 +97,8 @@ test.describe('User Profile – Delete Account', () => {
|
||||
test('Login after deletion is blocked with an appropriate error', async ({ page }) => {
|
||||
await page.goto('/auth/login')
|
||||
|
||||
await page.getByLabel('Email address').fill(E2E_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_PASSWORD)
|
||||
await page.getByLabel('Email address').fill(E2E_DELETE_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_DELETE_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
// Login must fail with a deletion-related error message
|
||||
|
||||
@@ -57,7 +57,10 @@ test.describe('User Profile – editing', () => {
|
||||
|
||||
test('Back button navigates back', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
await page.goto('/parent/profile')
|
||||
// Navigate in-app via the profile dropdown so Vue Router has a real history entry.
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
await expect(page).toHaveURL('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user