Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
54
frontend/e2e/mode_parent/child-options/delete-child.spec.ts
Normal file
54
frontend/e2e/mode_parent/child-options/delete-child.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age } })
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const data = await listRes.json()
|
||||
const child = (data.children ?? []).find((c: any) => c.name === name)
|
||||
return child?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Child kebab menu – Delete Child', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const CHILD_NAME = 'KebabDelete'
|
||||
let childId = ''
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Best-effort cleanup in case the test did not delete the child
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
childId = ''
|
||||
})
|
||||
|
||||
test('Confirm deletes the child from the list', async ({ page, request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
await page.goto('/parent')
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await card.getByRole('button', { name: 'Delete Child' }).click()
|
||||
|
||||
await expect(page.getByText('Are you sure you want to delete this child?')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).not.toBeVisible()
|
||||
childId = '' // already deleted, skip afterEach cleanup
|
||||
})
|
||||
|
||||
test('Cancel does not delete the child', async ({ page, request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
await page.goto('/parent')
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await card.getByRole('button', { name: 'Delete Child' }).click()
|
||||
|
||||
await expect(page.getByText('Are you sure you want to delete this child?')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByText('Are you sure you want to delete this child?')).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
})
|
||||
})
|
||||
40
frontend/e2e/mode_parent/child-options/delete-points.spec.ts
Normal file
40
frontend/e2e/mode_parent/child-options/delete-points.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age } })
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const data = await listRes.json()
|
||||
const child = (data.children ?? []).find((c: any) => c.name === name)
|
||||
return child?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Child kebab menu – Delete Points', () => {
|
||||
const CHILD_NAME = 'KebabPoints'
|
||||
let childId = ''
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: 50 } })
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
childId = ''
|
||||
})
|
||||
|
||||
test('Delete Points resets child points to 0', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
)
|
||||
|
||||
await card.getByRole('button', { name: 'Delete Points' }).click()
|
||||
|
||||
await expect(card.getByText('Points: 0')).toBeVisible()
|
||||
})
|
||||
})
|
||||
40
frontend/e2e/mode_parent/child-options/edit-child.spec.ts
Normal file
40
frontend/e2e/mode_parent/child-options/edit-child.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age } })
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const data = await listRes.json()
|
||||
const child = (data.children ?? []).find((c: any) => c.name === name)
|
||||
return child?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Child kebab menu – Edit Child', () => {
|
||||
const CHILD_NAME = 'KebabEdit'
|
||||
let childId = ''
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
childId = ''
|
||||
})
|
||||
|
||||
test('Edit Child menu item navigates to the child editor', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
)
|
||||
|
||||
await card.getByRole('button', { name: 'Edit Child' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/[^/]+\/edit/)
|
||||
await expect(page.getByRole('heading', { name: 'Edit Child' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
121
frontend/e2e/mode_parent/chore-scheduler/extend-time.spec.ts
Normal file
121
frontend/e2e/mode_parent/chore-scheduler/extend-time.spec.ts
Normal file
@@ -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/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
233
frontend/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 })
|
||||
}
|
||||
113
frontend/e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts
Normal file
113
frontend/e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts
Normal file
@@ -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 })
|
||||
})
|
||||
})
|
||||
130
frontend/e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts
Normal file
130
frontend/e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
171
frontend/e2e/mode_parent/chore-scheduler/schedule-load.spec.ts
Normal file
171
frontend/e2e/mode_parent/chore-scheduler/schedule-load.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
129
frontend/e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts
Normal file
129
frontend/e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts
Normal file
@@ -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 })
|
||||
})
|
||||
})
|
||||
16
frontend/e2e/mode_parent/create-child/authorization.spec.ts
Normal file
16
frontend/e2e/mode_parent/create-child/authorization.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { STORAGE_STATE_NO_PIN } from '../../e2e-constants'
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_NO_PIN })
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test('Add Child FAB is hidden when parent auth is expired', async ({ page }) => {
|
||||
// Navigate to app root - with no parent auth, router redirects to /child
|
||||
await page.goto('/')
|
||||
|
||||
// expect: the 'Add Child' FAB is NOT visible (not in parent mode)
|
||||
await expect(page.getByRole('button', { name: 'Add Child' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
163
frontend/e2e/mode_parent/create-child/happy-path.spec.ts
Normal file
163
frontend/e2e/mode_parent/create-child/happy-path.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||
|
||||
async function deleteNamedChildren(request: any, names: string[]) {
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
if (names.includes(child.name)) {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllChildren(request: any) {
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
// Serial mode: the 'empty state' test calls deleteAllChildren() which would
|
||||
// race against sibling tests creating children if they ran in parallel.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Create a child with name and age only', async ({ page, request }) => {
|
||||
await deleteNamedChildren(request, ['Alice'])
|
||||
|
||||
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// 2. Click the 'Add Child' FAB
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
await expect(nameInput).toBeVisible()
|
||||
await expect(ageInput).toBeVisible()
|
||||
|
||||
// Submit should be disabled until all required fields are valid.
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
// 3. Enter 'Alice' in the Name field
|
||||
await nameInput.fill('Alice')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
// 4. Enter '8' in the Age field
|
||||
await ageInput.fill('8')
|
||||
|
||||
// 5. Leave Image as default and click Create
|
||||
// Use toPass() to handle SSE-triggered form resets from parallel tests
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
await deleteNamedChildren(request, ['Alice'])
|
||||
await nameInput.fill('Alice')
|
||||
await ageInput.fill('8')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page.locator('.error')).not.toBeVisible()
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Alice')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Create a child via the inline Create button in empty state', async ({ page, request }) => {
|
||||
// 1. Navigate to empty state and click inline Create.
|
||||
// Retry-loop handles SSE-triggered re-renders: a parallel test may add a child
|
||||
// between deleteAllChildren() and the click, detaching the empty-state button.
|
||||
await expect(async () => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('No children')).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole('button', { name: 'Create' }).click({ timeout: 5000 })
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible({
|
||||
timeout: 5000,
|
||||
})
|
||||
}).toPass({ timeout: 30000 })
|
||||
|
||||
// 2. Enter 'Bob' and '10', then submit
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
// Submit should be disabled until all required fields are valid
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await nameInput.fill('Bob')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await ageInput.fill('10')
|
||||
|
||||
// Handle async form initialization race and SSE-triggered form resets
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
await deleteNamedChildren(request, ['Bob'])
|
||||
await nameInput.fill('Bob')
|
||||
await ageInput.fill('10')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page.locator('.error')).not.toBeVisible()
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Bob')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Create a child with a custom uploaded image', async ({ page, request }) => {
|
||||
await deleteNamedChildren(request, ['Grace'])
|
||||
|
||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
|
||||
// 2. Enter 'Grace' and '6'
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
// Submit should be disabled until all required fields are valid
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await nameInput.fill('Grace')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await ageInput.fill('6')
|
||||
|
||||
// 3. Upload a local image file via 'Add from device'
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('button', { name: 'Add from device' }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(TEST_IMAGE)
|
||||
|
||||
// Handle async form initialization race and SSE-triggered form resets
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
await deleteNamedChildren(request, ['Grace'])
|
||||
await nameInput.fill('Grace')
|
||||
await ageInput.fill('6')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page.locator('.error')).not.toBeVisible()
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Grace')).toBeVisible()
|
||||
})
|
||||
})
|
||||
21
frontend/e2e/mode_parent/create-child/navigation.spec.ts
Normal file
21
frontend/e2e/mode_parent/create-child/navigation.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test('Cancel navigates back without saving', async ({ page }) => {
|
||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
|
||||
// 2. Fill in 'Frank' and '9', then click Cancel
|
||||
await page.getByLabel('Name').fill('Frank')
|
||||
await page.getByLabel('Age').fill('9')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// expect: back on /parent and 'Frank' is NOT listed
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Frank')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
108
frontend/e2e/mode_parent/create-child/sse.spec.ts
Normal file
108
frontend/e2e/mode_parent/create-child/sse.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('New child appears in list without page reload', async ({ page, context, request }) => {
|
||||
// Clean up 'Hannah' before test
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
if (child.name === 'Hannah') {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Open two browser tabs both on /parent (children list)
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
const tab2 = await context.newPage()
|
||||
await tab2.goto('/')
|
||||
await expect(tab2).toHaveURL('/parent')
|
||||
|
||||
// 2. In Tab 1, create child 'Hannah' age '4'
|
||||
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
// Clean up any Hannah created in a previous attempt where navigation was cancelled
|
||||
const lr = await request.get('/api/child/list')
|
||||
for (const c of (await lr.json()).children ?? []) {
|
||||
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await page.getByLabel('Name').fill('Hannah')
|
||||
await page.getByLabel('Age').fill('4')
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
// 3. Tab 2 should show 'Hannah' via SSE without a manual refresh
|
||||
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
await tab2.close()
|
||||
})
|
||||
|
||||
test('New child appears in child mode list without page reload', async ({
|
||||
page,
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
// Clean up 'Hannah' before test
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
if (child.name === 'Hannah') {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Tab 1: parent mode
|
||||
await page.goto('/')
|
||||
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_CC })
|
||||
const tab2 = await childContext.newPage()
|
||||
|
||||
// Load the app first (to ensure localStorage is seeded from storageState),
|
||||
// then remove parentAuth so the next navigation boots in child mode.
|
||||
await tab2.goto('/')
|
||||
await tab2.evaluate(() => localStorage.removeItem('parentAuth'))
|
||||
|
||||
// Full navigation triggers a fresh Vue init — auth store reads no parentAuth
|
||||
// so the router allows /child
|
||||
await tab2.goto('/child')
|
||||
await expect(tab2).toHaveURL('/child')
|
||||
|
||||
// 3. In Tab 1, create child 'Hannah' age '4'
|
||||
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
// Clean up any Hannah created in a previous attempt where navigation was cancelled
|
||||
const lr = await request.get('/api/child/list')
|
||||
for (const c of (await lr.json()).children ?? []) {
|
||||
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await page.getByLabel('Name').fill('Hannah')
|
||||
await page.getByLabel('Age').fill('4')
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
// 4. Tab 2 (child mode) should show 'Hannah' via SSE without a manual refresh
|
||||
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
await childContext.close()
|
||||
})
|
||||
})
|
||||
88
frontend/e2e/mode_parent/create-child/validation.spec.ts
Normal file
88
frontend/e2e/mode_parent/create-child/validation.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
|
||||
// Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reject submission when Name is empty', async ({ page }) => {
|
||||
// 2. Leave Name empty, enter '7' in Age - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Age').fill('7')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Reject submission when Name is whitespace only', async ({ page }) => {
|
||||
// 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill(' ')
|
||||
await page.getByLabel('Age').fill('7')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Reject submission when Age is empty', async ({ page }) => {
|
||||
// 2. Enter 'Charlie', clear Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Charlie')
|
||||
await page.getByLabel('Age').clear()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Reject negative age', async ({ page }) => {
|
||||
// 2. Enter 'Dave', enter '-1' - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill('Dave')
|
||||
await page.getByLabel('Age').fill('-1')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
// Use toPass() to handle SSE-triggered form resets from parallel tests
|
||||
const FULL_NAME = 'A'.repeat(64)
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
// Clean up any previously-created 64-A child in case prev attempt created but nav was cancelled
|
||||
const lr = await request.get('/api/child/list')
|
||||
for (const c of (await lr.json()).children ?? []) {
|
||||
if (c.name === FULL_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
// Fill 65 chars — HTML maxlength=64 truncates to 64
|
||||
await nameInput.fill('A'.repeat(65))
|
||||
// Verify truncation via non-retrying read (throws if SSE cleared the field)
|
||||
const truncated = await nameInput.inputValue()
|
||||
if (truncated !== FULL_NAME)
|
||||
throw new Error(`maxlength truncation failed: got "${truncated}"`)
|
||||
await ageInput.fill('5')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
})
|
||||
|
||||
test('Reject age greater than 120', async ({ page }) => {
|
||||
// 2. Enter 'Eve', enter '121' in Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Eve')
|
||||
await page.getByLabel('Age').fill('121')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §2
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ChoreNotifApproveChild'
|
||||
const CHORE_NAME = 'ChoreNotifApproveChore'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chore Notification — Appearance and In-App Approval', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let pointsBeforeApproval = 0
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Set a daily interval schedule so the chore persists post-approval
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Confirm the chore to create a pending notification
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 2.1 Confirmed chore appears in the notification view
|
||||
test('Confirmed chore appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText('completed')
|
||||
})
|
||||
|
||||
// 2.2 Notification item displays the child's name
|
||||
test('Notification item displays the child name', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText(CHILD_NAME)
|
||||
})
|
||||
|
||||
// 2.3 Clicking notification navigates to ParentView with correct query params
|
||||
test('Clicking notification navigates to ParentView with correct query params', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${choreId}`)
|
||||
expect(page.url()).toContain('entityType=chore')
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 2.4 Pending chore card shows PENDING stamp in ParentView
|
||||
test('Pending chore card shows PENDING stamp in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await expect(card).toContainText('PENDING')
|
||||
})
|
||||
|
||||
// 2.5 Clicking a pending chore card opens ChoreApproveDialog
|
||||
test('Clicking a pending chore card opens ChoreApproveDialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 2.6 Approving via ChoreApproveDialog removes the PENDING stamp
|
||||
test('Approving via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
// Capture points before approval for test 2.8 verification
|
||||
pointsBeforeApproval = await getPoints(page)
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const approveBtn = page.getByRole('button', { name: 'Approve' })
|
||||
await expect(approveBtn).toBeVisible({ timeout: 5000 })
|
||||
await approveBtn.click()
|
||||
await expect(approveBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
})
|
||||
|
||||
// 2.7 Approving a chore clears it from the notification view
|
||||
test('Approving a chore clears it from notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 2.8 Approving a chore awards points to the child
|
||||
test('Approving a chore awards points to the child', async ({ page }) => {
|
||||
// The chore was approved in test 2.6. Verify the points were updated correctly.
|
||||
// (Cannot re-confirm as the chore was already approved today.)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const currentPoints = await getPoints(page)
|
||||
expect(currentPoints).toBe(pointsBeforeApproval + CHORE_POINTS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §3
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ChoreNotifDenyChild'
|
||||
const CHORE_NAME = 'ChoreNotifDenyChore'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chore Notification — In-App Rejection', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Set a daily interval schedule so the chore persists post-rejection
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 3.1 Confirmed chore appears in the notification view
|
||||
test('Confirmed chore appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 3.2 ChoreApproveDialog shows both Approve and Reject buttons
|
||||
test('ChoreApproveDialog shows both Approve and Reject buttons', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 3.3 Rejecting via ChoreApproveDialog removes the PENDING stamp
|
||||
test('Rejecting via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const rejectBtn = page.getByRole('button', { name: 'Reject' })
|
||||
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
|
||||
await rejectBtn.click()
|
||||
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
})
|
||||
|
||||
// 3.4 Rejecting a chore clears it from the notification view
|
||||
test('Rejecting a chore clears it from notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 3.5 Rejecting a chore does not award points
|
||||
test('Rejecting a chore does not award points', async ({ page, request }) => {
|
||||
// Re-confirm the chore for this test
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const before = await getPoints(page)
|
||||
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const rejectBtn = page.getByRole('button', { name: 'Reject' })
|
||||
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
|
||||
await rejectBtn.click()
|
||||
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §6
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page, type Locator } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DeepLinkChild'
|
||||
const CHORE_PREFIX = 'DeepLinkChore'
|
||||
const CHORE_COUNT = 10
|
||||
const REWARD_NAME = 'DeepLinkReward'
|
||||
const REWARD_COST = 20
|
||||
const INITIAL_POINTS = 100
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'deep link test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('ParentView Deep-Link Navigation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const choreIds: string[] = []
|
||||
let targetChoreId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
|
||||
for (let i = 1; i <= CHORE_COUNT; i++) {
|
||||
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
|
||||
}
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
// Target the last chore (most likely off-screen)
|
||||
targetChoreId = choreIds[CHORE_COUNT - 1]
|
||||
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: targetChoreId },
|
||||
})
|
||||
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of choreIds) await request.delete(`/api/task/${id}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 6.1 Deep link to chore — correct card is scrolled into viewport
|
||||
test('Deep link to chore scrolls correct card into viewport', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=chore`)
|
||||
const card = choreSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
|
||||
// 6.2 Deep link to reward — correct card is scrolled into viewport
|
||||
test('Deep link to reward scrolls correct card into viewport', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}?scrollTo=${rewardId}&entityType=reward`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
|
||||
// 6.3 Deep link with unknown entityType — page loads without JS error
|
||||
test('Deep link with unknown entityType loads without JS error', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=unknown`)
|
||||
await page.waitForTimeout(500)
|
||||
expect(errors).toHaveLength(0)
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 6.4 Deep link with missing scrollTo — page loads normally
|
||||
test('Deep link with missing scrollTo loads normally', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}?entityType=chore`)
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 6.5 Deep link with no query params — page loads normally
|
||||
test('Deep link with no query params loads normally', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.locator('h3', { hasText: 'Rewards' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 6.6 Clicking a chore notification produces the correct deep link
|
||||
test('Clicking a chore notification produces correct deep link', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${targetChoreId}`)
|
||||
expect(page.url()).toContain('entityType=chore')
|
||||
const card = choreSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
|
||||
// 6.7 Clicking a reward notification produces the correct deep link
|
||||
test('Clicking a reward notification produces correct deep link', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${rewardId}`)
|
||||
expect(page.url()).toContain('entityType=reward')
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — approve chore
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestApproveChoreChild'
|
||||
const CHORE_NAME = 'DigestApproveChoreChore'
|
||||
const CHORE_POINTS = 8
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Approve Chore', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let approveToken = ''
|
||||
|
||||
test.beforeAll(async ({ request, playwright }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
// Set interval schedule so chore persists post-approval
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
// Obtain a valid approve-chore token via the E2E test helper
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
approveToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 7.1 Approve-chore token returns 302 redirect to correct ParentView URL
|
||||
test('Approve-chore token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain(`scrollTo=${choreId}`)
|
||||
expect(location).toContain('entityType=chore')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.2 Approve-chore action is performed: pending confirmation removed, points awarded
|
||||
test('Approve-chore action is performed: points awarded and confirmation removed', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${approveToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get(`/api/child/list`)
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
expect(child.points).toBeGreaterThanOrEqual(CHORE_POINTS)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === choreId,
|
||||
)
|
||||
// Should be no pending (status == 'pending') confirmation
|
||||
expect(pending.filter((c: any) => c.status === 'pending')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — approve reward
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestApproveRewardChild'
|
||||
const REWARD_NAME = 'DigestApproveRewardReward'
|
||||
const REWARD_COST = 15
|
||||
const INITIAL_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'digest approve test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Approve Reward', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
let approveToken = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: rewardId,
|
||||
entity_type: 'reward',
|
||||
action: 'approve',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
approveToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 7.5 Approve-reward token returns 302 redirect to correct ParentView URL
|
||||
test('Approve-reward token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain(`scrollTo=${rewardId}`)
|
||||
expect(location).toContain('entityType=reward')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.6 Approve-reward action: reward triggered, child points deducted
|
||||
test('Approve-reward action: reward triggered and child points deducted', async ({ request }) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${approveToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get('/api/child/list')
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
expect(child.points).toBe(INITIAL_POINTS - REWARD_COST)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
|
||||
)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — deny chore
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestDenyChoreChild'
|
||||
const CHORE_NAME = 'DigestDenyChoreChore'
|
||||
const CHORE_POINTS = 8
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Deny Chore', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let denyToken = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'deny',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
denyToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 7.3 Deny-chore token returns 302 redirect to correct ParentView URL
|
||||
test('Deny-chore token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain('entityType=chore')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.4 Deny-chore action is performed: pending confirmation removed, no points awarded
|
||||
test('Deny-chore action is performed: confirmation removed, points unchanged', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${denyToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get(`/api/child/list`)
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
// No points awarded for rejection
|
||||
expect(child.points).toBe(0)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === choreId && c.status === 'pending',
|
||||
)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — deny reward
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestDenyRewardChild'
|
||||
const REWARD_NAME = 'DigestDenyRewardReward'
|
||||
const REWARD_COST = 15
|
||||
const INITIAL_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'digest deny test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Deny Reward', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
let denyToken = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: rewardId,
|
||||
entity_type: 'reward',
|
||||
action: 'deny',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
denyToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 7.7 Deny-reward token returns 302 redirect to correct ParentView URL
|
||||
test('Deny-reward token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain('entityType=reward')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.8 Deny-reward action: pending request removed, points unchanged
|
||||
test('Deny-reward action: pending request removed and points unchanged', async ({ request }) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${denyToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get('/api/child/list')
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
// Points should be unchanged (deny doesn't deduct anything)
|
||||
expect(child.points).toBe(INITIAL_POINTS)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
|
||||
)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §8
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestErrorChild'
|
||||
const CHORE_NAME = 'DigestErrorChore'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getUnauthContext(playwright: any) {
|
||||
return playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Error Paths', () => {
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 8.1 Expired token returns 400
|
||||
test('Expired token returns 400', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
expires_in_hours: -1,
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
const expiredToken = (await tokenRes.json()).token
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
|
||||
expect(res.status()).toBe(400)
|
||||
expect(res.headers()['location']).toBeUndefined()
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 8.2 Already-used token returns 400 on second use
|
||||
test('Already-used token returns 400 on second use', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'deny',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
const token = (await tokenRes.json()).token
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
|
||||
// First use — authenticated POST executes and consumes the token
|
||||
const first = await request.post(`/api/digest-action/${token}`)
|
||||
expect(first.status()).toBe(200)
|
||||
|
||||
// Second use — token is consumed, unauthenticated GET should return 400
|
||||
const second = await unauthCtx.get(`/api/digest-action/${token}`, { maxRedirects: 0 })
|
||||
expect(second.status()).toBe(400)
|
||||
await unauthCtx.dispose()
|
||||
|
||||
// Re-create a pending chore confirmation for subsequent tests
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
// 8.3 Tampered token returns 400
|
||||
test('Tampered token returns 400', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
const validToken: string = (await tokenRes.json()).token
|
||||
|
||||
// Tamper: replace the last character
|
||||
const lastChar = validToken[validToken.length - 1]
|
||||
const replacement = lastChar === 'a' ? 'b' : 'a'
|
||||
const tamperedToken = validToken.slice(0, -1) + replacement
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get(`/api/digest-action/${tamperedToken}`, { maxRedirects: 0 })
|
||||
expect(res.status()).toBe(400)
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 8.4 Completely fake token returns 400
|
||||
test('Completely fake token returns 400', async ({ playwright }) => {
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get('/api/digest-action/this-token-does-not-exist', {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(400)
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 8.5 Error response is a plain HTML page with no redirect
|
||||
test('Error response is plain HTML with no Location header', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
expires_in_hours: -1,
|
||||
},
|
||||
})
|
||||
const expiredToken = (await tokenRes.json()).token
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
|
||||
expect(res.status()).toBe(400)
|
||||
const contentType = res.headers()['content-type'] ?? ''
|
||||
expect(contentType).toContain('text/html')
|
||||
expect(res.headers()['location']).toBeUndefined()
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §4
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'NotifOverflowChild'
|
||||
const LONG_CHORE_NAME = 'This Is A Very Long Chore Name That Should Not Overflow The Card Boundary'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Notification card overflow', () => {
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, LONG_CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Confirm chore to create a pending notification
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test('Notification text does not overflow card boundaries', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: LONG_CHORE_NAME })
|
||||
await expect(notifItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Get bounding boxes
|
||||
const cardBox = await notifItem.boundingBox()
|
||||
expect(cardBox).not.toBeNull()
|
||||
|
||||
// Verify the card doesn't overflow horizontally beyond its parent
|
||||
const parentBox = await notifItem.locator('..').boundingBox()
|
||||
expect(parentBox).not.toBeNull()
|
||||
|
||||
if (cardBox && parentBox) {
|
||||
// Card's right edge should not extend beyond parent's right edge
|
||||
expect(cardBox.x + cardBox.width).toBeLessThanOrEqual(parentBox.x + parentBox.width + 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('Long notification entity name wraps or clips within card', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: LONG_CHORE_NAME })
|
||||
await expect(notifItem).toBeVisible()
|
||||
|
||||
// The text content inside the notification should not cause horizontal scroll
|
||||
const hasHorizontalScroll = await notifItem.evaluate((el) => {
|
||||
return el.scrollWidth > el.clientWidth
|
||||
})
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §9
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'NotifScrollChild'
|
||||
const CHORE_COUNT = 10
|
||||
const CHORE_PREFIX = 'NotifScrollChore'
|
||||
const TARGET_CHORE_IDX = CHORE_COUNT // last chore, likely off-screen
|
||||
const REWARD_NAME = 'NotifScrollReward'
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'notif test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Notification click navigates and scrolls to task', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const choreIds: string[] = []
|
||||
let targetChoreId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
|
||||
for (let i = 1; i <= CHORE_COUNT; i++) {
|
||||
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
|
||||
}
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
// The target chore is the last one — most likely off-screen
|
||||
targetChoreId = choreIds[TARGET_CHORE_IDX - 1]
|
||||
|
||||
// Confirm the target chore → creates pending notification
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: targetChoreId },
|
||||
})
|
||||
|
||||
// Request the reward → creates pending notification
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of choreIds) await request.delete(`/api/task/${id}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('Clicking a chore notification navigates and scrolls to the chore card', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const targetChoreName = `${CHORE_PREFIX}${TARGET_CHORE_IDX}`
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: targetChoreName })
|
||||
await expect(notifItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await notifItem.click()
|
||||
|
||||
// Verify navigation to parent child view with scrollTo query param
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
// Verify the target chore card is scrolled into viewport
|
||||
const targetCard = choreSection(page).locator('.item-card').filter({ hasText: targetChoreName })
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(await isInViewport(targetCard)).toBe(true)
|
||||
})
|
||||
|
||||
test('Clicking a reward notification scrolls reward card into viewport', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(notifItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await notifItem.click()
|
||||
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const targetCard = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(await isInViewport(targetCard)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §1
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
function pushToggle(page: Page) {
|
||||
return page
|
||||
.locator('.toggle-field-row')
|
||||
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
test.describe('Push subscription registration', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.3 No subscription POST when notification permission not granted
|
||||
// -------------------------------------------------------------------------
|
||||
test('No subscription POST is sent when notification permission is not granted', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Do NOT grant notifications permission
|
||||
let postCaptured = false
|
||||
await page.route('**/api/push-subscription', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postCaptured = true
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/parent/profile')
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
const isDisabled = await toggle.isDisabled().catch(() => false)
|
||||
|
||||
if (!isDisabled) {
|
||||
// Try to click the toggle — it may prompt for permission but deny it
|
||||
await toggle.click().catch(() => {})
|
||||
await page
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click()
|
||||
.catch(() => {})
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
expect(postCaptured).toBe(false)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.4 Backend rejects unauthenticated subscription POST (401)
|
||||
// -------------------------------------------------------------------------
|
||||
test('Backend rejects unauthenticated subscription POST with 401', async ({ page }) => {
|
||||
// Navigate first, then use credentials:'omit' to strip cookies and verify
|
||||
// the backend enforces auth.
|
||||
await page.goto('/parent/profile')
|
||||
const status = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/push-subscription', {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
|
||||
keys: { p256dh: 'fake-key', auth: 'fake-auth' },
|
||||
}),
|
||||
})
|
||||
return res.status
|
||||
})
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.5 Backend rejects unauthenticated subscription DELETE (401)
|
||||
// -------------------------------------------------------------------------
|
||||
test('Backend rejects unauthenticated subscription DELETE with 401', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
const status = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/push-subscription', {
|
||||
method: 'DELETE',
|
||||
credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
|
||||
}),
|
||||
})
|
||||
return res.status
|
||||
})
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §4
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'RewardNotifApproveChild'
|
||||
const REWARD_NAME = 'RewardNotifApproveReward'
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_POINTS = 30
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'notif e2e test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Reward Notification — Appearance and In-App Grant', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 4.1 Requested reward appears in the notification view
|
||||
test('Requested reward appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText('requested')
|
||||
})
|
||||
|
||||
// 4.2 Notification item displays the child's name
|
||||
test('Notification item displays the child name', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText(CHILD_NAME)
|
||||
})
|
||||
|
||||
// 4.3 Clicking reward notification navigates to ParentView with correct query params
|
||||
test('Clicking reward notification navigates to ParentView with correct query params', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${rewardId}`)
|
||||
expect(page.url()).toContain('entityType=reward')
|
||||
})
|
||||
|
||||
// 4.4 Pending reward card shows PENDING badge in ParentView
|
||||
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await expect(card).toContainText('PENDING')
|
||||
})
|
||||
|
||||
// 4.5 Clicking a pending reward card opens the grant confirmation dialog
|
||||
test('Clicking a pending reward card opens grant confirmation dialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
// A confirm dialog should appear — look for a confirmation button
|
||||
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 4.6 Confirming the grant removes PENDING badge and updates child points
|
||||
test('Confirming grant removes PENDING badge and updates child points', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const before = await getPoints(page)
|
||||
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
|
||||
await confirmBtn.click()
|
||||
await expect(confirmBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST), {
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
// 4.7 Granting a reward clears it from the notification view
|
||||
test('Granting a reward clears it from notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §5
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'RewardNotifDenyChild'
|
||||
const REWARD_NAME = 'RewardNotifDenyReward'
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_POINTS = 30
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'notif e2e deny test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Reward Notification — In-App Denial', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
// Create a fresh pending request before each test
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Clean up any unconsumed pending requests
|
||||
await request.post(`/api/child/${childId}/cancel-request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
// 5.1 Requested reward appears in the notification view
|
||||
test('Requested reward appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 5.2 Pending reward card shows PENDING badge in ParentView
|
||||
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await expect(card).toContainText('PENDING')
|
||||
})
|
||||
|
||||
// 5.3 Denying a reward request removes the PENDING badge
|
||||
test('Denying a reward request removes PENDING badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const before = await getPoints(page)
|
||||
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
// Look for a Deny button in the dialog
|
||||
const denyBtn = page.getByRole('button', { name: /deny/i })
|
||||
await expect(denyBtn).toBeVisible({ timeout: 5000 })
|
||||
await denyBtn.click()
|
||||
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
|
||||
// Points should be unchanged
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
|
||||
})
|
||||
|
||||
// 5.4 Denying a reward request clears it from the notification view
|
||||
test('Denying a reward request clears it from notification view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const denyBtn = page.getByRole('button', { name: /deny/i })
|
||||
await expect(denyBtn).toBeVisible({ timeout: 5000 })
|
||||
await denyBtn.click()
|
||||
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 5.5 POST deny-reward-request returns 200 when pending request exists (API layer)
|
||||
test('POST deny-reward-request returns 200 when pending request exists', async ({ request }) => {
|
||||
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
expect(res.status()).toBe(200)
|
||||
})
|
||||
|
||||
// 5.6 POST deny-reward-request returns 200 with graceful message when no pending request
|
||||
test('POST deny-reward-request returns 200 gracefully when no pending request', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Cancel existing request first (afterEach will also try but that's fine)
|
||||
await request.post(`/api/child/${childId}/cancel-request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
// Backend returns 200 with a graceful "already resolved" message per the spec
|
||||
expect(res.status()).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.message).toContain('already been resolved')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §10
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
function digestToggle(page: Page) {
|
||||
return page
|
||||
.locator('.toggle-field-row')
|
||||
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Daily Digest' }) })
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
function pushToggle(page: Page) {
|
||||
return page
|
||||
.locator('.toggle-field-row')
|
||||
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
test.describe('User Profile Notification Settings', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.1 Email Digest toggle is visible on User Profile page
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.2 Email Digest toggle initialises from the server value
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Email Digest toggle initialises from the server value', async ({ page, request }) => {
|
||||
// Set known state: enabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
// Set known state: disabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.reload()
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
// Restore default
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.3 Toggling Email Digest off sends email_digest_enabled: false to API
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/user/profile', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
capturedBody = JSON.parse(route.request().postData() ?? '{}')
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await digestToggle(page).click()
|
||||
|
||||
// Submit the form (Save button)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 3000 })
|
||||
|
||||
// Restore
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.4 Toggling Email Digest on sends email_digest_enabled: true to API
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.goto('/parent/profile')
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/user/profile', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
capturedBody = JSON.parse(route.request().postData() ?? '{}')
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await digestToggle(page).click()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(true)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 3000 })
|
||||
|
||||
// Restore
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.5 Push Notifications toggle is visible on User Profile page
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.6 Push Notifications toggle initialises to off when no subscription exists
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle initialises to off when no subscription exists', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Do NOT grant notifications permission — toggle should start unchecked
|
||||
await page.goto('/parent/profile')
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// Either aria-pressed="false" or disabled (when browser denies permissions)
|
||||
const ariaPressed = await toggle.getAttribute('aria-pressed')
|
||||
expect(ariaPressed).toBe('false')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.8 Push Notifications toggle is disabled when browser permission is denied
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle is disabled when browser permission is denied', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Do not grant permission (leave as denied/prompt)
|
||||
await context.clearPermissions()
|
||||
await page.goto('/parent/profile')
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// When permission is denied, the toggle should be disabled or aria-pressed="false"
|
||||
const isDisabled = await toggle.isDisabled().catch(() => false)
|
||||
const ariaPressed = await toggle.getAttribute('aria-pressed')
|
||||
// Either disabled or not pressed — permission denied prevents enabling it
|
||||
expect(isDisabled || ariaPressed === 'false').toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
test.describe('Parent profile button – temporary parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Switch from permanent mode to temporary mode:
|
||||
// 1. Exit parent mode via Child Mode
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
// 2. Re-enter parent mode WITHOUT the persistence checkbox
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
// Do NOT check 'Stay in parent mode'
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
})
|
||||
|
||||
test('Badge – no 🔒 badge in temporary parent mode', async ({ page }) => {
|
||||
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
test.describe('Parent profile button – permanent parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
})
|
||||
|
||||
test('Badge – shows 🔒 in permanent parent mode', async ({ page }) => {
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toHaveText('🔒')
|
||||
})
|
||||
|
||||
test('Menu – shows user name and email', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
|
||||
await expect(page.getByText(E2E_FIRST_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(E2E_EMAIL, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Profile option navigates to the User Profile page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode exits parent mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// View-selector navigation buttons disappear after exiting parent mode
|
||||
await expect(page.getByRole('button', { name: 'Children' })).not.toBeVisible()
|
||||
// Profile button changes label to "Parent login" when not in parent mode
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode re-entry without checkbox enters temporary mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// Click profile button to open PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
// Do NOT check "Stay in parent mode"
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// No persistent badge — temporary mode
|
||||
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode re-entry with checkbox enters permanent mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// Click profile button to open PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// Persistent badge visible — permanent mode
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Sign Out navigates to landing page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Sign out' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('button', { name: 'Sign In' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
78
frontend/e2e/mode_parent/rewards/reward-cancel.spec.ts
Normal file
78
frontend/e2e/mode_parent/rewards/reward-cancel.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Reward cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Cancel reward creation', async ({ page }) => {
|
||||
const name = `CancelMe-${Date.now()}`
|
||||
// 1. Navigate and open create form
|
||||
await page.goto('/parent/rewards')
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
|
||||
// 2. Fill then cancel
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#cost', '5')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// verify return to list and the cancelled item was not saved
|
||||
await expect(page.getByText(name, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel reward edit', async ({ page }) => {
|
||||
// 1. Ensure Toy car exists and open edit
|
||||
const suffix = Date.now()
|
||||
const original = `Toy car ${suffix}`
|
||||
await page.goto('/parent/rewards')
|
||||
if (!(await page.locator(`text=${original}`).count())) {
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
await page.click(`text=${original}`)
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
|
||||
// 2. make a change then cancel
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name') as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = 'Should not save'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.locator('text=Should not save')).toHaveCount(0)
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Convert default reward', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Convert a default reward to a user item', async ({ page }) => {
|
||||
await page.goto('/parent/rewards')
|
||||
|
||||
// remove any row that has a delete button (covers custom copies and renamed originals)
|
||||
const allRows = page.locator('text=Choose meal')
|
||||
const total = await allRows.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
const r = allRows.nth(i)
|
||||
const delBtn = r.locator('..').getByRole('button', { name: 'Delete' })
|
||||
if ((await delBtn.count()) > 0) {
|
||||
await delBtn.click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
}
|
||||
}
|
||||
|
||||
// locate the remaining default reward (exact text)
|
||||
const row = page.getByText('Choose meal', { exact: true }).first()
|
||||
await expect(row).toBeVisible()
|
||||
// delete button should not exist initially on default
|
||||
await expect(row.locator('..').getByRole('button', { name: 'Delete' })).toHaveCount(0)
|
||||
|
||||
// open edit form
|
||||
await row.click()
|
||||
await expect(page.locator('input#name')).toHaveValue('Choose meal')
|
||||
|
||||
// change fields
|
||||
await page.locator('#name').fill('Choose meal (custom)')
|
||||
await page.locator('#cost').fill('35')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// after save, ensure row now has delete control
|
||||
const updated = page.getByText('Choose meal (custom)', { exact: true }).first()
|
||||
await expect(updated).toBeVisible()
|
||||
await expect(updated.locator('..').getByRole('button', { name: 'Delete' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
139
frontend/e2e/mode_parent/rewards/reward-create-edit.spec.ts
Normal file
139
frontend/e2e/mode_parent/rewards/reward-create-edit.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Reward management', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Create a new reward', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Toy car ${suffix}`
|
||||
|
||||
// 1. Navigate to /parent/rewards and verify default list
|
||||
await page.goto('/parent/rewards')
|
||||
// expect: some reward name visible (use default item)
|
||||
await expect(page.getByText('Choose meal', { exact: true })).toBeVisible()
|
||||
|
||||
// 2. Click the 'Create Reward' FAB
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
// expect: form appears with fields
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
|
||||
// 3. Fill in name and cost via DOM events
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, name)
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
|
||||
// 4. Submit
|
||||
await page.click('button:has-text("Create")')
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/parent\//)
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing reward', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `Toy car ${suffix}`
|
||||
const updated = `Toy boat ${suffix}`
|
||||
|
||||
// ensure the item exists
|
||||
await page.goto('/parent/rewards')
|
||||
if (!(await page.locator(`text=${original}`).count())) {
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
|
||||
// open edit form
|
||||
await page.click(`text=${original}`)
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
|
||||
// change values
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string | number) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
if (typeof val === 'number') {
|
||||
el.valueAsNumber = val
|
||||
} else {
|
||||
el.value = val
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
// set numeric cost to ensure backend receives integer
|
||||
setVal('#cost', 25)
|
||||
}, updated)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/parent\//)
|
||||
// allow extra time for the updated row to appear via SSE
|
||||
await expect(page.locator(`text=${updated}`)).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('Delete a reward', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Toy car ${suffix}`
|
||||
|
||||
await page.goto('/parent/rewards')
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
|
||||
// delete using row-local button
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.first()
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
124
frontend/e2e/mode_parent/rewards/reward-delete-default.spec.ts
Normal file
124
frontend/e2e/mode_parent/rewards/reward-delete-default.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Default reward cost edit and restore', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Edit default cost and verify restoration on delete', async ({ page }) => {
|
||||
await page.goto('/parent/rewards')
|
||||
|
||||
// cleanup any user-copy of "Choose meal"
|
||||
// Keep deleting rows until there are none left – avoids DOM-shift race when
|
||||
// multiple matching rows exist (e.g. leftovers from previous specs). Because
|
||||
// other workers may modify the list concurrently, we also retry the entire
|
||||
// loop once after a short delay if a delete button still shows up.
|
||||
async function purge(): Promise<void> {
|
||||
while (
|
||||
await page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.count()
|
||||
) {
|
||||
const btn = page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.first()
|
||||
await btn.click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
// give UI a moment to refresh the list
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
}
|
||||
await purge()
|
||||
// if something else sneaks one in (concurrent spec), wait then try again
|
||||
if (
|
||||
await page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.count()
|
||||
) {
|
||||
await page.waitForTimeout(500)
|
||||
await purge()
|
||||
}
|
||||
// reload to make sure the list has settled after all deletions
|
||||
await page.reload()
|
||||
await page.waitForURL('/parent/rewards')
|
||||
|
||||
// ensure default exists with no delete icon
|
||||
const defaultRow = page.getByText('Choose meal', { exact: true }).first()
|
||||
await expect(defaultRow).toBeVisible()
|
||||
// sometimes concurrent specs race and temporarily re-add a delete icon; give
|
||||
// it a moment and retry once
|
||||
let deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count()
|
||||
if (deleteCount) {
|
||||
await page.waitForTimeout(500)
|
||||
deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count()
|
||||
}
|
||||
await expect(deleteCount).toBe(0)
|
||||
|
||||
// open edit and change cost
|
||||
await defaultRow.click()
|
||||
await expect(page.locator('input#name')).toHaveValue('Choose meal')
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#cost', '50')
|
||||
})
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
|
||||
// After save: pick the row wrapper that now shows 50 pts (user copy)
|
||||
const customRow = page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.filter({ has: page.locator('text=50 pts') })
|
||||
.first()
|
||||
await expect(customRow).toBeVisible({ timeout: 10000 })
|
||||
await expect(customRow.getByRole('button', { name: /Delete/ })).toBeVisible()
|
||||
|
||||
// delete and confirm restoration using the same customRow
|
||||
await customRow.getByRole('button', { name: /Delete/ }).click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
|
||||
// wait until the exact 'Choose meal' copy showing the edited cost is gone
|
||||
await expect(
|
||||
page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.filter({ has: page.locator('text=50 pts') }),
|
||||
).toHaveCount(0, { timeout: 10000 })
|
||||
|
||||
// reload the list to clear any lingering delete icon artifacts
|
||||
await page.reload()
|
||||
await page.waitForURL('/parent/rewards')
|
||||
|
||||
// ensure no delete button is visible on the exact default 'Choose meal' row
|
||||
await expect(
|
||||
page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' }),
|
||||
).toHaveCount(0, { timeout: 10000 })
|
||||
|
||||
const finalRow = page.getByText('Choose meal', { exact: true }).first()
|
||||
await expect(finalRow).toBeVisible()
|
||||
await expect(finalRow).not.toContainText('50 pts')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivateChoreChild'
|
||||
const CHORE_NAME = 'TriggerTestChore'
|
||||
const KIND_NAME = 'TriggerChoreHelperKindness'
|
||||
const CHORE_POINTS = 10
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chore activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', 5)
|
||||
// assign chore
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
// assign kindness (set-tasks preserves other types)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
// Add a schedule so COMPLETED status persists after trigger
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
})
|
||||
|
||||
test('Kebab button appears after first chore click', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, kebab becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Kebab button disappears when clicking a non-chore item', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
const kindCard = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await kindCard.waitFor({ state: 'visible' })
|
||||
|
||||
// Click chore — kebab appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
|
||||
// Click a kindness item — selectedChoreId resets to null, kebab gone
|
||||
await kindCard.click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel chore confirmation — no points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
})
|
||||
|
||||
test('Confirm chore activation — points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + CHORE_POINTS))
|
||||
})
|
||||
|
||||
test('Confirmed chore shows COMPLETED stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking a completed chore does not open confirmation', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
// Two-click completed chore
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
// No confirmation dialog should appear; points must remain unchanged
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §2
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'GeneralChoreChild'
|
||||
const GENERAL_CHORE_NAME = 'GeneralTestChore'
|
||||
const SCHEDULED_CHORE_NAME = 'ScheduledTestChore'
|
||||
const CHORE_POINTS = 10
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('General chore — no COMPLETED stamp', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let generalChoreId = ''
|
||||
let scheduledChoreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
generalChoreId = await createTask(request, GENERAL_CHORE_NAME, CHORE_POINTS)
|
||||
scheduledChoreId = await createTask(request, SCHEDULED_CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
// Assign both chores
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [generalChoreId, scheduledChoreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Only the second chore gets a schedule
|
||||
await request.put(`/api/child/${childId}/task/${scheduledChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (generalChoreId) await request.delete(`/api/task/${generalChoreId}`)
|
||||
if (scheduledChoreId) await request.delete(`/api/task/${scheduledChoreId}`)
|
||||
})
|
||||
|
||||
test('Approving a general chore awards points', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + CHORE_POINTS))
|
||||
})
|
||||
|
||||
test('General chore does NOT show COMPLETED stamp after approval', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('General chore can be triggered again immediately after approval', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Scheduled chore DOES show COMPLETED after approval', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: SCHEDULED_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivateKindnessChild'
|
||||
const KIND_NAME = 'TriggerTestKindness'
|
||||
const KIND_HELPER_NAME = 'TriggerKindnessHelper'
|
||||
const KIND_POINTS = 5
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Kindness act activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let kindId = ''
|
||||
let kindHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
kindHelperId = await createTask(request, KIND_HELPER_NAME, 'kindness', KIND_POINTS)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId, kindHelperId], type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (kindHelperId) await request.delete(`/api/task/${kindHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first kindness act click', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, edit button becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different kindness card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = kindnessSection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: KIND_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
// First click on target card — edit button appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
// Click on a different card — target's edit button disappears
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel kindness act confirmation — no points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
})
|
||||
|
||||
test('Confirm kindness act activation — points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + KIND_POINTS))
|
||||
})
|
||||
|
||||
test('Kindness act can be activated a second time — points awarded again', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
// Second activation of the same kindness act
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + KIND_POINTS))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivatePenaltyChild'
|
||||
const PEN_NAME = 'TriggerTestPenalty'
|
||||
const PEN_HELPER_NAME = 'TriggerPenaltyHelper'
|
||||
const PEN_POINTS = 20
|
||||
const INITIAL_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Penalty activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let penId = ''
|
||||
let penHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
penHelperId = await createTask(request, PEN_HELPER_NAME, 'penalty', PEN_POINTS)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId, penHelperId], type: 'penalty' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (penHelperId) await request.delete(`/api/task/${penHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first penalty click', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, edit button becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different penalty card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = penaltySection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: PEN_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
// First click on target card — edit button appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
// Click on a different card — target's edit button disappears
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel penalty confirmation — no points deducted', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
})
|
||||
|
||||
test('Confirm penalty activation — points deducted', async ({ page }) => {
|
||||
// Starts at INITIAL_POINTS (50)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// 50 - 20 = 30
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - PEN_POINTS))
|
||||
})
|
||||
|
||||
test('Penalty can be activated multiple times — points deducted again', async ({ page }) => {
|
||||
// Starts at 30 after previous test
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// 30 - 20 = 10
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - PEN_POINTS))
|
||||
})
|
||||
|
||||
test('Points floor at 0 — penalty cannot make points negative', async ({ page }) => {
|
||||
// Starts at 10 after previous test; penalty is 20 pts
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// 10 - 20 would be -10; app floors at 0
|
||||
await expect(page.locator('.points .value')).toHaveText('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,359 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
// Tests for the "pending reward warning" dialog that appears when the parent tries to
|
||||
// activate a chore, kindness act, penalty, or a different reward while one of the child's
|
||||
// reward requests is in a pending state.
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'PendingRewardWarnChild'
|
||||
const CHORE_NAME = 'PendingWarnChore'
|
||||
const KIND_NAME = 'PendingWarnKindness'
|
||||
const PEN_NAME = 'PendingWarnPenalty'
|
||||
const PENDING_REWARD_NAME = 'PendingWarnReward'
|
||||
const OTHER_REWARD_NAME = 'PendingWarnOtherReward'
|
||||
const REWARD_COST = 10
|
||||
const CHORE_POINTS = 5
|
||||
const KIND_POINTS = 5
|
||||
const PEN_POINTS = 5
|
||||
const INITIAL_POINTS = 30 // must be >= REWARD_COST so the request-reward call succeeds
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E pending-warn test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
// ── suite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Pending reward warning dialog', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
let penId = ''
|
||||
let pendingRewardId = ''
|
||||
let otherRewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
pendingRewardId = await createReward(request, PENDING_REWARD_NAME, REWARD_COST)
|
||||
otherRewardId = await createReward(request, OTHER_REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId], type: 'penalty' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [pendingRewardId, otherRewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (pendingRewardId) await request.delete(`/api/reward/${pendingRewardId}`)
|
||||
if (otherRewardId) await request.delete(`/api/reward/${otherRewardId}`)
|
||||
})
|
||||
|
||||
// Ensure each test starts with the pending reward freshly requested
|
||||
test.beforeEach(async ({ request }) => {
|
||||
// Reset child points so the request-reward call always succeeds
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: INITIAL_POINTS } })
|
||||
// Cancel any leftover request (404 if none — ignored)
|
||||
await request.post(`/api/child/${childId}/cancel-request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
// Place a fresh pending request
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
})
|
||||
|
||||
// ── banner ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('Pending reward card shows PENDING banner', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.locator('.pending')).toHaveText('PENDING')
|
||||
})
|
||||
|
||||
// ── chore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('Chore activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
// Pending reward warning dialog appears (has a "No" button)
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
// Dialog dismissed; task confirmation dialog must NOT have appeared
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
// Points unchanged
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
// PENDING banner still visible on the reward card
|
||||
const rewardCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
await expect(rewardCard.locator('.pending')).toHaveText('PENDING')
|
||||
})
|
||||
|
||||
test('Chore activation — Confirm on pending dialog cancels pending reward and triggers chore', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
// Pending reward warning dialog
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// After confirming, the task confirmation dialog appears (has "Cancel" button)
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Chore points awarded — points changed from initial value
|
||||
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
|
||||
timeout: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
// ── kindness ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('Kindness activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
})
|
||||
|
||||
test('Kindness activation — Confirm on pending dialog cancels pending reward and triggers kindness', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
|
||||
timeout: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
// ── penalty ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('Penalty activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
})
|
||||
|
||||
test('Penalty activation — Confirm on pending dialog cancels pending reward and triggers penalty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Penalty deducted points
|
||||
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
|
||||
timeout: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
// ── other reward ──────────────────────────────────────────────────────────
|
||||
|
||||
test('Other reward activation — Cancel on pending dialog leaves state unchanged', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
// Both rewards should be ready (INITIAL_POINTS >= REWARD_COST)
|
||||
const pendingCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
const otherCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: OTHER_REWARD_NAME })
|
||||
await pendingCard.waitFor({ state: 'visible' })
|
||||
await otherCard.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(pendingCard.locator('.pending')).toHaveText('PENDING')
|
||||
await expect(otherCard.getByText('REWARD READY')).toBeVisible()
|
||||
|
||||
const pointsBefore = await getPoints(page)
|
||||
await activateItem(otherCard)
|
||||
|
||||
// Pending reward warning dialog appears when another reward has a pending request
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
// PENDING banner still visible — reward request was not cancelled
|
||||
await expect(pendingCard.locator('.pending')).toHaveText('PENDING')
|
||||
})
|
||||
|
||||
test('Other reward activation — Confirm on pending dialog cancels the pending reward', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const pendingCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
const otherCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: OTHER_REWARD_NAME })
|
||||
await pendingCard.waitFor({ state: 'visible' })
|
||||
await otherCard.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(otherCard)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Pending dialog closes; reward confirm does NOT auto-open (other reward must be re-clicked)
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
|
||||
// The pending reward request was cancelled — PENDING banner should disappear
|
||||
await expect(pendingCard.locator('.pending')).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivateRewardChild'
|
||||
const REWARD_NAME = 'TriggerTestReward'
|
||||
const REWARD_HELPER_NAME = 'TriggerRewardHelper'
|
||||
const REWARD_COST = 20
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Reward redemption', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
let rewardHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
rewardHelperId = await createReward(request, REWARD_HELPER_NAME, REWARD_COST)
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId, rewardHelperId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
if (rewardHelperId) await request.delete(`/api/reward/${rewardHelperId}`)
|
||||
})
|
||||
|
||||
test('Locked reward shows correct points needed text', async ({ page }) => {
|
||||
// Child starts at 0 pts; reward costs 20 → "20 more points"
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
|
||||
await expect(card.getByText('REWARD READY')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking a locked reward does not open confirmation dialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Two-click on locked reward
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
// triggerReward returns early when points_needed > 0 — no dialog
|
||||
await expect(page.locator('.points .value')).toHaveText('0')
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Reward shows REWARD READY when child has sufficient points', async ({ page, request }) => {
|
||||
// Set child points to match reward cost
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
await expect(card.getByText(/more points/)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a ready reward', async ({ page }) => {
|
||||
// Child has REWARD_COST pts (set in previous test)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, edit button becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different reward card', async ({ page }) => {
|
||||
// Child has REWARD_COST pts (set in previous test); both rewards show REWARD READY
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = rewardSection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: REWARD_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
// First click on target card — edit button appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
// Click on a different reward card — target's edit button disappears
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel reward redemption — points not deducted', async ({ page }) => {
|
||||
// Child has REWARD_COST pts (from two tests ago; edit button test did not consume them)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Confirm reward redemption — points deducted and reward locks again', async ({ page }) => {
|
||||
// Child still has REWARD_COST pts after the cancelled redemption
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Points reduced by reward cost
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST))
|
||||
// Reward is now locked again — shows points needed
|
||||
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
|
||||
await expect(card.getByText('REWARD READY')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
194
frontend/e2e/mode_parent/task-activation/scroll-and-flow.spec.ts
Normal file
194
frontend/e2e/mode_parent/task-activation/scroll-and-flow.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
// Tests for scrollable list flow and first-click scroll-to-center behavior.
|
||||
// 8 kindness items are assigned to a dedicated child so the list overflows
|
||||
// a narrow (480 px) viewport, reliably exercising the horizontal scroll path.
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Returns true if the element's bounding rect overlaps the current page viewport.
|
||||
* Uses getBoundingClientRect() — reliable across all Playwright versions and
|
||||
* correctly handles elements clipped by overflow scroll containers.
|
||||
*/
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const CHILD_NAME = 'ScrollFlowChild'
|
||||
const ITEM_COUNT = 8
|
||||
const ITEM_NAMES = Array.from({ length: ITEM_COUNT }, (_, i) => `ScrollTestKindness${i + 1}`)
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(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: 'kindness' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Scroll and flow', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const taskIds: string[] = []
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
for (const name of ITEM_NAMES) {
|
||||
taskIds.push(await createTask(request, name))
|
||||
}
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: taskIds, type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of taskIds) await request.delete(`/api/task/${id}`)
|
||||
taskIds.length = 0
|
||||
})
|
||||
|
||||
test('Kindness list is horizontally scrollable when many items are assigned', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const scrollWrapper = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// Total content width (8 × 140 px + 7 × 12 px gaps ≈ 1204 px) must exceed container width
|
||||
const isOverflowing = await scrollWrapper.evaluate(
|
||||
(el: HTMLElement) => el.scrollWidth > el.clientWidth,
|
||||
)
|
||||
expect(isOverflowing).toBe(true)
|
||||
|
||||
// The last card in DOM order is outside the 480 px viewport at scrollLeft=0
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const lastCard = section.locator('.item-card').last()
|
||||
|
||||
expect(await isInViewport(lastCard)).toBe(false)
|
||||
})
|
||||
|
||||
test('Scrolling the list reveals hidden items', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const scrollWrapper = section.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// Use DOM-order first/last — independent of API return order
|
||||
const firstCard = section.locator('.item-card').first()
|
||||
const lastCard = section.locator('.item-card').last()
|
||||
|
||||
// Initially: first item is in viewport, last item is scrolled off to the right
|
||||
expect(await isInViewport(firstCard)).toBe(true)
|
||||
expect(await isInViewport(lastCard)).toBe(false)
|
||||
|
||||
// Programmatically scroll to the end — override smooth-scroll so position is immediate
|
||||
await scrollWrapper.evaluate((el: HTMLElement) => {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Last item is now visible; first item has scrolled off to the left
|
||||
expect(await isInViewport(lastCard)).toBe(true)
|
||||
expect(await isInViewport(firstCard)).toBe(false)
|
||||
})
|
||||
|
||||
test('First click on an off-screen item scrolls it into view and marks it ready', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const scrollWrapper = section.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// The last item in DOM order is definitely off-screen at scrollLeft=0 on a 480 px viewport
|
||||
const lastCard = section.locator('.item-card').last()
|
||||
expect(await isInViewport(lastCard)).toBe(false)
|
||||
|
||||
// force: true is required because the element's bounding-box center is outside
|
||||
// the page viewport — that is exactly the condition we are testing (scroll-to-center).
|
||||
await lastCard.click({ force: true })
|
||||
|
||||
// The first-click handler runs centerItem() then emits item-ready
|
||||
await expect(lastCard).toHaveClass(/item-ready/, { timeout: 5000 })
|
||||
|
||||
// Wait for the smooth-scroll animation to settle
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// After centering, the item must be visible within the 480 px viewport
|
||||
expect(await isInViewport(lastCard)).toBe(true)
|
||||
})
|
||||
|
||||
test('Scrolling does not prevent first-click centering on a partially visible item', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const scrollWrapper = section.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// Scroll to the middle — override smooth-scroll so position is immediate
|
||||
await scrollWrapper.evaluate((el: HTMLElement) => {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = Math.floor(el.scrollWidth / 2)
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// The first item in DOM order is now off-screen to the left after scrolling to the middle
|
||||
const firstCard = section.locator('.item-card').first()
|
||||
expect(await isInViewport(firstCard)).toBe(false)
|
||||
|
||||
// Click the first item (force: true because its center is left of the viewport)
|
||||
await firstCard.click({ force: true })
|
||||
await expect(firstCard).toHaveClass(/item-ready/, { timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// First item should now be centered and in the viewport
|
||||
expect(await isInViewport(firstCard)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §1
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignTitleChild'
|
||||
|
||||
async function createTestChild(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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Assign view heading shows child name', () => {
|
||||
let childId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
})
|
||||
|
||||
test('"Assign Chores" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Chores.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Assign Rewards" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Rewards.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Assign Kindness Acts" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Kindness Acts.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Assign Penalties" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Penalties.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignChoreChild'
|
||||
const CHORE1_NAME = 'AssignTestChore1'
|
||||
const CHORE2_NAME = 'AssignTestChore2'
|
||||
const CHORE3_NAME = 'AssignTestChore3'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const res = await request.get('/api/task/list')
|
||||
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
|
||||
return data.tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Chore assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let chore1Id = ''
|
||||
let chore2Id = ''
|
||||
let chore3Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test tasks from a previous run
|
||||
const taskRes = await request.get('/api/task/list')
|
||||
const taskData = await taskRes.json()
|
||||
for (const t of taskData.tasks ?? []) {
|
||||
if ([CHORE1_NAME, CHORE2_NAME, CHORE3_NAME].includes(t.name)) {
|
||||
await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
chore1Id = await createTestTask(request, CHORE1_NAME, 'chore')
|
||||
chore2Id = await createTestTask(request, CHORE2_NAME, 'chore')
|
||||
chore3Id = await createTestTask(request, CHORE3_NAME, 'chore')
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (chore1Id) await request.delete(`/api/task/${chore1Id}`)
|
||||
if (chore2Id) await request.delete(`/api/task/${chore2Id}`)
|
||||
if (chore3Id) await request.delete(`/api/task/${chore3Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Chores" button navigates to the chore assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Test-specific chores are listed
|
||||
await expect(page.getByText(CHORE1_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(CHORE2_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select chores and submit — items appear in ParentView chores section', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Select two chores
|
||||
await page.getByText(CHORE1_NAME, { exact: true }).click()
|
||||
await page.getByText(CHORE2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
await expect(page.getByText(CHORE1_NAME)).toBeVisible()
|
||||
await expect(page.getByText(CHORE2_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows assigned items as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-chores`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Previously assigned items must be pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// An unassigned item must not be checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Uncheck the two previously assigned chores
|
||||
await page.getByText(CHORE1_NAME, { exact: true }).click()
|
||||
await page.getByText(CHORE2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
await expect(page.getByText(CHORE1_NAME)).not.toBeVisible()
|
||||
await expect(page.getByText(CHORE2_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
await page.getByText(CHORE3_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back to the assign view and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-chores`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignKindnessChild'
|
||||
const KIND1_NAME = 'AssignTestKindness1'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const res = await request.get('/api/task/list')
|
||||
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
|
||||
return data.tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Kindness act assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let kind1Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test task from a previous run
|
||||
const taskRes = await request.get('/api/task/list')
|
||||
const taskData = await taskRes.json()
|
||||
for (const t of taskData.tasks ?? []) {
|
||||
if (t.name === KIND1_NAME) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
kind1Id = await createTestTask(request, KIND1_NAME, 'kindness')
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (kind1Id) await request.delete(`/api/task/${kind1Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Kindness Acts" button navigates to the kindness assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Test-specific kindness act is listed
|
||||
await expect(page.getByText(KIND1_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select a kindness act and submit — item appears in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
await expect(page.getByText(KIND1_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows the assigned item as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-kindness`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck the kindness act and submit — item is removed from ParentView', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned item
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
await expect(page.getByText(KIND1_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Check the item
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-kindness`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignPenaltyChild'
|
||||
const PEN1_NAME = 'AssignTestPenalty1'
|
||||
const PEN2_NAME = 'AssignTestPenalty2'
|
||||
const PEN3_NAME = 'AssignTestPenalty3'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const res = await request.get('/api/task/list')
|
||||
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
|
||||
return data.tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Penalty assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let pen1Id = ''
|
||||
let pen2Id = ''
|
||||
let pen3Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test tasks from a previous run
|
||||
const taskRes = await request.get('/api/task/list')
|
||||
const taskData = await taskRes.json()
|
||||
for (const t of taskData.tasks ?? []) {
|
||||
if ([PEN1_NAME, PEN2_NAME, PEN3_NAME].includes(t.name)) {
|
||||
await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
pen1Id = await createTestTask(request, PEN1_NAME, 'penalty')
|
||||
pen2Id = await createTestTask(request, PEN2_NAME, 'penalty')
|
||||
pen3Id = await createTestTask(request, PEN3_NAME, 'penalty')
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (pen1Id) await request.delete(`/api/task/${pen1Id}`)
|
||||
if (pen2Id) await request.delete(`/api/task/${pen2Id}`)
|
||||
if (pen3Id) await request.delete(`/api/task/${pen3Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Penalties" button navigates to the penalty assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Test-specific penalty items are listed
|
||||
await expect(page.getByText(PEN1_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(PEN2_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select penalties and submit — items appear in ParentView penalties section', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Select two penalties
|
||||
await page.getByText(PEN1_NAME, { exact: true }).click()
|
||||
await page.getByText(PEN2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
await expect(page.getByText(PEN1_NAME)).toBeVisible()
|
||||
await expect(page.getByText(PEN2_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows assigned penalties as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-penalties`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Previously assigned items should be pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// An unassigned item should not be checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned penalties
|
||||
await page.getByText(PEN1_NAME, { exact: true }).click()
|
||||
await page.getByText(PEN2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
await expect(page.getByText(PEN1_NAME)).not.toBeVisible()
|
||||
await expect(page.getByText(PEN2_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
await page.getByText(PEN3_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-penalties`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignRewardChild'
|
||||
const REW1_NAME = 'AssignTestReward1'
|
||||
const REW2_NAME = 'AssignTestReward2'
|
||||
const REW3_NAME = 'AssignTestReward3'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestReward(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/reward/add', { data: { name, description: 'Test reward', cost: 10 } })
|
||||
const res = await request.get('/api/reward/list')
|
||||
const data = (await res.json()) as { rewards?: { name: string; id: string }[] }
|
||||
return data.rewards?.find((r) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Reward assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rew1Id = ''
|
||||
let rew2Id = ''
|
||||
let rew3Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test rewards from a previous run
|
||||
const rewardRes = await request.get('/api/reward/list')
|
||||
const rewardData = await rewardRes.json()
|
||||
for (const r of rewardData.rewards ?? []) {
|
||||
if ([REW1_NAME, REW2_NAME, REW3_NAME].includes(r.name)) {
|
||||
await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
rew1Id = await createTestReward(request, REW1_NAME)
|
||||
rew2Id = await createTestReward(request, REW2_NAME)
|
||||
rew3Id = await createTestReward(request, REW3_NAME)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rew1Id) await request.delete(`/api/reward/${rew1Id}`)
|
||||
if (rew2Id) await request.delete(`/api/reward/${rew2Id}`)
|
||||
if (rew3Id) await request.delete(`/api/reward/${rew3Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Rewards" button navigates to the reward assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Test-specific rewards are listed
|
||||
await expect(page.getByText(REW1_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(REW2_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select rewards and submit — items appear in ParentView rewards section', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Select two rewards
|
||||
await page.getByText(REW1_NAME, { exact: true }).click()
|
||||
await page.getByText(REW2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
await expect(page.getByText(REW1_NAME)).toBeVisible()
|
||||
await expect(page.getByText(REW2_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows assigned rewards as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-rewards`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Previously assigned items should be pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// An unassigned item should not be checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned rewards
|
||||
await page.getByText(REW1_NAME, { exact: true }).click()
|
||||
await page.getByText(REW2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
await expect(page.getByText(REW1_NAME)).not.toBeVisible()
|
||||
await expect(page.getByText(REW2_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
await page.getByText(REW3_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-rewards`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyChoreChild'
|
||||
const CHORE_NAME = 'ModifyTestChore'
|
||||
const CHORE_POINTS = 10
|
||||
const HELPER_KIND_NAME = 'ModifyChoreHelperKindness'
|
||||
|
||||
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')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openChoreOverrideModal(page: Page, card: Locator): Promise<void> {
|
||||
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 page.getByRole('button', { name: 'Edit Points' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Chore edit points', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let helperKindId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
helperKindId = await createTask(request, HELPER_KIND_NAME, 'kindness', 5)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [helperKindId], type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (helperKindId) await request.delete(`/api/task/${helperKindId}`)
|
||||
})
|
||||
|
||||
test('Kebab button is hidden initially, visible after first click, hidden after deselect', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden (v-show=false while selectedChoreId != item.id)
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeHidden()
|
||||
|
||||
// First click: card enters ready state, kebab shown
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
|
||||
// Clicking a kindness card resets selectedChoreId to null → kebab hidden
|
||||
const kindCard = kindnessSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: HELPER_KIND_NAME })
|
||||
await kindCard.click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeHidden()
|
||||
})
|
||||
|
||||
test('Kebab menu contains "Edit Points" and "Schedule"', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
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: 'Edit Points' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Edit Points" opens override modal pre-populated with current points', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
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 page.getByRole('button', { name: 'Edit Points' }).click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(CHORE_NAME)
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(CHORE_POINTS))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Clicking outside the modal does not close it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openChoreOverrideModal(page, card)
|
||||
|
||||
// Click the backdrop area far from the modal dialog
|
||||
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 }, force: true })
|
||||
|
||||
// Modal must still be visible
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not save — card still shows original points, no ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('99')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText(`${CHORE_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a valid new value updates the displayed points on the card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('50')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('50 Points')
|
||||
})
|
||||
|
||||
test('⭐ override badge appears on the card after saving a custom value', async ({ page }) => {
|
||||
// Badge state persists server-side; verify on a fresh page load
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('⭐ badge persists after changing value back to the original default', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Change override back to original value — badge stays until item is unassigned/reassigned
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill(String(CHORE_POINTS))
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText(`${CHORE_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('-1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Zero is a valid value — card shows "0 Points" with ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('0')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('0 Points')
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
})
|
||||
170
frontend/e2e/mode_parent/task-modification/chore-reset.spec.ts
Normal file
170
frontend/e2e/mode_parent/task-modification/chore-reset.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyResetChild'
|
||||
const CHORE_NAME = 'ModifyResetChore'
|
||||
const CHORE_POINTS = 10
|
||||
const HELPER_KIND_NAME = 'ModifyResetKindnessHelper'
|
||||
|
||||
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')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openChoreKebab(page: Page, card: Locator): Promise<void> {
|
||||
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()
|
||||
}
|
||||
|
||||
test.describe('Chore reset', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let helperKindId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
helperKindId = await createTask(request, HELPER_KIND_NAME, 'kindness', 5)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [helperKindId], type: 'kindness' },
|
||||
})
|
||||
// Add a schedule so COMPLETED status persists after trigger
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (helperKindId) await request.delete(`/api/task/${helperKindId}`)
|
||||
})
|
||||
|
||||
test('"Reset" is absent from kebab menu when chore is not yet completed', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreKebab(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Edit Points' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Chore shows COMPLETED stamp after activation and confirmation', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Reset" appears in kebab menu after chore is completed', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
|
||||
await openChoreKebab(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking "Reset" removes the COMPLETED stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
|
||||
await openChoreKebab(page, card)
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Chore is re-activatable after reset — returns to COMPLETED on confirm', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Chore is not completed after the previous test's reset
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reset → activate cycle is repeatable without error', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Reset the now-completed chore
|
||||
await openChoreKebab(page, card)
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh so item-ready state is cleared before activating
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Activate again — COMPLETED returns
|
||||
await activateItem(card)
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyKindnessChild'
|
||||
const KIND_NAME = 'ModifyTestKindness'
|
||||
const KIND_HELPER_NAME = 'ModifyKindnessHelper'
|
||||
const KIND_POINTS = 15
|
||||
|
||||
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')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Kindness act edit points', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let kindId = ''
|
||||
let kindHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
kindHelperId = await createTask(request, KIND_HELPER_NAME, 'kindness', 5)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId, kindHelperId], type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (kindHelperId) await request.delete(`/api/task/${kindHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a kindness card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different kindness card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = kindnessSection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: KIND_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button opens modal with kindness name and "Assign new points" subtitle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(KIND_NAME)
|
||||
await expect(page.locator('.modal-subtitle')).toContainText('new points')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Input is pre-populated with the current point value', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(KIND_POINTS))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not save — points remain unchanged, no ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('77')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText(`${KIND_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a new value updates displayed points and shows ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('20')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('20 Points')
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('-5')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyActivateChild'
|
||||
const CHORE_NAME = 'ModifyActChore'
|
||||
const KIND_NAME = 'ModifyActKindness'
|
||||
const PEN_NAME = 'ModifyActPenalty'
|
||||
const REWARD_NAME = 'ModifyActReward'
|
||||
|
||||
const CHORE_POINTS = 10
|
||||
const KIND_POINTS = 10
|
||||
const PEN_POINTS = 10
|
||||
const REWARD_COST = 100
|
||||
|
||||
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')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
const { rewards } = (await list.json()) as { rewards: { name: string; id: string }[] }
|
||||
return rewards?.find((r) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openChoreOverrideModal(page: Page, card: Locator): Promise<void> {
|
||||
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 page.getByRole('button', { name: 'Edit Points' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Modified points take effect on activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
let penId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId], type: 'penalty' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('Modified chore points are awarded on activation (override 25, default 10)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit override to 25 via kebab → Edit Points
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('25')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — activate chore and verify points awarded at override rate
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const choreCard = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await choreCard.waitFor({ state: 'visible' })
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(choreCard)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + 25))
|
||||
})
|
||||
|
||||
test('Modified kindness points are awarded on activation (override 8, default 10)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit override to 8 via edit button
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('8')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — activate and verify points use override value
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const kindCard = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await kindCard.waitFor({ state: 'visible' })
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(kindCard)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + 8))
|
||||
})
|
||||
|
||||
test('Modified penalty points are deducted at override rate (override 3, default 10)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit penalty override to 3 via edit button
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('3')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — activate penalty and verify deduction uses override value
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const penCard = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await penCard.waitFor({ state: 'visible' })
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(penCard)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - 3))
|
||||
})
|
||||
|
||||
test('Modified reward cost is reflected in points needed display (override 30)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit reward override to 30 via edit button
|
||||
// Child currently has 25 + 8 - 3 = 30 points from previous tests
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('30')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — verify reward shows "REWARD READY" (30pts held, 30 cost override)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const rewardCard = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await rewardCard.waitFor({ state: 'visible' })
|
||||
await expect(rewardCard.getByText('REWARD READY')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §5
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'OverrideSSEChild'
|
||||
const CHORE_NAME = 'OverrideSSEChore'
|
||||
const KIND_NAME = 'OverrideSSEKindness'
|
||||
const PEN_NAME = 'OverrideSSEPenalty'
|
||||
|
||||
const CHORE_POINTS = 10
|
||||
const KIND_POINTS = 15
|
||||
const PEN_POINTS = 8
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Override SSE updates child view', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
let penId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId], type: 'penalty' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
})
|
||||
|
||||
test('Changing chore override points updates child view in real time', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Verify original points displayed
|
||||
await expect(card.getByText(`${CHORE_POINTS} Points`)).toBeVisible()
|
||||
|
||||
// Set override via API
|
||||
await request.put(`/api/child/${childId}/override`, {
|
||||
data: { entity_id: choreId, entity_type: 'task', custom_value: 25 },
|
||||
})
|
||||
|
||||
// Without page reload, verify the card updates
|
||||
await expect(card.getByText('25 Points')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Changing kindness override points updates child view', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText(`${KIND_POINTS} Points`)).toBeVisible()
|
||||
|
||||
await request.put(`/api/child/${childId}/override`, {
|
||||
data: { entity_id: kindId, entity_type: 'task', custom_value: 30 },
|
||||
})
|
||||
|
||||
await expect(card.getByText('30 Points')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Changing penalty override points updates child view', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText(`${PEN_POINTS} Points`)).toBeVisible()
|
||||
|
||||
await request.put(`/api/child/${childId}/override`, {
|
||||
data: { entity_id: penId, entity_type: 'task', custom_value: 20 },
|
||||
})
|
||||
|
||||
await expect(card.getByText('20 Points')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Deleting an override reverts child view to original points', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Currently showing overridden value from earlier test
|
||||
await expect(card.getByText('25 Points')).toBeVisible()
|
||||
|
||||
// Delete the override
|
||||
await request.delete(`/api/child/${childId}/override/${choreId}`)
|
||||
|
||||
// Without page reload, verify the card reverts to original points
|
||||
await expect(card.getByText(`${CHORE_POINTS} Points`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyPenaltyChild'
|
||||
const PEN_NAME = 'ModifyTestPenalty'
|
||||
const PEN_HELPER_NAME = 'ModifyPenaltyHelper'
|
||||
const PEN_POINTS = 5
|
||||
|
||||
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')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): 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, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Penalty edit points', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let penId = ''
|
||||
let penHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
penHelperId = await createTask(request, PEN_HELPER_NAME, 'penalty', PEN_POINTS)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId, penHelperId], type: 'penalty' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (penHelperId) await request.delete(`/api/task/${penHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a penalty card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different penalty card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = penaltySection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: PEN_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button opens modal with penalty name and "Assign new points" subtitle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(PEN_NAME)
|
||||
await expect(page.locator('.modal-subtitle')).toContainText('new points')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Input is pre-populated with the raw point value (positive, not negated)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
// The input holds the positive magnitude; the negative display is a UI convention
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(PEN_POINTS))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not change the display', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('99')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
// Penalty displays points negated: -5 Points
|
||||
await expect(card.locator('.item-points')).toContainText(`-${PEN_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a new value updates card to show negated override and ⭐ badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('12')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('-12 Points')
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('-1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §10
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'PendingResetChild'
|
||||
const CHORE_NAME = 'PendingResetChore'
|
||||
const REWARD_NAME = 'PendingResetReward'
|
||||
const CHORE_POINTS = 10
|
||||
const REWARD_COST = 20
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', {
|
||||
data: { name, description: 'E2E pending-reset test', cost },
|
||||
})
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Pending status resets on task/reward modification', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
// Assign chore + reward
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
// Create interval schedule for chore (no deadline)
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('10.1 Editing chore points clears pending status', async ({ page, request }) => {
|
||||
// Create pending via child confirm-chore API
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Verify PENDING stamp is visible
|
||||
const stamp = card.locator('.chore-stamp')
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Edit the parent task's points via API (triggers SSE → clears pending)
|
||||
await request.put(`/api/task/${choreId}/edit`, { data: { points: CHORE_POINTS + 1 } })
|
||||
|
||||
// Wait for SSE to propagate — PENDING should disappear
|
||||
await expect(stamp).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.2 Changing chore schedule clears pending status', async ({ page, request }) => {
|
||||
// Create pending again (10.1 cleared the previous one)
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const stamp = card.locator('.chore-stamp')
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Change the schedule via API (triggers SSE → clears pending)
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 2,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(stamp).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.5 Editing task name only does NOT clear pending', async ({ page, request }) => {
|
||||
// Create pending again
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const stamp = card.locator('.chore-stamp')
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Edit name only (no points/type change) — should NOT clear pending
|
||||
await request.put(`/api/task/${choreId}/edit`, { data: { name: CHORE_NAME } })
|
||||
|
||||
// Wait briefly for SSE to propagate, then verify PENDING is still there
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Clean up: cancel the pending confirmation for subsequent tests
|
||||
await request.post(`/api/child/${childId}/cancel-confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test('10.4 Completed chores remain completed after point edit', async ({ page, request }) => {
|
||||
// Directly trigger-task (parent approval) → creates approved confirmation for scheduled chore
|
||||
await request.post(`/api/child/${childId}/trigger-task`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Verify COMPLETED is visible
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
|
||||
// Edit points — only status='pending' is cleared, not approved
|
||||
await request.put(`/api/task/${choreId}/edit`, { data: { points: CHORE_POINTS + 2 } })
|
||||
|
||||
// Wait for SSE, then verify COMPLETED stamp is still visible
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('10.3 Editing reward cost clears pending reward request', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Create pending reward request via API
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
|
||||
// Wait for SSE → PENDING on reward (use .pending class to avoid matching reward name)
|
||||
const pendingBadge = card.locator('.pending')
|
||||
await expect(pendingBadge).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Edit reward cost via API → clears pending
|
||||
await request.put(`/api/reward/${rewardId}/edit`, { data: { cost: REWARD_COST + 5 } })
|
||||
|
||||
// Wait for SSE — PENDING should disappear
|
||||
await expect(pendingBadge).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyRewardChild'
|
||||
const REWARD_NAME = 'ModifyTestReward'
|
||||
const REWARD_COST = 100
|
||||
|
||||
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')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
const { rewards } = (await list.json()) as { rewards: { name: string; id: string }[] }
|
||||
return rewards?.find((r) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Reward edit cost', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a reward card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button opens modal with reward name and "Assign new cost" subtitle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(REWARD_NAME)
|
||||
await expect(page.locator('.modal-subtitle')).toContainText('new cost')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Input is pre-populated with the current cost', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(REWARD_COST))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not change the points needed display', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('999')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
// Child has 0 points; reward cost 100 → "100 more points"
|
||||
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a new cost updates the points needed and shows ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('50')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
// Child still has 0 points; override cost 50 → "50 more points"
|
||||
await expect(card.getByText('50 more points')).toBeVisible()
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Setting cost to 0 makes the reward immediately redeemable', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('0')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('-1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Editing a pending reward shows a warning dialog — Cancel aborts the edit', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Give child enough points to satisfy the original reward cost, then create a pending request
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
||||
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('PENDING')).toBeVisible()
|
||||
|
||||
// First click → edit button visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
// PendingRewardDialog should appear warning about the pending state
|
||||
await expect(page.locator('.modal-message')).toContainText('pending')
|
||||
|
||||
// Clicking "No" closes the dialog without opening the override modal
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
await expect(page.locator('.modal-message')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Editing a pending reward — confirming the warning opens the override modal', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Pending state was established in the previous test; navigate fresh
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('PENDING')).toBeVisible()
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
// PendingRewardDialog visible
|
||||
await expect(page.locator('.modal-message')).toContainText('pending')
|
||||
|
||||
// Confirming cancels the pending request and opens the override modal
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §6
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ScrollEditChild'
|
||||
const CHORE_COUNT = 12
|
||||
const REWARD_COUNT = 6
|
||||
const CHORE_PREFIX = 'ScrollEditChore'
|
||||
const REWARD_PREFIX = 'ScrollEditReward'
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
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: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'scroll test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Scroll to edited task after save', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const choreIds: string[] = []
|
||||
const rewardIds: string[] = []
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
|
||||
for (let i = 1; i <= CHORE_COUNT; i++) {
|
||||
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5 + i))
|
||||
}
|
||||
for (let i = 1; i <= REWARD_COUNT; i++) {
|
||||
rewardIds.push(await createReward(request, `${REWARD_PREFIX}${i}`, 10 + i))
|
||||
}
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: rewardIds },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of choreIds) await request.delete(`/api/task/${id}`)
|
||||
for (const id of rewardIds) await request.delete(`/api/reward/${id}`)
|
||||
})
|
||||
|
||||
test('Edited chore card scrolls into viewport after override save', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
// Target the last chore — it should be scrolled off-screen in a narrow viewport
|
||||
const targetName = `${CHORE_PREFIX}${CHORE_COUNT}`
|
||||
const targetCard = choreSection(page).locator('.item-card').filter({ hasText: targetName })
|
||||
await choreSection(page).locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
// Click the target chore to bring up the kebab menu (first click centers + shows menu)
|
||||
await targetCard.click()
|
||||
await expect(targetCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
|
||||
// Open the kebab menu → "Edit Points"
|
||||
await targetCard.getByRole('button', { name: 'Options' }).click()
|
||||
await page.getByText('Edit Points').click()
|
||||
|
||||
// Override modal should be visible with input
|
||||
const input = page.locator('#custom-value')
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
// Set a new value and save
|
||||
await input.fill('99')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// After SSE override event → list refreshes → card scrolls into view
|
||||
// Use expect.poll to retry, allowing scroll animation to complete
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await expect.poll(() => isInViewport(targetCard), { timeout: 3000 }).toBe(true)
|
||||
})
|
||||
|
||||
test('Edited reward card scrolls into viewport after override save', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
// Target the last reward
|
||||
const targetName = `${REWARD_PREFIX}${REWARD_COUNT}`
|
||||
const targetCard = rewardSection(page).locator('.item-card').filter({ hasText: targetName })
|
||||
await rewardSection(page).locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
// Click the target reward to center it and show the edit button
|
||||
await targetCard.click()
|
||||
await expect(targetCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
|
||||
// Click the Edit button
|
||||
await targetCard.locator('.edit-button').click()
|
||||
|
||||
// Override modal should be visible
|
||||
const input = page.locator('#custom-value')
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
// Set a new value and save
|
||||
await input.fill('77')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// After SSE override event → list refreshes → card scrolls into view
|
||||
// Use expect.poll to retry, allowing scroll animation to complete
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await expect.poll(() => isInViewport(targetCard), { timeout: 3000 }).toBe(true)
|
||||
})
|
||||
})
|
||||
240
frontend/e2e/mode_parent/task-sorting/child-sort-order.spec.ts
Normal file
240
frontend/e2e/mode_parent/task-sorting/child-sort-order.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §8
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ChildSortChild'
|
||||
|
||||
// Scheduled chores — two with different deadline times
|
||||
const SCHED_EARLY = 'CSort1SchedEarly'
|
||||
const SCHED_LATE = 'CSort2SchedLate'
|
||||
// General chore (no schedule)
|
||||
const GENERAL_CHORE = 'CSort3General'
|
||||
// Pending chore (child confirmed)
|
||||
const PENDING_CHORE = 'CSort4Pending'
|
||||
// Non-today chore (should be hidden in child view)
|
||||
const NON_TODAY_CHORE = 'CSortHiddenNonToday'
|
||||
|
||||
// Rewards with different costs
|
||||
const PENDING_REWARD = 'CSortRewardPending'
|
||||
const CHEAP_REWARD = 'CSortRewardCheap'
|
||||
const EXPENSIVE_REWARD = 'CSortRewardExpensive'
|
||||
|
||||
const POINTS = 5
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
|
||||
function todayDayIndex(): number {
|
||||
return new Date().getDay()
|
||||
}
|
||||
|
||||
function nonTodayDayIndex(): number {
|
||||
return (new Date().getDay() + 1) % 7
|
||||
}
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(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: POINTS, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'sort test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Child mode sort order', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let schedEarlyId = ''
|
||||
let schedLateId = ''
|
||||
let generalChoreId = ''
|
||||
let pendingChoreId = ''
|
||||
let nonTodayChoreId = ''
|
||||
let pendingRewardId = ''
|
||||
let cheapRewardId = ''
|
||||
let expensiveRewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
|
||||
schedEarlyId = await createTask(request, SCHED_EARLY)
|
||||
schedLateId = await createTask(request, SCHED_LATE)
|
||||
generalChoreId = await createTask(request, GENERAL_CHORE)
|
||||
pendingChoreId = await createTask(request, PENDING_CHORE)
|
||||
nonTodayChoreId = await createTask(request, NON_TODAY_CHORE)
|
||||
|
||||
// Rewards: different costs so points_needed differs
|
||||
pendingRewardId = await createReward(request, PENDING_REWARD, 10)
|
||||
cheapRewardId = await createReward(request, CHEAP_REWARD, 15)
|
||||
expensiveRewardId = await createReward(request, EXPENSIVE_REWARD, 100)
|
||||
|
||||
const allChoreIds = [schedEarlyId, schedLateId, generalChoreId, pendingChoreId, nonTodayChoreId]
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: allChoreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [pendingRewardId, cheapRewardId, expensiveRewardId] },
|
||||
})
|
||||
|
||||
// Scheduled early: today with deadline at 23:00 (future deadline, low sort key)
|
||||
await request.put(`/api/child/${childId}/task/${schedEarlyId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: todayDayIndex(), hour: 23, minute: 0 }],
|
||||
default_hour: 23,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Scheduled late: today with deadline at 23:59 (future deadline, higher sort key)
|
||||
await request.put(`/api/child/${childId}/task/${schedLateId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: todayDayIndex(), hour: 23, minute: 59 }],
|
||||
default_hour: 23,
|
||||
default_minute: 59,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Non-today chore: scheduled for a different day only
|
||||
await request.put(`/api/child/${childId}/task/${nonTodayChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Pending chore: child confirms
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: pendingChoreId },
|
||||
})
|
||||
|
||||
// Pending reward: request it
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
|
||||
// General chore: no schedule needed
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [schedEarlyId, schedLateId, generalChoreId, pendingChoreId, nonTodayChoreId]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
for (const id of [pendingRewardId, cheapRewardId, expensiveRewardId]) {
|
||||
if (id) await request.delete(`/api/reward/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Child chore sort: scheduled today (earliest deadline) → general → pending', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Clear parent auth so the router allows /child routes
|
||||
await page.addInitScript(() => localStorage.removeItem('parentAuth'))
|
||||
await page.goto(`/child/${childId}`)
|
||||
const section = choreSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
// Scheduled for today (priority 0) sorted by earliest deadline → earliest first
|
||||
const earlyIdx = names.indexOf(SCHED_EARLY)
|
||||
const lateIdx = names.indexOf(SCHED_LATE)
|
||||
// General (priority 1)
|
||||
const generalIdx = names.indexOf(GENERAL_CHORE)
|
||||
// Pending (priority 2)
|
||||
const pendingIdx = names.indexOf(PENDING_CHORE)
|
||||
|
||||
expect(earlyIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(lateIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(generalIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Scheduled → earliest first
|
||||
expect(earlyIdx).toBeLessThan(lateIdx)
|
||||
// Scheduled before general
|
||||
expect(lateIdx).toBeLessThan(generalIdx)
|
||||
// General before pending
|
||||
expect(generalIdx).toBeLessThan(pendingIdx)
|
||||
|
||||
// Non-today chore should be hidden
|
||||
expect(names).not.toContain(NON_TODAY_CHORE)
|
||||
})
|
||||
|
||||
test('Child reward sort: pending first, then ascending by points needed', async ({ page }) => {
|
||||
// Clear parent auth so the router allows /child routes
|
||||
await page.addInitScript(() => localStorage.removeItem('parentAuth'))
|
||||
await page.goto(`/child/${childId}`)
|
||||
const section = rewardSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
const pendingIdx = names.indexOf(PENDING_REWARD)
|
||||
const cheapIdx = names.indexOf(CHEAP_REWARD)
|
||||
const expensiveIdx = names.indexOf(EXPENSIVE_REWARD)
|
||||
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(cheapIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(expensiveIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Pending first
|
||||
expect(pendingIdx).toBeLessThan(cheapIdx)
|
||||
expect(pendingIdx).toBeLessThan(expensiveIdx)
|
||||
|
||||
// Then ascending by points_needed (cheap reward costs 15, expensive 100; child has 50 points)
|
||||
// Points needed: cheap = max(0, 15-50) = 0; expensive = max(0, 100-50) = 50
|
||||
// So cheap should come before expensive
|
||||
expect(cheapIdx).toBeLessThan(expensiveIdx)
|
||||
})
|
||||
})
|
||||
224
frontend/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts
Normal file
224
frontend/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §7
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ParentSortChild'
|
||||
const PENDING_CHORE = 'Sort1Pending'
|
||||
const GENERAL_CHORE = 'Sort2General'
|
||||
const EXPIRED_CHORE = 'Sort3Expired'
|
||||
const COMPLETED_CHORE = 'Sort4Completed'
|
||||
const NON_TODAY_CHORE = 'Sort5NonToday'
|
||||
|
||||
const PENDING_REWARD = 'SortRewardPending'
|
||||
const NORMAL_REWARD = 'SortRewardNormal'
|
||||
|
||||
const POINTS = 5
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
|
||||
function todayDayIndex(): number {
|
||||
return new Date().getDay()
|
||||
}
|
||||
|
||||
function nonTodayDayIndex(): number {
|
||||
return (new Date().getDay() + 1) % 7
|
||||
}
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): 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')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(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: POINTS, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'sort test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Parent mode sort order', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let pendingChoreId = ''
|
||||
let generalChoreId = ''
|
||||
let expiredChoreId = ''
|
||||
let completedChoreId = ''
|
||||
let nonTodayChoreId = ''
|
||||
let pendingRewardId = ''
|
||||
let normalRewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
|
||||
pendingChoreId = await createTask(request, PENDING_CHORE)
|
||||
generalChoreId = await createTask(request, GENERAL_CHORE)
|
||||
expiredChoreId = await createTask(request, EXPIRED_CHORE)
|
||||
completedChoreId = await createTask(request, COMPLETED_CHORE)
|
||||
nonTodayChoreId = await createTask(request, NON_TODAY_CHORE)
|
||||
pendingRewardId = await createReward(request, PENDING_REWARD, REWARD_COST)
|
||||
normalRewardId = await createReward(request, NORMAL_REWARD, REWARD_COST)
|
||||
|
||||
const allChoreIds = [
|
||||
pendingChoreId,
|
||||
generalChoreId,
|
||||
expiredChoreId,
|
||||
completedChoreId,
|
||||
nonTodayChoreId,
|
||||
]
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: allChoreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [pendingRewardId, normalRewardId] },
|
||||
})
|
||||
|
||||
// Expired chore: scheduled today with deadline already passed (00:01)
|
||||
await request.put(`/api/child/${childId}/task/${expiredChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: todayDayIndex(), hour: 0, minute: 1 }],
|
||||
default_hour: 0,
|
||||
default_minute: 1,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Completed chore: scheduled today (interval, no deadline), then trigger to approve
|
||||
await request.put(`/api/child/${childId}/task/${completedChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
await request.post(`/api/child/${childId}/trigger-task`, {
|
||||
data: { task_id: completedChoreId },
|
||||
})
|
||||
|
||||
// Non-today chore: scheduled for a different day only
|
||||
await request.put(`/api/child/${childId}/task/${nonTodayChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Pending chore: child confirms → creates pending status
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: pendingChoreId },
|
||||
})
|
||||
|
||||
// Pending reward: request it
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
|
||||
// General chore: no schedule, no changes needed
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [
|
||||
pendingChoreId,
|
||||
generalChoreId,
|
||||
expiredChoreId,
|
||||
completedChoreId,
|
||||
nonTodayChoreId,
|
||||
]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
for (const id of [pendingRewardId, normalRewardId]) {
|
||||
if (id) await request.delete(`/api/reward/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Parent chore sort: pending → general → expired → completed → unscheduled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = choreSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
// Expected order: pending(0) → general(1) → expired(2) → completed(3) → non-today(4)
|
||||
const pendingIdx = names.indexOf(PENDING_CHORE)
|
||||
const generalIdx = names.indexOf(GENERAL_CHORE)
|
||||
const expiredIdx = names.indexOf(EXPIRED_CHORE)
|
||||
const completedIdx = names.indexOf(COMPLETED_CHORE)
|
||||
const nonTodayIdx = names.indexOf(NON_TODAY_CHORE)
|
||||
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(generalIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(expiredIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(completedIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(nonTodayIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
expect(pendingIdx).toBeLessThan(generalIdx)
|
||||
expect(generalIdx).toBeLessThan(expiredIdx)
|
||||
expect(expiredIdx).toBeLessThan(completedIdx)
|
||||
expect(completedIdx).toBeLessThan(nonTodayIdx)
|
||||
})
|
||||
|
||||
test('Parent reward sort: pending requests appear first', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = rewardSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
const pendingIdx = names.indexOf(PENDING_REWARD)
|
||||
const normalIdx = names.indexOf(NORMAL_REWARD)
|
||||
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(normalIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(pendingIdx).toBeLessThan(normalIdx)
|
||||
})
|
||||
})
|
||||
75
frontend/e2e/mode_parent/tasks/chore-cancel.spec.ts
Normal file
75
frontend/e2e/mode_parent/tasks/chore-cancel.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Chore creation/edit cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Cancel chore creation', async ({ page }) => {
|
||||
// 1. From /parent/tasks/chores, start creating a chore
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
// expect: Create Chore form appears
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// 2. Fill in 'Test' name and '5' points then cancel
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Test')
|
||||
setVal('#points', '5')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
// expect: User is returned to the chores list (exact match to avoid custom copy)
|
||||
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
|
||||
// expect: No chore named 'Test' exists
|
||||
await expect(page.locator('text=Test')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel chore edit', async ({ page }) => {
|
||||
// 1. Locate a chore and open its edit form
|
||||
await page.goto('/parent/tasks/chores')
|
||||
// ensure item exists
|
||||
if (!(await page.getByText('Wash dishes', { exact: true }).count())) {
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Wash dishes')
|
||||
setVal('#points', '10')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
await page.getByText('Wash dishes', { exact: true }).click()
|
||||
|
||||
// 2. Modify the name then cancel
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name')
|
||||
if (el) {
|
||||
el.value = 'Should not save'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
// expect: Navigation returns to list (exact match)
|
||||
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
|
||||
// expect: Original values remain unchanged
|
||||
await expect(page.getByText('Wash dishes', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
43
frontend/e2e/mode_parent/tasks/chore-convert-default.spec.ts
Normal file
43
frontend/e2e/mode_parent/tasks/chore-convert-default.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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 chore to a user item by editing', async ({ page }) => {
|
||||
// 1. Locate a default chore such as 'Clean your mess' in the chores list
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await expect(page.locator('text=Clean your mess')).toBeVisible()
|
||||
// expect: Item is visible and has no delete button
|
||||
await expect(
|
||||
page.locator('text=Clean your mess >> .. >> button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click the default chore itself to edit
|
||||
await page.click('text=Clean your mess')
|
||||
// expect: Edit form opens with the default values
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Clean your mess')
|
||||
|
||||
// 3. Change some properties (name and points) so Save becomes enabled
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Clean your mess (custom)')
|
||||
setVal('#points', '20')
|
||||
})
|
||||
// expect: Save button is enabled because form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
|
||||
// 4. Save the update
|
||||
await page.click('button:has-text("Save")')
|
||||
// expect: The chore now shows as editable and includes a delete option
|
||||
await expect(
|
||||
page.locator('text=Clean your mess >> .. >> button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: Item behaves like a custom chore
|
||||
})
|
||||
141
frontend/e2e/mode_parent/tasks/chore-create-edit.spec.ts
Normal file
141
frontend/e2e/mode_parent/tasks/chore-create-edit.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Chore management', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test('Create a new chore (parent mode)', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Wash dishes ${suffix}`
|
||||
// 1. Navigate to /parent/tasks/chores
|
||||
await page.goto('/parent/tasks/chores')
|
||||
// expect: The parent dashboard loads and shows the chores list
|
||||
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
|
||||
|
||||
// 2. Click the 'Create Chore' FAB
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
// expect: The Create Chore form is displayed with fields for name and points
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// 3. Enter the chore name and points using DOM events
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '10')
|
||||
}, name)
|
||||
// expect: Submit/Create button becomes enabled
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
|
||||
// 5. Click the Create button
|
||||
await page.click('button:has-text("Create")')
|
||||
// expect: No validation errors are shown
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
// expect: Navigation returns to /parent/chores (or dashboard)
|
||||
await expect(page).toHaveURL(/\/parent/)
|
||||
// expect: The new chore appears in the chores list
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing chore', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `Wash dishes ${suffix}`
|
||||
const updated = `Wash car ${suffix}`
|
||||
// 1. Ensure there is at least one chore in the list (create one if necessary)
|
||||
await page.goto('/parent/tasks/chores')
|
||||
if (!(await page.locator(`text=${original}`).count())) {
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '10')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
// expect: The chore appears in the list
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
|
||||
// 2. Click the chore row to edit
|
||||
await page.click(`text=${original}`)
|
||||
// expect: Edit Chore form appears (loaded with current values)
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// 3. Change the Name to 'Wash car' and Points to '15' via DOM events
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '15')
|
||||
}, updated)
|
||||
// expect: Fields are updated with the new values
|
||||
|
||||
// 4. Click Save/Update
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
// expect: No errors are displayed
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
// expect: Navigation returns to chores list
|
||||
await expect(page).toHaveURL(/\/parent/)
|
||||
// expect: The chore now reads updated name with 15 points
|
||||
await expect(page.locator(`text=${updated}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete a chore', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Wash car ${suffix}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '15')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
|
||||
// delete using row-local button then confirm via modal
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.first()
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
// modal appears with a warning message
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
// click the red danger button inside the modal (labelled Delete)
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
92
frontend/e2e/mode_parent/tasks/chore-delete-default.spec.ts
Normal file
92
frontend/e2e/mode_parent/tasks/chore-delete-default.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Edit default chore "Take out trash" and verify system chore restoration on delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
|
||||
// Cleanup: if a previous run left a modified 'Take out trash' (with delete icon), remove it first
|
||||
while (
|
||||
(await page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.count()) > 0
|
||||
) {
|
||||
await page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
}
|
||||
|
||||
// 1. Verify 'Take out trash' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Take out trash', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click 'Take out trash' to open the edit form and change points
|
||||
await page.getByText('Take out trash', { exact: true }).click()
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Take out trash')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#points', '5')
|
||||
})
|
||||
|
||||
// expect: Save button becomes enabled because the form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// 3. Verify exactly one 'Take out trash' is in the list and it now has a delete icon
|
||||
await expect(page.getByText('Take out trash', { exact: true })).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: points display reflects the updated value
|
||||
await expect(
|
||||
page.getByText('Take out trash', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('5 pts')
|
||||
|
||||
// 4. Delete the modified 'Take out trash' and verify the system default is restored
|
||||
await page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// expect: 'Take out trash' is still on the list (system default restored)
|
||||
await expect(page.getByText('Take out trash', { exact: true })).toBeVisible()
|
||||
// expect: no delete icon (it's a system default again)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
// expect: original points (20 pts) are restored
|
||||
await expect(
|
||||
page.getByText('Take out trash', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('20 pts')
|
||||
})
|
||||
63
frontend/e2e/mode_parent/tasks/kindness-cancel.spec.ts
Normal file
63
frontend/e2e/mode_parent/tasks/kindness-cancel.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Kindness act cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Cancel kindness act creation', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Test')
|
||||
setVal('#points', '5')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.locator('text=Should not save')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel kindness act edit', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
if (!(await page.getByText('Share toys', { exact: true }).count())) {
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Share toys')
|
||||
setVal('#points', '5')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
// open edit by clicking row
|
||||
await page.getByText('Share toys', { exact: true }).click()
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name')
|
||||
if (el) {
|
||||
el.value = 'Never saved'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.getByText('Share toys', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
136
frontend/e2e/mode_parent/tasks/kindness-create-edit.spec.ts
Normal file
136
frontend/e2e/mode_parent/tasks/kindness-create-edit.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Kindness act management', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
// avoid parallel execution within this file; shared backend state and SSE events
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test('Create a new kindness act (parent mode)', async ({ page }) => {
|
||||
// use a unique name so repeated runs don't collide
|
||||
const suffix = Date.now()
|
||||
const name = `Share toys ${suffix}`
|
||||
|
||||
// 1. navigate once and switch to kindness tab
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
await expect(page.locator('text=Kindness Acts')).toBeVisible()
|
||||
|
||||
// 2. open form
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await expect(page.locator('text=Name')).toBeVisible()
|
||||
|
||||
// 3‑4. fill using evaluate helper
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '5')
|
||||
}, name)
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
|
||||
// 5. submit and assert the new row is visible
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
await expect(page.locator(`text=${name}`).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing kindness act', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `Share toys ${suffix}`
|
||||
const updated = `Help with homework ${suffix}`
|
||||
|
||||
// navigate and create fresh item
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '5')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${original}`).first()).toBeVisible()
|
||||
|
||||
// 2. open edit by clicking first matching row
|
||||
await page.locator(`text=${original}`).first().click()
|
||||
// wait for edit form to appear
|
||||
await expect(page.locator('text=Name')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
|
||||
// 3. update values once form is ready
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '8')
|
||||
}, updated)
|
||||
|
||||
// 4. save and verify (wait until button is enabled)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator(`text=${updated}`).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete a kindness act', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Help with homework ${suffix}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
// create fresh item
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '8')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`).first()).toBeVisible()
|
||||
|
||||
// click the delete button on the first matching row
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.first()
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
// confirmation modal should appear
|
||||
await expect(
|
||||
page.locator('text=Are you sure you want to delete this kindness act?'),
|
||||
).toBeVisible()
|
||||
// click the red danger button inside the modal
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
134
frontend/e2e/mode_parent/tasks/kindness-default.spec.ts
Normal file
134
frontend/e2e/mode_parent/tasks/kindness-default.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// 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, 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')
|
||||
// find a default act
|
||||
await expect(page.locator('text=Be good for the day')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('text=Be good for the day >> .. >> button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// edit it — rename so there is no name collision with the delete-default test
|
||||
await page.click('text=Be good for the day')
|
||||
await page.locator('#name').fill('Be good today (edited)')
|
||||
await page.locator('#points').fill('7')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// renamed item should now be deletable
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good today (edited)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
|
||||
// clean up: delete the user item so the next test sees "Be good for the day" as a system default
|
||||
await page
|
||||
.getByText('Be good today (edited)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
})
|
||||
|
||||
test('Edit default kindness act "Be good for the day" and verify system act restoration on delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByText('Kindness Acts').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(
|
||||
page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click 'Be good for the day' to open the edit form and change points
|
||||
await page.getByText('Be good for the day', { exact: true }).click()
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Be good for the day')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#points', '3')
|
||||
})
|
||||
|
||||
// expect: Save button becomes enabled because the form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// 3. Verify exactly one 'Be good for the day' is in the list and it now has a delete icon
|
||||
await expect(page.getByText('Be good for the day', { exact: true })).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: points display reflects the updated value
|
||||
await expect(
|
||||
page.getByText('Be good for the day', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('3 pts')
|
||||
|
||||
// 4. Delete the modified 'Be good for the day' and verify the system default is restored
|
||||
await page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// expect: 'Be good for the day' is still on the list (system default restored)
|
||||
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
|
||||
// expect: no delete icon (it's a system default again)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
// expect: original points (15 pts) are restored
|
||||
await expect(
|
||||
page.getByText('Be good for the day', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('15 pts')
|
||||
})
|
||||
})
|
||||
55
frontend/e2e/mode_parent/tasks/penalty-cancel.spec.ts
Normal file
55
frontend/e2e/mode_parent/tasks/penalty-cancel.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Penalty creation/edit cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Cancel penalty creation', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Should not save')
|
||||
setVal('#points', '10')
|
||||
})
|
||||
// cancel before submitting the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.locator('text=Should not save')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel penalty edit', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
if (!(await page.getByText('No screen time', { exact: true }).count())) {
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.locator('#name').fill('No screen time')
|
||||
await page.locator('#points').fill('10')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.getByText('No screen time', { exact: true })).toBeVisible()
|
||||
}
|
||||
// open edit by clicking row
|
||||
await page.getByText('No screen time', { exact: true }).click()
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name')
|
||||
if (el) {
|
||||
el.value = 'Never saved'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.getByText('No screen time', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
114
frontend/e2e/mode_parent/tasks/penalty-create-edit.spec.ts
Normal file
114
frontend/e2e/mode_parent/tasks/penalty-create-edit.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Penalty management', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Create a new penalty (parent mode)', async ({ page }) => {
|
||||
const name = `No screen time ${Date.now()}`
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
await expect(page.locator('text=Penalties')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '30')
|
||||
}, name)
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing penalty', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `No screen time ${suffix}`
|
||||
const updated = `No dessert ${suffix}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
|
||||
// create the item to edit
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '30')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
|
||||
// open edit by clicking the row itself
|
||||
await page.click(`text=${original}`)
|
||||
// wait for the edit form to finish loading data from the API
|
||||
await expect(page.locator('#name')).toHaveValue(original)
|
||||
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '20')
|
||||
}, updated)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator(`text=${updated}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete a penalty', async ({ page }) => {
|
||||
const name = `No dessert ${Date.now()}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '20')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete item' })
|
||||
.click()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
136
frontend/e2e/mode_parent/tasks/penalty-default.spec.ts
Normal file
136
frontend/e2e/mode_parent/tasks/penalty-default.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// 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, 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')
|
||||
|
||||
// locate default penalty
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('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
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
|
||||
// clean up: delete so the next test sees 'Fighting' as a system default again
|
||||
await page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
})
|
||||
|
||||
test('Edit default penalty "Fighting" and verify system penalty restoration on delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByText('Penalties').click()
|
||||
|
||||
// 1. Verify 'Fighting' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click 'Fighting' to open the edit form and change points
|
||||
await page.getByText('Fighting', { exact: true }).click()
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Fighting')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#points', '3')
|
||||
})
|
||||
|
||||
// expect: Save button becomes enabled because the form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// 3. Verify exactly one 'Fighting' is in the list and it now has a delete icon
|
||||
await expect(page.getByText('Fighting', { exact: true })).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: points display reflects the updated value
|
||||
await expect(
|
||||
page.getByText('Fighting', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('3 pts')
|
||||
|
||||
// 4. Delete the modified 'Fighting' and verify the system default is restored
|
||||
await page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// expect: 'Fighting' is still on the list (system default restored)
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
// expect: no delete icon (it's a system default again)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
// expect: original points (10 pts) are restored
|
||||
await expect(
|
||||
page.getByText('Fighting', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('10 pts')
|
||||
})
|
||||
})
|
||||
51
frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts
Normal file
51
frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Simple chore creation test', async ({ page }) => {
|
||||
// Navigate to chores
|
||||
await page.goto('/parent/tasks/chores')
|
||||
|
||||
// Click the Create Chore FAB
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
|
||||
// Wait for form to load
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// Fill form using custom evaluation
|
||||
await page.evaluate(() => {
|
||||
// Fill name
|
||||
const nameInput = document.querySelector('input[type="text"], input:not([type])')
|
||||
if (nameInput) {
|
||||
nameInput.value = 'Simple Chore Test'
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
nameInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Fill points
|
||||
const pointsInput = document.querySelector('input[type="number"]')
|
||||
if (pointsInput) {
|
||||
pointsInput.value = '5'
|
||||
pointsInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
pointsInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Click first image to select it
|
||||
const firstImage = document.querySelector('img[alt*="Image"]')
|
||||
if (firstImage) {
|
||||
firstImage.click()
|
||||
}
|
||||
})
|
||||
|
||||
// Wait a moment for validation to trigger
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Click Create button
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
// Verify we're back on the list page and item was created
|
||||
// locate the name element, then move up to the row container
|
||||
const choreName = page.locator('text=Simple Chore Test').first()
|
||||
const choreRow = choreName.locator('..')
|
||||
await expect(choreRow).toBeVisible()
|
||||
// the row container should display the correct points value
|
||||
await expect(choreRow.locator('text=5 pts')).toBeVisible()
|
||||
})
|
||||
181
frontend/e2e/mode_parent/user-profile/change-pin.spec.ts
Normal file
181
frontend/e2e/mode_parent/user-profile/change-pin.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
const NEW_PIN = '5678'
|
||||
|
||||
async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> {
|
||||
// Request a new code, retrieve it via test endpoint, verify it, then set the pin
|
||||
await request.post(`${BACKEND}/user/request-pin-setup`)
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await request.post(`${BACKEND}/user/verify-pin-setup`, { data: { code } })
|
||||
await request.post(`${BACKEND}/user/set-pin`, { data: { pin } })
|
||||
}
|
||||
|
||||
test.describe('User Profile – Change Parent PIN', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Restore original PIN so subsequent test runs start fresh
|
||||
await setPinDirectly(request, E2E_PIN)
|
||||
})
|
||||
|
||||
test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
await expect(page.getByRole('heading', { name: 'Set up your Parent PIN' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back from PIN setup page returns to profile', async ({ page }) => {
|
||||
// Navigate from the profile page so browser history exists
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Send Verification Code advances to step 2', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeInput = page.getByPlaceholder('6-digit code')
|
||||
await expect(codeInput).toBeVisible()
|
||||
|
||||
// Verify Code is disabled while input is empty
|
||||
await expect(page.getByRole('button', { name: 'Verify Code' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Resend Code button appears after delay', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
// According to the component, resend appears after 10 seconds
|
||||
await expect(page.getByRole('button', { name: 'Resend Code' })).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('Invalid code shows error', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('6-digit code').fill('ZZZZZZ')
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
|
||||
await expect(page.getByText(/invalid code/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Valid code advances to step 3 (Create PIN)', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
// Retrieve the code via the test-only endpoint
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
await expect(page.getByPlaceholder('New PIN')).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Confirm PIN')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN disabled when PINs do not match', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill('1111')
|
||||
await page.getByPlaceholder('Confirm PIN').fill('2222')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN disabled for fewer than 4 digits', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill('12')
|
||||
await page.getByPlaceholder('Confirm PIN').fill('12')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN succeeds with matching 4-digit PIN – shows success step', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill(NEW_PIN)
|
||||
await page.getByPlaceholder('Confirm PIN').fill(NEW_PIN)
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Set PIN' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Parent PIN Set!' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back from success step exits parent mode and goes to child selector', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill(NEW_PIN)
|
||||
await page.getByPlaceholder('Confirm PIN').fill(NEW_PIN)
|
||||
await page.getByRole('button', { name: 'Set PIN' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Parent PIN Set!' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/child(\/|$)/)
|
||||
// Parent mode exited – "Parent login" button visible instead of parent menu
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
109
frontend/e2e/mode_parent/user-profile/delete-account.spec.ts
Normal file
109
frontend/e2e/mode_parent/user-profile/delete-account.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
test.describe('User Profile – Delete Account', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
await expect(page.locator('#confirmEmail')).toBeVisible()
|
||||
await expect(page.locator('#confirmEmail')).toHaveValue('')
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Cancel' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete button stays disabled for incomplete email', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
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@')
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button enables when matching email is entered', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
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_DELETE_EMAIL)
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Cancel closes the dialog without deleting the account', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Dialog is gone, still on profile page
|
||||
await expect(
|
||||
page.locator('.modal-title', { hasText: 'Delete Your Account?' }),
|
||||
).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Backdrop click does NOT close the delete dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
// Click the backdrop (outside the modal-dialog box)
|
||||
await page.locator('.modal-backdrop').click({ position: { x: 10, y: 10 }, force: true })
|
||||
|
||||
// Dialog must still be visible – the delete warning has no backdrop-click listener
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful deletion: confirmation modal appears then redirects to landing page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/profile')
|
||||
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_DELETE_EMAIL)
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// Confirmation modal appears
|
||||
await expect(page.locator('.modal-title', { hasText: 'Account Deleted' })).toBeVisible()
|
||||
await expect(page.getByText(/marked for deletion/i)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Redirect to landing page
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.getByRole('button', { name: 'Sign In' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Login after deletion is blocked with an appropriate error', async ({ page }) => {
|
||||
await page.goto('/auth/login')
|
||||
|
||||
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
|
||||
await expect(page.getByText(/marked for deletion/i)).toBeVisible({ timeout: 8000 })
|
||||
// URL stays on the login page
|
||||
await expect(page).toHaveURL(/\/auth\/login/)
|
||||
})
|
||||
})
|
||||
241
frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts
Normal file
241
frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { E2E_EMAIL, E2E_FIRST_NAME } from '../../e2e-constants'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
interface ProfileData {
|
||||
first_name: string
|
||||
last_name: string
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
async function getProfile(request: APIRequestContext): Promise<ProfileData> {
|
||||
const res = await request.get(`${BACKEND}/user/profile`)
|
||||
const data = await res.json()
|
||||
return { first_name: data.first_name, last_name: data.last_name, image_id: data.image_id ?? null }
|
||||
}
|
||||
|
||||
async function restoreProfile(request: APIRequestContext, profile: ProfileData): Promise<void> {
|
||||
await request.put(`${BACKEND}/user/profile`, {
|
||||
data: {
|
||||
first_name: profile.first_name,
|
||||
last_name: profile.last_name,
|
||||
image_id: profile.image_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('User Profile – editing', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let originalProfile: ProfileData
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
originalProfile = await getProfile(request)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreProfile(request, originalProfile)
|
||||
})
|
||||
|
||||
test('Profile page loads with correct data', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('Tester')
|
||||
await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL)
|
||||
await expect(page.getByLabel('Email Address')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Back button navigates back', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// 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()
|
||||
|
||||
await expect(page).toHaveURL('/parent')
|
||||
})
|
||||
|
||||
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('First Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save enables when a name is changed', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
await page.getByLabel('Last Name').fill('UpdatedTester')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog.locator('.modal-title', { hasText: 'Profile Updated' })).toBeVisible()
|
||||
await expect(dialog.getByText('Your profile was updated successfully.')).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// OK navigates back; go back to profile to verify persistence
|
||||
await page.goto('/parent/profile')
|
||||
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
||||
})
|
||||
|
||||
test('Cancel discards unsaved changes', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('Discarded')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Navigate back to verify no changes were saved
|
||||
await page.goto('/parent/profile')
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
})
|
||||
|
||||
test('Email field is read-only', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
const emailInput = page.locator('#email')
|
||||
await expect(emailInput).toBeDisabled()
|
||||
await expect(emailInput).toHaveValue(E2E_EMAIL)
|
||||
})
|
||||
|
||||
test('Change profile image (built-in)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
// Wait for images to load
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
// Pick the second image in the list (index 1), regardless of which is selected
|
||||
const images = page.locator('.selectable-image')
|
||||
const count = await images.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
// Find first image that is NOT currently selected
|
||||
let targetIndex = -1
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cls = await images.nth(i).getAttribute('class')
|
||||
if (!cls?.includes('selected')) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(targetIndex).toBeGreaterThanOrEqual(0)
|
||||
|
||||
await images.nth(targetIndex).click()
|
||||
|
||||
// Confirm it is now selected
|
||||
await expect(images.nth(targetIndex)).toHaveClass(/selected/)
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Re-visit and confirm selection persists
|
||||
await page.goto('/parent/profile')
|
||||
await page.waitForSelector('.selectable-image')
|
||||
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
||||
expect(selectedSrc).toBeTruthy()
|
||||
// The URL will differ after a new load (Object URL vs cached), so verify via API
|
||||
const profile = await getProfile(page.request)
|
||||
expect(profile.image_id).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Upload a custom profile image', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('button', { name: 'Add from device' }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(TEST_IMAGE)
|
||||
|
||||
// The uploaded image appears first in the list and is selected
|
||||
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
})
|
||||
|
||||
test('Change Password shows email-sent modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Change Password' }).click()
|
||||
|
||||
// Modal appears (title can transition quickly from loading to success)
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.locator('.modal-title')).toContainText(
|
||||
/Change Password|Password Change Email Sent|Password Change Failed/,
|
||||
)
|
||||
|
||||
// Eventually becomes the confirmed state
|
||||
await expect(
|
||||
dialog.locator('.modal-title', { hasText: 'Password Change Email Sent' }),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(dialog.locator('.modal-message')).toContainText(/password change link/i)
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Modal dismissed, back on the profile page
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user