Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s

This commit is contained in:
2026-04-25 00:40:15 -04:00
parent db846f4e31
commit 127378797c
263 changed files with 88 additions and 79 deletions

View File

@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "oh4sQB_Q1Se4xydZHTIolU6FhvlKPIlDPsxfd_qe4wQ",
"domain": "localhost",
"path": "/api/auth",
"expires": 1784867824.852037,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI4NGY1Y2I3NS05NWU1LTRjMTItOTExOS02MzdkYjJlMWY1MGYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcwOTI3MjR9.7-d4EvJEYtYSi_5ZFiU7tuxgc4ilVMBxhwzgAQZNTvY",
"domain": "localhost",
"path": "/",
"expires": 1777092724.851439,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777091824594}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777264625262}"
}
]
}
]
}

View File

@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "lIhurtvmo0oGcimN_8v59h9y3CrglEAloQO1QFw8RsQ",
"domain": "localhost",
"path": "/api/auth",
"expires": 1784867824.995155,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiOWYzYTljNWMtM2IwMy00YTZlLTliNmUtYjE0MTQzZWNhMzNkIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3MDkyNzI0fQ.UB5MCIZ2PHbn3UyohNzmFbOCb3TR-KHxJPWB9qkWwCQ",
"domain": "localhost",
"path": "/",
"expires": 1777092724.99406,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777091824687}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777264625511}"
}
]
}
]
}

View File

@@ -0,0 +1,35 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "azzLrjadyNjF1jFgenfC4sY2WNd3I6Sk53lLjVgzq6Y",
"domain": "localhost",
"path": "/api/auth",
"expires": 1781031244.039823,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJhZjkzYTYwYy02MTBjLTQ2ZjUtOTM3NS02ZTZhMTVhNzk5NDIiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzMyNTYxNDR9.Y1weoDtuQDMYO0PrKHGq97s0bV6ldbYO9JwXZCS4jak",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1773255243826}"
}
]
}
]
}

View File

@@ -0,0 +1,35 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "rwrt2Ui8ecL_ZSoiWpPY5Ahvp-L9Egbegz_FaYn9KA8",
"domain": "localhost",
"path": "/api/auth",
"expires": 1781620038.006557,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZTc3OTM1Yi1jYThlLTRiOGYtYmFlMi00NGQyNDA5MjVjODAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4NDQ5Mzd9.NHp7pbMRgbO1jamIJBVwlv75OzbTFoOR801C9gTBNEY",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1773844037684}"
}
]
}
]
}

View File

@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "uMExObHukEdFnlszorcDq2XlbRW0AeIV71x_vSovEF4",
"domain": "localhost",
"path": "/api/auth",
"expires": 1784867821.107956,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI2ZWE5YWZmZC1jMGExLTRkMzUtOGJmYS1mZDBkOWY5NzFmOWQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcwOTI3MjF9.rb64zKqS6uXqkgX_4kke37Rc6om68elt4Qa24Fy9NPo",
"domain": "localhost",
"path": "/",
"expires": 1777092721.107403,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777091820880}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777264621275}"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

View File

@@ -0,0 +1,42 @@
import { test as setup } from '@playwright/test'
import { STORAGE_STATE_CC, E2E_CC_EMAIL, E2E_CC_PASSWORD, E2E_CC_PIN } from './e2e-constants'
const BACKEND = 'http://localhost:5000'
setup('authenticate create-child isolated user', async ({ page }) => {
// Create the isolated create-child test user (separate from the main E2E user).
// This user starts with no children so deleteAllChildren() in the empty-state test
// never interferes with concurrent tests running under the main E2E user.
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-cc-user`)
if (!createRes.ok()) {
throw new Error(`e2e-create-cc-user failed: ${createRes.status()} ${await createRes.text()}`)
}
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_CC_EMAIL)
await page.getByLabel('Password').fill(E2E_CC_PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/(parent|child)/)
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_CC_PIN)
await page.getByLabel('Stay in parent mode on this device').check()
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
try {
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
} catch (e) {
await page.screenshot({ path: 'auth-setup-cc-parent-fail.png' })
throw new Error(
'CC user parent mode not reached after PIN entry. See auth-setup-cc-parent-fail.png for details.',
)
}
await page.context().storageState({ path: STORAGE_STATE_CC })
})

View File

@@ -0,0 +1,47 @@
import { test as setup } from '@playwright/test'
import {
STORAGE_STATE_DELETE,
E2E_DELETE_EMAIL,
E2E_DELETE_PASSWORD,
E2E_DELETE_PIN,
} from './e2e-constants'
const BACKEND = 'http://localhost:5000'
setup('authenticate delete user', async ({ page }) => {
// Create the isolated delete-test user (separate from the main E2E user)
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-delete-user`)
if (!createRes.ok()) {
throw new Error(
`e2e-create-delete-user failed: ${createRes.status()} ${await createRes.text()}`,
)
}
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()
await page.waitForURL(/\/(parent|child)/)
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_DELETE_PIN)
await page.getByLabel('Stay in parent mode on this device').check()
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
try {
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
} catch (e) {
await page.screenshot({ path: 'auth-setup-delete-parent-fail.png' })
throw new Error(
'Delete user parent mode not reached after PIN entry. See auth-setup-delete-parent-fail.png for details.',
)
}
await page.context().storageState({ path: STORAGE_STATE_DELETE })
})

View File

@@ -0,0 +1,35 @@
import { test as setup } from '@playwright/test'
import { STORAGE_STATE_NO_PIN, E2E_EMAIL, E2E_PASSWORD } from './e2e-constants'
// the default 30s timeout was occasionally exceeded when running the entire
// task suite (login redirect could be delayed by other tests). bump it so the
// fixture itself never times out during a large run.
setup.setTimeout(60_000)
// Skipping this setup for now — it was timing out repeatedly during large runs.
// The parent PIN removal scenario is exercised indirectly in other tests.
setup.skip('authenticate without parent pin', async ({ page }) => {
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_EMAIL)
await page.getByLabel('Password').fill(E2E_PASSWORD)
// perform login click and wait for the auth API call rather than relying on a
// navigation, which was flaking under the full-suite run and causing
// timeouts.
const [response] = await Promise.all([
page.waitForResponse((res) => res.url().endsWith('/auth/login') && res.status() === 200),
page.getByRole('button', { name: 'Sign in' }).click(),
])
// ensure we actually got a successful login back
if (!response.ok()) {
throw new Error('Login failed in setup-no-pin')
}
// small sanity wait for some element that only appears after auth
await page.waitForSelector('button[aria-label="Parent menu"]', { timeout: 30000 })
// Remove parent auth from localStorage so the PIN prompt appears
await page.evaluate(() => localStorage.removeItem('parentAuth'))
await page.context().storageState({ path: STORAGE_STATE_NO_PIN })
})

View File

@@ -0,0 +1,44 @@
import { test as setup } from '@playwright/test'
import { STORAGE_STATE, E2E_EMAIL, E2E_PASSWORD, E2E_PIN } from './e2e-constants'
setup('authenticate', async ({ page }) => {
// Seed backend test data
const backendUrl = 'http://localhost:5000'
const seedRes = await page.request.post(`${backendUrl}/auth/e2e-seed`)
if (!seedRes.ok()) {
throw new Error(`e2e-seed failed: ${seedRes.status()} ${await seedRes.text()}`)
}
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_EMAIL)
await page.getByLabel('Password').fill(E2E_PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
// After login the router redirects to /child (not parent-authenticated yet)
await page.waitForURL(/\/(parent|child)/)
// Click the LoginButton in the header to open the PIN modal
await page.getByRole('button', { name: 'Parent login' }).click()
// Fill in the PIN and submit
const pinInput = page.getByPlaceholder('46 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()
// LoginButton does router.push('/parent') after PIN - wait for it
await page.waitForURL(/\/parent(\/|$)/)
// Confirm parent mode is active by waiting for the Add Child FAB at /parent
try {
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
} catch (e) {
await page.screenshot({ path: 'auth-setup-parent-fail.png' })
throw new Error(
'Parent mode not reached after PIN entry. See auth-setup-parent-fail.png for details.',
)
}
await page.context().storageState({ path: STORAGE_STATE })
})

View File

@@ -0,0 +1,15 @@
export const STORAGE_STATE = 'e2e/.auth/user.json'
export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json'
export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json'
export const E2E_EMAIL = 'e2e@test.com'
export const E2E_PASSWORD = 'E2eTestPass1!'
export const E2E_PIN = '1234'
export const E2E_FIRST_NAME = 'E2E'
export const E2E_DELETE_EMAIL = 'e2e-delete@test.com'
export const E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
export const E2E_DELETE_PIN = '5678'
export const E2E_CC_EMAIL = 'e2e-cc@test.com'
export const E2E_CC_PASSWORD = 'E2eCCPass1!'
export const E2E_CC_PIN = '3456'

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});

View 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()
})
})

View 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()
})
})

View 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()
})
})

View File

@@ -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()
})
})

View 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)
})
})

View 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 })
}

View 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()
})
})

View File

@@ -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 })
})
})

View 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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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 17', 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()
})
})

View 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)
})
})

View 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()
})
})

View File

@@ -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 })
})
})

View 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()
})
})

View 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()
})
})

View 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()
})
})

View 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()
})
})

View 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')
})
})

View File

@@ -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)
})
})

View File

@@ -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 })
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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 })
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -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('46 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()
})
})

View File

@@ -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('46 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('46 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()
})
})

View 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()
})
})

View File

@@ -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()
})
})

View 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)
})
})

View 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')
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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))
})
})

View File

@@ -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')
})
})

View File

@@ -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 })
})
})

View File

@@ -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()
})
})

View 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)
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View 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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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 })
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View 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)
})
})

View 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)
})
})

View 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()
})
})

View 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
})

View 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)
})
})

View 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')
})

View 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()
})
})

View 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()
// 34. 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)
})
})

View 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')
})
})

View 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()
})
})

View 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)
})
})

View 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')
})
})

View 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()
})

View 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()
})
})

View 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/)
})
})

View 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()
})
})

View File

@@ -0,0 +1,281 @@
# E2E Test Plan — Bug Fixes 1.0.6 Set 1
Covers the bug fixes from `.github/specs/bugs-1.0.6-01/bugs-1.0.6-set01.md`. Each section proposes new E2E specs or additions to existing specs.
---
## 1. Assign View Shows Child Name
**File:** `e2e/mode_parent/task-assignment/assign-title-shows-child-name.spec.ts`
**Project:** `chromium-task-assignment`
**Seed:** Create an isolated child with a known name via API. Create at least one chore, reward, kindness, and penalty task.
#### 1.1. "Assign Chores" heading contains child name
- Navigate to `/parent/{childId}/assign-chores?name={childName}`
- Verify the heading text includes the child's name
#### 1.2. "Assign Rewards" heading contains child name
- Navigate to `/parent/{childId}/assign-rewards?name={childName}`
- Verify heading
#### 1.3. "Assign Kindnesses" heading contains child name
- Navigate to `/parent/{childId}/assign-kindness?name={childName}`
- Verify heading
#### 1.4. "Assign Penalties" heading contains child name
- Navigate to `/parent/{childId}/assign-penalties?name={childName}`
- Verify heading
---
## 2. General Chore Approval — No COMPLETED Stamp
**File:** `e2e/mode_parent/task-activation/general-chore-no-completed.spec.ts`
**Project:** `chromium-task-activation`
**Seed:** Create an isolated child, one general (unscheduled) chore, and one scheduled chore. Assign both to the child.
#### 2.1. Approving a general chore awards points
- Click general chore → confirm in dialog → approve
- Verify child's points increase by chore value
#### 2.2. General chore does NOT show COMPLETED stamp after approval
- After approval, verify the chore card does not contain "COMPLETED" text
#### 2.3. General chore can be triggered again immediately after approval
- Click the same general chore again
- Verify the confirmation dialog re-appears (chore is re-triggerable)
#### 2.4. Scheduled chore DOES show COMPLETED after approval (contrast)
- Trigger and approve the scheduled chore
- Verify "COMPLETED" stamp is visible on that card
---
## 3. Extend Time Removes TOO LATE Banner
**Existing file:** `e2e/mode_parent/chore-scheduler/extend-time.spec.ts`
The existing spec already has tests for "Extend Time removes TOO LATE badge" and "gray styling removed." The bug fix ensures `refresh()` is called immediately, so the existing tests should pass without page reload. If not already asserted:
#### 3.1. TOO LATE disappears without page reload (addition to existing spec)
- After clicking "Extend Time," assert TOO LATE text is hidden within 3 seconds
- Do NOT call `page.reload()` between extension and assertion
---
## 4. Notification Card Overflow
**File:** `e2e/mode_parent/notifications/notification-card-overflow.spec.ts`
**Project:** `chromium-task-activation` or new `chromium-notifications`
**Seed:** Create child + a chore with a very long name (50+ characters). Assign and trigger it so a notification is created.
#### 4.1. Notification icon stays within card boundaries
- Navigate to notifications view
- Get the bounding box of the notification card and its icon
- Assert the icon's bounding box is fully contained within the card's bounding box
#### 4.2. Long notification text does not visually overflow
- Assert the card element has appropriate overflow CSS (`overflow: hidden` or similar)
- Optionally use `toHaveScreenshot()` for visual regression
**Note:** This test benefits from visual regression testing (`expect(page).toHaveScreenshot()`) more than functional assertions, since overflow is inherently visual.
---
## 5. Override SSE Updates Child View
**File:** `e2e/mode_parent/task-modification/override-sse-child-update.spec.ts`
**Project:** `chromium-task-modification`
**Seed:** Create child + one chore, one kindness, one penalty. Assign all to the child.
#### 5.1. Changing chore override points updates child view in real time
- Open child view in the browser (`goToChildView`)
- Via API, set a point override on the chore: `PUT /api/child/{id}/task/{taskId}/override`
- Without page reload, verify the chore card displays the new point value
#### 5.2. Changing kindness override points updates child view
- Same pattern for kindness task — set override via API, assert child view updates
#### 5.3. Changing penalty override points updates child view
- Same pattern for penalty task
#### 5.4. Deleting an override reverts child view to original points
- Remove the override via API
- Verify child view reverts to original point value without reload
---
## 6. Scroll to Edited Task After Save
**File:** `e2e/mode_parent/task-modification/scroll-to-edited-task.spec.ts`
**Project:** `chromium-task-modification`
**Seed:** Create child + 1015 chores and 5+ rewards (enough to require both vertical and horizontal scrolling). Assign all to the child.
#### 6.1. Edited chore card scrolls into viewport after override save
- Navigate to parent child view
- Open override modal on a chore that requires scrolling to reach
- Save a new point value
- Assert the edited card `toBeInViewport()` after the list refreshes
#### 6.2. Edited reward card scrolls into viewport after override save
- Open override modal on a distant reward
- Save, assert card `toBeInViewport()`
---
## 7. Task Sorting — Parent Mode
**File:** `e2e/mode_parent/task-sorting/parent-sort-order.spec.ts`
**Project:** New `chromium-task-sorting` or `chromium-chore-scheduler`
**Seed:** Create child + 5 chores in different states:
1. A pending chore (child triggered, not yet approved)
2. A general (unscheduled) chore
3. An expired scheduled chore (today, deadline in the past → TOO LATE)
4. A completed scheduled chore (triggered + approved)
5. A chore scheduled on a non-today day (unscheduled for today)
Also create 2+ rewards: one with a pending request, one without.
#### 7.1. Parent chore sort: pending → general → expired → completed → unscheduled
- Navigate to parent child view
- Read all chore card names in DOM order
- Assert the order matches: pending chore first, then general, then expired, then completed, then unscheduled-today
#### 7.2. Parent reward sort: pending requests appear first
- Read all reward card names in DOM order
- Assert the pending-request reward appears before other rewards
---
## 8. Task Sorting — Child Mode
**File:** `e2e/mode_parent/task-sorting/child-sort-order.spec.ts`
**Project:** Same as §7
**Seed:** Same child + tasks as §7, plus rewards with varying costs.
#### 8.1. Child chore sort: scheduled today (earliest deadline first) → general → pending
- Switch to child view (`goToChildView`)
- Read all visible chore card names in DOM order
- Assert: scheduled-today chores first (sorted by deadline), then general, then pending
- Non-today chores should be hidden in child mode
#### 8.2. Child reward sort: pending first, then ascending by points needed
- Read all reward card names in DOM order
- Assert: pending reward first, remaining sorted by ascending cost/points-needed
---
## 9. Notification Click Navigates and Scrolls to Task
**File:** `e2e/mode_parent/notifications/notification-click-scroll.spec.ts`
**Project:** `chromium-task-activation` or new `chromium-notifications`
**Seed:** Create child + many chores (10+). Trigger one chore to create a pending notification. The triggered chore should be far enough in the list to require scrolling.
#### 9.1. Clicking a chore notification navigates to parent child view
- Navigate to notifications page
- Click the pending chore notification
- Verify URL matches `/parent/{childId}` with `scrollTo` and `entityType` query params
#### 9.2. Target chore card is scrolled into viewport after navigation
- After navigation from notification click, assert the target chore card `toBeInViewport()`
#### 9.3. Works for reward notifications too
- Request a reward → navigate to notifications → click → verify reward card is in viewport
---
## 10. Pending Status Resets on Task/Reward Modification
**File:** `e2e/mode_parent/task-modification/pending-reset-on-edit.spec.ts`
**Project:** `chromium-task-modification`
**Seed:** Create child + one chore + one reward. Assign both. Create a chore schedule.
#### 10.1. Editing chore points clears pending status
- Trigger the chore (creates pending confirmation)
- Verify chore card shows pending state
- Edit the parent task's points via API: `POST /api/task/{taskId}/edit` with new points
- Refresh or wait for SSE — verify the chore card no longer shows pending state
#### 10.2. Changing chore schedule clears pending status
- Trigger the chore again
- Set a new schedule via API: `PUT /api/child/{childId}/task/{taskId}/schedule`
- Verify pending is cleared
#### 10.3. Editing reward cost clears pending reward request
- Request the reward (creates pending request)
- Edit the reward's cost via API
- Verify the reward card no longer shows pending state
#### 10.4. Completed chores remain completed after point edit
- Complete and approve a scheduled chore so it shows COMPLETED
- Edit the parent task's points
- Verify the chore still shows COMPLETED stamp
#### 10.5. Editing task name only (no point change) does NOT clear pending
- Trigger the chore
- Edit the task name only (points unchanged) via API
- Verify the chore still shows pending state
---
## Summary
| # | Area | Spec File | Tests | Priority |
| --- | -------------------------- | ------------------------------------------------------- | ----- | -------- |
| 1 | Assign view child name | `task-assignment/assign-title-shows-child-name.spec.ts` | 4 | Medium |
| 2 | General chore no COMPLETED | `task-activation/general-chore-no-completed.spec.ts` | 4 | **High** |
| 3 | Extend time TOO LATE | Existing `chore-scheduler/extend-time.spec.ts` | 01 | Low |
| 4 | Notification overflow | `notifications/notification-card-overflow.spec.ts` | 2 | Low |
| 5 | Override SSE child update | `task-modification/override-sse-child-update.spec.ts` | 4 | **High** |
| 6 | Scroll to edited task | `task-modification/scroll-to-edited-task.spec.ts` | 2 | Medium |
| 7 | Parent sort order | `task-sorting/parent-sort-order.spec.ts` | 2 | **High** |
| 8 | Child sort order | `task-sorting/child-sort-order.spec.ts` | 2 | **High** |
| 9 | Notification click scroll | `notifications/notification-click-scroll.spec.ts` | 3 | Medium |
| 10 | Pending reset on edit | `task-modification/pending-reset-on-edit.spec.ts` | 5 | **High** |
**Recommended implementation order:** #2, #10, #7, #8, #5, #9, #6, #1, #3, #4
**Notes:**
- Bug #1 (uppercase verification code) is omitted from this E2E plan because reaching the PIN setup step requires email delivery; this is better covered by a component-level test on `ParentPinSetup.vue`.
- Bug #4 (extend time) is largely already covered by the existing `extend-time.spec.ts` — only a minor addition may be needed.
- Bug #5 (notification overflow) is best validated through visual regression (`toHaveScreenshot()`) rather than functional assertions.

View File

@@ -0,0 +1,96 @@
# Child Kebab Menu Options
## Application Overview
Tests for the per-child kebab (⋮) menu on the Parent dashboard (`/parent`). The menu is only visible when the parent is authenticated (PIN entered). Each option card exposes an "Options" button (`aria-label="Options"`) that opens a dropdown with three actions: **Edit Child**, **Delete Points**, and **Delete Child**.
All tests run under the `chromium-child-options` project which completes before `chromium-create-child` starts, preventing `deleteAllChildren()` in the create-child suite from interfering with children created by these tests.
Each test creates its own named child via the API in `beforeEach` and cleans up in `afterEach`, so the tests are fully isolated and can run in parallel with each other.
## Test Scenarios
### 1. Edit Child ✅ IMPLEMENTED
**File:** `e2e/mode_parent/child-options/edit-child.spec.ts`
**Seed child:** `KebabEdit` (created via `PUT /api/child/add`, deleted in `afterEach`)
#### 1.1. Edit Child menu item navigates to the child editor ✅
**Steps:**
1. Create child `KebabEdit` via API in `beforeEach`
2. Navigate to `/parent`
- expect: A card with heading `KebabEdit` is visible
3. Click the "Options" button on the `KebabEdit` card
- expect: The kebab dropdown opens (`aria-expanded="true"` on the Options button)
4. Click "Edit Child" in the dropdown
- expect: URL matches `/parent/<id>/edit`
- expect: Page heading "Edit Child" is visible
---
### 2. Delete Points ✅ IMPLEMENTED
**File:** `e2e/mode_parent/child-options/delete-points.spec.ts`
**Seed child:** `KebabPoints` (created via `PUT /api/child/add`, seeded to 50 points via `PUT /api/child/:id/edit`, deleted in `afterEach`)
#### 2.1. Delete Points resets child points to 0 ✅
**Steps:**
1. Create child `KebabPoints` with 50 points via API in `beforeEach`
2. Navigate to `/parent`
3. Click the "Options" button on the `KebabPoints` card
- expect: The kebab dropdown opens (`aria-expanded="true"` on the Options button)
4. Click "Delete Points" in the dropdown
- expect: The card shows `Points: 0`
---
### 3. Delete Child ✅ IMPLEMENTED
**File:** `e2e/mode_parent/child-options/delete-child.spec.ts`
**Seed child:** `KebabDelete` (created per-test, cleaned up in `afterEach`)
**Note:** The two tests in this file share the same child name and use `test.describe.configure({ mode: 'serial' })` to guarantee they never run in parallel with each other.
#### 3.1. Confirm deletes the child from the list ✅
**Steps:**
1. Create child `KebabDelete` via API
2. Navigate to `/parent`
- expect: Card with heading `KebabDelete` is visible
3. Click "Options" → "Delete Child"
- expect: Confirmation dialog "Are you sure you want to delete this child?" appears
4. Click the "Delete" button in the dialog
- expect: The `KebabDelete` card is no longer visible in the list
#### 3.2. Cancel does not delete the child ✅
**Steps:**
1. Create child `KebabDelete` via API
2. Navigate to `/parent`
- expect: Card with heading `KebabDelete` is visible
3. Click "Options" → "Delete Child"
- expect: Confirmation dialog "Are you sure you want to delete this child?" appears
4. Click the "Cancel" button in the dialog
- expect: The confirmation dialog is dismissed
- expect: The `KebabDelete` card is still visible in the list

View File

@@ -0,0 +1,558 @@
# Chore Scheduler E2E Tests
## Application Overview
The chore scheduler allows parents to configure when chores appear for their children and set deadline times. Schedules operate in two modes: **Specific Days** (select weekdays + per-day deadlines with optional exceptions) and **Every X Days** (interval-based with anchor date). Schedules can be **paused** (enabled/disabled toggle) — a paused schedule makes the chore appear every day with no deadline, as if unscheduled. All schedule logic runs client-side; the backend only stores and returns raw schedule data.
**Key behavioral difference between parent and child mode:**
- **Parent mode** (`/parent/:id`): All assigned chores are always visible regardless of schedule. Off-day and expired chores are grayed out (`.chore-inactive`). A kebab menu provides Schedule, Edit Points, Extend Time (when expired), and Reset (when completed) options.
- **Child mode** (`/child/:id`): Chores not scheduled for today are **hidden entirely** (filtered out client-side). Expired chores show a `TOO LATE` badge and are grayed out. No kebab menu or scheduling controls exist in child mode.
**Specs covered:**
- `feat-calendar-chore.md` — base schedule feature
- `feat-calendar-schedule-refactor-01.md` — Specific Days UI refactor (day chips, default deadline, exceptions, TimePickerPopover)
- `feat-calendar-schedule-refactor-02.md` — Every X Days UI refactor (stepper, DateInputField, anchor date, next occurrence preview, Anytime toggle)
- `feat-schedule-enable-disable.md` — enable/disable (pause) toggle
---
## Implementation
Tests live in `e2e/mode_parent/chore-scheduler/`. Each spec creates its own isolated child and chore tasks via the API so files can run in parallel. All created resources are deleted in `afterAll`.
## Playwright Projects
| Project | testMatch | Notes |
| -------------------------- | -------------------------------------------- | ----------------------- |
| `chromium-chore-scheduler` | `/mode_parent/chore-scheduler/.+\.spec\.ts/` | Depends on `setup` only |
**Config:** Add project to `playwright.config.ts` with `dependencies: ['setup']`. Exclude from the catch-all project via `testIgnore`.
---
## Seed Strategy
Each spec file creates an isolated child and one or more chore tasks via `beforeAll` API calls. Schedule configuration is done via the `PUT /api/child/:childId/task/:taskId/schedule` API. Cleanup (`delete child + tasks`) runs in `afterAll`.
For tests that need to verify child-mode behavior, the test navigates to `/child/:id` (no parent auth required for child view). For parent-mode behavior, navigate to `/parent/:id`.
**Time-sensitive tests:** Tests that depend on "Due by" labels or "TOO LATE" badges must set a schedule deadline far in the future (e.g., 23:59) to avoid the chore expiring mid-test, or set a deadline in the past (e.g., 00:01) to guarantee expiry. Use `page.clock` or carefully chosen deadline times to control time-dependent state.
---
## Test Scenarios
### 1. Schedule Modal — Opening & Structure
**File:** `e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts`
**Seed:** Creates child `ScheduleModalChild` and chore `ScheduleModalChore` via API. Assigns chore to child. Deletes all in `afterAll`.
#### 1.1. Schedule option appears in kebab menu
- Navigate to `/parent/:childId`
- Click the chore card, then click the kebab "Options" button
- expect: "Schedule" button is visible in the menu
#### 1.2. Schedule modal opens with correct title and subtitle
- Open kebab menu → click "Schedule"
- expect: Modal heading shows "Schedule Chore"
- expect: Modal subtitle shows the chore's name
#### 1.3. Default state for new schedule — days mode selected, no days checked
- Open the Schedule modal for a chore with no existing schedule
- expect: "Specific Days" mode button is active
- expect: No day chips are in the active state
- expect: Enable/disable toggle shows "Enabled" label and is ON
#### 1.4. Mode toggle switches between Specific Days and Every X Days
- Open the Schedule modal
- Click "Every X Days" button
- expect: Interval form is visible (stepper, "Starting on" date field, deadline row)
- Click "Specific Days" button
- expect: Day chips row is visible
#### 1.5. Cancel closes the modal without saving
- Open the Schedule modal, select Monday, click Cancel
- Reopen the modal
- expect: No days are selected (change was not persisted)
#### 1.6. Save is disabled when form is not dirty
- Open the Schedule modal (no existing schedule)
- expect: Save button is disabled
---
### 2. Schedule Modal — Specific Days Mode
**File:** `e2e/mode_parent/chore-scheduler/schedule-days-mode.spec.ts`
**Seed:** Creates child `ScheduleDaysChild` and chore `ScheduleDaysChore` via API. Assigns chore. Deletes all in `afterAll`.
#### 2.1. Clicking day chips toggles them active/inactive
- Open Schedule modal
- Click "Mo" chip
- expect: "Mo" chip has active styling
- Click "Mo" chip again
- expect: "Mo" chip is no longer active
#### 2.2. Default deadline row appears when at least one day is selected
- Open Schedule modal, no days selected
- expect: No deadline row visible
- Click "Tu" chip
- expect: Deadline row with "Deadline" label and a time picker appears
#### 2.3. Default deadline "Anytime" toggle hides the time picker
- Select a day so the deadline row appears
- Click "Clear (Anytime)" link
- expect: Time picker disappears, "Anytime" label is shown
- Click "Set deadline" link
- expect: Time picker reappears
#### 2.4. Exception time — "Set different time" creates per-day override
- Select Monday and Wednesday
- In the day list, click "Set different time" on Wednesday
- expect: A separate time picker appears for Wednesday
- expect: Monday still shows the default time label
#### 2.5. Exception time — "Reset to default" removes the override
- With Wednesday exception set, click "Reset to default" on Wednesday
- expect: Wednesday row shows the default time label again
#### 2.6. Save with specific days — schedule persists on reopen
- Select Monday and Friday, set default deadline to 03:00 PM, Save
- Reopen the Schedule modal
- expect: Monday and Friday chips are active
- expect: Default deadline shows 03:00 PM
#### 2.7. Save with zero days selected deletes the schedule
- Open a schedule that has days selected, uncheck all days, Save
- Reopen the modal
- expect: No days selected (schedule was removed), form appears as new
#### 2.8. Dirty detection — changing days enables Save
- Open modal with an existing schedule
- Toggle a different day
- expect: Save becomes enabled
---
### 3. Schedule Modal — Every X Days Mode
**File:** `e2e/mode_parent/chore-scheduler/schedule-interval-mode.spec.ts`
**Seed:** Creates child `ScheduleIntervalChild` and chore `ScheduleIntervalChore` via API. Assigns chore. Deletes all in `afterAll`.
#### 3.1. Interval stepper increments and decrements within 17
- Switch to "Every X Days" mode
- expect: Stepper shows "1" by default
- expect: Decrement () button is disabled at 1
- Click increment (+) 6 times
- expect: Value shows "7"
- expect: Increment (+) button is disabled at 7
#### 3.2. Anchor date field shows today's date by default
- Switch to "Every X Days" mode
- expect: "Starting on" date field contains today's ISO date
#### 3.3. Next occurrence preview label updates correctly
- Set interval to 2, set anchor date to today
- expect: "Next occurrence" label shows a date 2 days from today
#### 3.4. Interval deadline "Anytime" toggle works
- Switch to interval mode; deadline time picker is visible by default
- Click "Clear (Anytime)"
- expect: Time picker disappears, "Anytime" label shown
- Click "Set deadline"
- expect: Time picker reappears
#### 3.5. Save interval schedule — persists on reopen
- Set interval to 3, pick a specific anchor date, set deadline to 04:30 PM, Save
- Reopen Schedule modal
- expect: "Every X Days" mode is active
- expect: Interval shows 3, anchor date matches, deadline shows 04:30 PM
#### 3.6. Dirty detection — changing interval enables Save
- Open modal with an existing interval schedule
- Change interval from 3 to 5
- expect: Save becomes enabled
---
### 4. Schedule Enable/Disable Toggle
**File:** `e2e/mode_parent/chore-scheduler/schedule-enable-disable.spec.ts`
**Seed:** Creates child `ScheduleToggleChild` and chore `ScheduleToggleChore` via API. Assigns chore. Creates a "Specific Days" schedule for a non-today weekday via API. Deletes all in `afterAll`.
#### 4.1. Toggle row is visible in the schedule modal
- Open Schedule modal
- expect: Toggle row with "Enabled" label and a switch is visible above the mode buttons
#### 4.2. Toggling OFF shows "Paused" label and dims the form body
- Click the toggle switch to OFF
- expect: Label changes to "Paused"
- expect: Mode toggle and form fields have reduced opacity (dimmed)
- expect: Cancel and Save buttons remain fully interactive
#### 4.3. Toggling ON shows "Enabled" label and restores the form body
- Toggle OFF, then toggle back ON
- expect: Label returns to "Enabled"
- expect: Form body opacity is restored
#### 4.4. Save paused state — persists on reopen
- Toggle OFF, Save
- Reopen Schedule modal
- expect: Toggle is OFF, label shows "Paused", form body is dimmed
#### 4.5. Dirty detection — toggling enabled state enables Save
- Open an existing schedule (enabled), toggle OFF
- expect: Save becomes enabled
- Toggle back ON
- expect: Save is disabled again (reverted to original)
#### 4.6. Paused schedule in parent mode — chore is visible (not grayed out)
- Set up a schedule for a non-today weekday, then pause it via API
- Navigate to `/parent/:childId`
- expect: Chore card is visible and NOT grayed out (paused = show always like unscheduled)
#### 4.7. Paused schedule in child mode — chore is visible (not hidden)
- Same paused schedule as 4.6
- Navigate to `/child/:childId`
- expect: Chore card IS visible (paused = scheduled for every day)
#### 4.8. Paused schedule shows no "Due by" label
- Pause a schedule that has a deadline set
- Navigate to `/parent/:childId`
- expect: No "Due by" sub-text on the chore card (paused = no deadline)
---
### 5. Parent Mode — Chore Card Schedule States
**File:** `e2e/mode_parent/chore-scheduler/parent-card-states.spec.ts`
**Seed:** Creates child `ParentCardChild` with multiple chores via API. Assigns chores. Creates various schedules via API. Deletes all in `afterAll`.
**Chores:**
- `AlwaysActiveChore` — no schedule (always visible, normal appearance)
- `ScheduledTodayChore` — schedule includes today's weekday, deadline at 23:59
- `NotScheduledTodayChore` — schedule for a weekday that is NOT today
- `ExpiredChore` — schedule for today's weekday, deadline at 00:01 (guaranteed past)
- `AnytimeChore` — schedule for today, deadline set to "Anytime" (no expiry)
#### 5.1. Chore with no schedule — normal appearance, no annotations
- Navigate to `/parent/:childId`
- expect: `AlwaysActiveChore` card is visible with normal styling (no grayout, no badge)
#### 5.2. Chore scheduled for today — shows "Due by" label
- expect: `ScheduledTodayChore` card is visible
- expect: Card shows "Due by 11:59 PM" sub-text
- expect: Card is NOT grayed out
#### 5.3. Chore not scheduled for today — grayed out in parent mode
- expect: `NotScheduledTodayChore` card IS visible (parent mode shows all chores)
- expect: Card has grayed-out styling (`.chore-inactive` class — reduced opacity)
#### 5.4. Chore with expired deadline — grayed out with TOO LATE badge
- expect: `ExpiredChore` card is visible
- expect: Card has grayed-out styling
- expect: "TOO LATE" badge is visible on the card
#### 5.5. Chore with "Anytime" deadline — no "Due by" label, no TOO LATE
- expect: `AnytimeChore` card is visible with normal styling
- expect: No "Due by" sub-text
- expect: No "TOO LATE" badge
#### 5.6. Kebab menu always accessible on grayed-out chores
- Click the `NotScheduledTodayChore` card (grayed out)
- expect: Kebab "Options" button is visible
- Click "Options" → "Schedule" is available
- expect: Schedule modal opens normally
#### 5.7. Chore can still be triggered by parent regardless of schedule state
- Click the `ExpiredChore` (which has TOO LATE badge)
- Click again to activate
- expect: Confirmation dialog appears (parent can still trigger expired chores)
---
### 6. Child Mode — Chore Visibility and Schedule States
**File:** `e2e/mode_parent/chore-scheduler/child-card-states.spec.ts`
**Seed:** Creates child `ChildCardChild` with multiple chores. Assigns chores. Creates schedules via API. Deletes all in `afterAll`. Uses same chore set as test 5 where applicable.
**Chores:**
- `ChildAlwaysChore` — no schedule
- `ChildTodayChore` — scheduled for today, deadline at 23:59
- `ChildNotTodayChore` — scheduled for a non-today weekday
- `ChildExpiredChore` — scheduled for today, deadline at 00:01 (past)
- `ChildAnytimeChore` — scheduled for today, deadline "Anytime"
#### 6.1. Chore with no schedule — visible normally in child mode
- Navigate to `/child/:childId`
- expect: `ChildAlwaysChore` card is visible with normal styling
#### 6.2. Chore scheduled for today — visible with "Due by" label
- expect: `ChildTodayChore` card is visible
- expect: "Due by 11:59 PM" sub-text shown on card
- expect: Card is NOT grayed out
#### 6.3. Chore not scheduled for today — HIDDEN in child mode
- expect: `ChildNotTodayChore` card is NOT visible (fully hidden, not just grayed out)
#### 6.4. Chore with expired deadline — grayed out with TOO LATE badge
- expect: `ChildExpiredChore` card IS visible (still shown, but expired)
- expect: Card has grayed-out styling
- expect: "TOO LATE" badge visible
#### 6.5. Chore with "Anytime" — visible, no badge, no "Due by"
- expect: `ChildAnytimeChore` card visible with normal styling
- expect: No "Due by" label, no "TOO LATE" badge
#### 6.6. No kebab menu or schedule controls in child mode
- Click on `ChildTodayChore` card
- expect: No "Options" button or kebab menu appears on the card
---
### 7. Parent vs Child Mode Comparison
**File:** `e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts`
**Seed:** Creates child `CompareChild` and two chores: `VisibleBothChore` (scheduled for today) and `ParentOnlyChore` (scheduled for a non-today weekday). Assigns both. Deletes all in `afterAll`.
#### 7.1. Parent mode shows all chores including off-day ones
- Navigate to `/parent/:childId`
- expect: Both `VisibleBothChore` and `ParentOnlyChore` are visible
- expect: `ParentOnlyChore` is grayed out (`chore-inactive`)
- expect: `VisibleBothChore` is NOT grayed out
#### 7.2. Child mode hides off-day chores
- Navigate to `/child/:childId`
- expect: `VisibleBothChore` IS visible
- expect: `ParentOnlyChore` is NOT visible (hidden entirely)
#### 7.3. Adding a schedule via parent mode hides chore in child mode on wrong day
- Navigate to `/parent/:childId`
- Open Schedule for `VisibleBothChore`, set it to a non-today weekday only, Save
- Navigate to `/child/:childId`
- expect: `VisibleBothChore` is no longer visible in child mode
#### 7.4. Removing a schedule via parent mode shows chore in child mode again
- Navigate to `/parent/:childId`
- Open Schedule for the chore, uncheck all days (removes schedule), Save
- Navigate to `/child/:childId`
- expect: Chore is visible again in child mode (no schedule = always shown)
#### 7.5. Paused schedule — chore visible in both modes
- Set a non-today schedule and pause it via API
- Navigate to `/parent/:childId`
- expect: Chore visible, NOT grayed out (paused = acts unscheduled)
- Navigate to `/child/:childId`
- expect: Chore IS visible (paused = shows every day)
---
### 8. Extend Time
**File:** `e2e/mode_parent/chore-scheduler/extend-time.spec.ts`
**Seed:** Creates child `ExtendTimeChild` and chore `ExtendTimeChore` with a schedule for today and a deadline at 00:01 (guaranteed expired). Assigns chore. Deletes all in `afterAll`.
#### 8.1. "Extend Time" appears in kebab menu only when chore is expired
- Navigate to `/parent/:childId`
- expect: `ExtendTimeChore` shows "TOO LATE" badge (deadline is 00:01, already past)
- Click chore card, then "Options"
- expect: "Extend Time" menu item is visible
#### 8.2. "Extend Time" is absent when chore is not expired
- Set up a second chore with deadline at 23:59 (not yet passed)
- Click chore card, then "Options"
- expect: "Extend Time" menu item is NOT visible
#### 8.3. Clicking "Extend Time" removes the TOO LATE badge
- Click "Extend Time" on the expired chore
- expect: "TOO LATE" badge disappears from the card
- expect: Card is no longer grayed out
#### 8.4. Extended chore no longer shows "Due by" label
- After extending, the chore card
- expect: No "Due by" sub-text (extended = no deadline for remainder of day)
#### 8.5. Extending the same chore twice returns 409
- After the first extension, try opening kebab again
- expect: "Extend Time" is no longer in the menu (chore is no longer expired)
#### 8.6. Extension effect is visible in child mode
- After extending the chore in parent mode
- Navigate to `/child/:childId`
- expect: Chore is visible and NOT showing "TOO LATE" badge
---
### 9. Schedule with Interval Mode — Anchor Date & Next Occurrence
**File:** `e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts`
**Seed:** Creates child `IntervalAnchorChild` and chore `IntervalAnchorChore`. Assigns chore. Deletes all in `afterAll`.
#### 9.1. Interval schedule set to every 1 day from today — chore active today
- Create interval schedule: `interval_days=1`, `anchor_date=today`, deadline 23:59
- Navigate to `/parent/:childId`
- expect: Chore is NOT grayed out (hits today)
- Navigate to `/child/:childId`
- expect: Chore IS visible
#### 9.2. Interval schedule set to every 2 days from yesterday — chore NOT active today
- Create interval schedule: `interval_days=2`, `anchor_date=yesterday`
- Navigate to `/parent/:childId`
- expect: Chore IS grayed out (yesterday + 2 = tomorrow, not today)
- Navigate to `/child/:childId`
- expect: Chore is NOT visible
#### 9.3. Interval schedule set to every 2 days starting today — active today
- Create interval schedule: `interval_days=2`, `anchor_date=today`
- Navigate to `/parent/:childId`
- expect: Chore is NOT grayed out
- Navigate to `/child/:childId`
- expect: Chore IS visible
#### 9.4. Interval "Anytime" deadline — no "Due by" label
- Create interval schedule with `interval_has_deadline=false`
- Navigate to `/parent/:childId`
- expect: No "Due by" sub-text on the chore card
---
### 10. SSE Real-Time Updates
**File:** `e2e/mode_parent/chore-scheduler/schedule-sse.spec.ts`
**Seed:** Creates child `SSEScheduleChild` and chore `SSEScheduleChore`. Assigns chore. Deletes all in `afterAll`.
#### 10.1. Setting a schedule via API reflects in an already-open parent view
- Navigate to `/parent/:childId`
- expect: Chore is visible, no schedule annotations
- Via API call (`request.put`), create a schedule for a non-today weekday
- expect: Within a few seconds, the chore card becomes grayed out (SSE `chore_schedule_modified` fires, list refreshes)
#### 10.2. Deleting a schedule via API un-grays the chore in parent view
- (Continuing from 10.1) Delete the schedule via API
- expect: Within a few seconds, the chore card returns to normal styling
#### 10.3. Extending time via API reflects in an already-open parent view
- Set up a schedule with a past deadline (00:01) via API so chore is expired
- Navigate to `/parent/:childId`
- expect: "TOO LATE" badge visible
- Extend time via API call
- expect: "TOO LATE" badge disappears (SSE `chore_time_extended` fires, list refreshes)
---
### 11. Schedule Loading — Backward Compatibility
**File:** `e2e/mode_parent/chore-scheduler/schedule-load.spec.ts`
**Seed:** Creates child and chore. Uses direct API calls to create schedules with specific field combinations. Deletes all in `afterAll`.
#### 11.1. Schedule with `enabled` field missing defaults to enabled
- Create schedule via API without `enabled` field
- Open Schedule modal
- expect: Toggle shows "Enabled" (defaults to true)
#### 11.2. Schedule with `interval_has_deadline=false` shows "Anytime"
- Create interval schedule with `interval_has_deadline=false`
- Open Schedule modal
- expect: "Anytime" label is shown instead of time picker
#### 11.3. Existing days schedule with mixed times loads correctly
- Create days schedule with 3 days: Mon (08:00), Wed (08:00), Fri (10:30) — Fri is an exception
- Open Schedule modal
- expect: Mo, We, Fr chips are active
- expect: Default deadline shows 08:00 AM
- expect: Friday row shows its own time picker with 10:30 AM
#### 11.4. Interval schedule loads anchor date and interval correctly
- Create interval schedule via API with `anchor_date="2026-03-15"`, `interval_days=3`
- Open Schedule modal
- expect: "Every X Days" mode is active
- expect: Stepper shows 3
- expect: Date field shows 2026-03-15
---
## CSS Spec References
- `.chore-inactive`: `opacity: 0.45; filter: grayscale(60%)` — applied in both parent and child views
- `.chore-stamp`: absolute-positioned badge overlay for "TOO LATE", "COMPLETED", "PENDING"
- `.due-label`: "Due by X:XX PM" sub-text below point value
- `.schedule-body.disabled`: `opacity: 0.45; pointer-events: none` — dim wrapper when schedule is paused
- `.toggle-track.on`: `background: var(--btn-primary)` — toggle ON state

View File

@@ -0,0 +1,286 @@
# Create Child
## Application Overview
Tests for creating a new child from the Parent dashboard. The user is authenticated and in parent mode via stored auth state (tests/.auth/user.json). All tests start at /parent/children (Children List view).
## Test Scenarios
### 1. Happy Path ✅ IMPLEMENTED
**Seed:** `frontend/vue-app/seed.ts`
#### 1.1. Create a child with name and age only ✅
**File:** `tests/create-child/happy-path.spec.ts`
**Steps:**
1. Navigate to /parent (router auto-redirects from /)
- expect: The Children List page is displayed
2. Click the 'Add Child' floating action button (FAB) in the bottom-right corner
- expect: The Create Child form is displayed with Name, Age, and Image fields
3. Enter 'Alice' in the Name field
- expect: The Name field shows 'Alice'
- expect: The Create button remains disabled until all required fields are valid
4. Enter '8' in the Age field
- expect: The Age field shows '8'
- expect: The Create button becomes enabled
5. Leave the Image field as the default pre-selected value and click the Create button
- expect: No error messages are displayed
- expect: Navigation returns to /parent
- expect: The child 'Alice' appears in the children list
#### 1.2. Create a child via the inline 'Create' button in empty state ✅
**File:** `tests/create-child/happy-path.spec.ts`
**Steps:**
1. Navigate to /parent with no children in the account
- expect: An empty state message "No children" is shown with an inline 'Create' button
2. Click the inline 'Create' button (with class "round-btn")
- expect: The Create Child form is displayed
3. Enter 'Bob' in the Name field and '10' in the Age field, then click Create
- expect: Create button is initially disabled, becomes enabled after both fields are filled
- expect: No errors are displayed
- expect: Navigation returns to /parent
- expect: 'Bob' appears in the children list
#### 1.3. Create a child with a custom uploaded image ✅
**File:** `tests/create-child/happy-path.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed
2. Enter 'Grace' in the Name field and '6' in the Age field
- expect: Name and Age fields are populated
3. Click 'Add from device' button and choose a valid PNG or JPEG file
- expect: The image file is selected and uploaded
4. Click the Create button
- expect: Create button becomes enabled after async form initialization
- expect: No error messages are displayed
- expect: Navigation returns to /parent
- expect: 'Grace' appears in the children list with the uploaded image
### 2. Validation - Required Fields ✅ IMPLEMENTED
**Seed:** `frontend/vue-app/seed.ts`
**Note:** Form validation is client-side. Invalid inputs disable the submit button rather than showing error messages.
#### 2.1. Reject submission when Name is empty ✅
**File:** `tests/create-child/validation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed at /parent/children/create
2. Leave the Name field empty, enter '7' in the Age field
- expect: The Create button remains disabled
- expect: Form stays on /parent/children/create (no navigation)
#### 2.2. Reject submission when Name is whitespace only ✅
**File:** `tests/create-child/validation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed at /parent/children/create
2. Enter only spaces ' ' in the Name field, enter '7' in the Age field
- expect: The Create button remains disabled (whitespace is treated as empty)
- expect: Form stays on /parent/children/create
#### 2.3. Reject submission when Age is empty ✅
**File:** `tests/create-child/validation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed at /parent/children/create
2. Enter 'Charlie' in the Name field, clear the Age field
- expect: The Create button remains disabled
- expect: Form stays on /parent/children/create
### 3. Validation - Boundary Conditions ✅ IMPLEMENTED
**Seed:** `frontend/vue-app/seed.ts`
#### 3.1. Reject negative age ✅
**File:** `tests/create-child/validation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed at /parent/children/create
2. Enter 'Dave' in the Name field, enter '-1' in the Age field
- expect: The Create button remains disabled (negative age is invalid)
- expect: Form stays on /parent/children/create
#### 3.2. Enforce maximum Name length of 64 characters ✅
**File:** `tests/create-child/validation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed at /parent/children/create
2. Attempt to type a name with 65 characters in the Name field
- expect: The input is capped at 64 characters by HTML maxlength attribute
3. Enter '5' in the Age field and click Create
- expect: The Create button becomes enabled
- expect: The form submits successfully with the 64-character name
- expect: Navigation returns to /parent
#### 3.3. Reject age greater than 120 ✅
**File:** `tests/create-child/validation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed at /parent/children/create
2. Enter 'Eve' in the Name field, enter '121' in the Age field
- expect: The Create button remains disabled (age > 120 is invalid)
- expect: Form stays on /parent/children/create
### 4. Navigation ✅ IMPLEMENTED
**Seed:** `frontend/vue-app/seed.ts`
#### 4.1. Cancel navigates back without saving ✅
**File:** `tests/create-child/navigation.spec.ts`
**Steps:**
1. Navigate to /parent and click the 'Add Child' FAB
- expect: The Create Child form is displayed
2. Enter 'Frank' in the Name field and '9' in the Age field, then click the Cancel button
- expect: Navigation returns to /parent
- expect: 'Frank' does NOT appear in the children list
### 5. Real-Time Updates via SSE ✅ IMPLEMENTED
**Seed:** `frontend/vue-app/seed.ts`
#### 5.1. New child appears in list without page reload ✅
**File:** `tests/create-child/sse.spec.ts`
**Steps:**
1. Open two browser tabs both on /parent
- expect: Both tabs show the Children List page
2. In Tab 1, click 'Add Child' FAB, fill in name 'Hannah' and age '4', then submit
- expect: Tab 1 navigates to /parent and 'Hannah' is visible
3. Switch to Tab 2 which is still on /parent
- expect: 'Hannah' appears in Tab 2 without a manual refresh (SSE real-time update)
### 6. Authorization ✅ IMPLEMENTED
**Seed:** `frontend/vue-app/seed.ts`
#### 6.1. Add Child FAB is hidden when parent auth is expired ✅
**File:** `tests/create-child/authorization.spec.ts`
**Note:** Uses `chromium-no-pin` project storage state to simulate a user without parent authentication.
**Steps:**
1. Navigate to /parent using the no-pin storage state (parent auth not present)
- expect: The 'Add Child' FAB is NOT visible on the page
## Key Changes from Original Plan
1. **Validation Strategy**: Changed from server-side error messages to client-side form validation that disables the submit button for invalid inputs
2. **Async Form Handling**: Tests include logic to handle async form initialization races with default image loading
3. **Authorization test**: Uses `STORAGE_STATE_NO_PIN` (chromium-no-pin project) rather than manually clearing localStorage

View File

@@ -0,0 +1,481 @@
# Parent create and edit chores, kindness acts, and penalties
## Application Overview
Focus on parent-mode flows: a parent logs in via PIN, then creates, edits, and deletes three types of items (chore, kindness act, penalty) separately. Each scenario assumes the parent is already authenticated (PIN entered) and navigates to `/parent/tasks/chores`, then switches tabs as needed.
Tests are executed in parent mode; children should not be able to perform these operations. The intent is to verify that creation forms work, that existing items can be updated correctly, and that proper deletion and default-item restoration logic functions.
All item names use a `${Date.now()}` suffix to avoid collisions between parallel test runs.
## Test Scenarios
### 1. Chore management
**Seed:** `e2e/seed.spec.ts`
#### 1.1. Create a new chore (parent mode)
**File:** `e2e/mode_parent/tasks/chore-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`
- expect: The chores list loads and the default chore 'Clean your mess' is visible
2. Click the 'Create Chore' button
- expect: The Create Chore form is displayed with a 'Chore Name' field and a Points field
3. Enter a unique name (e.g. `Wash dishes ${suffix}`) and '10' in Points using DOM events
- expect: Submit/Create button becomes enabled
4. Click the Create button
- expect: No `.error` elements are shown
- expect: The new chore appears in the chores list
#### 1.2. Cancel chore creation
**File:** `e2e/mode_parent/tasks/chore-cancel.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores` and click 'Create Chore'
- expect: Create Chore form appears with 'Chore Name' label visible
2. Fill in 'Test' for name and '5' for points, then click 'Cancel'
- expect: User is returned to the chores list (default chore 'Clean your mess' is visible with exact match)
- expect: No element with text 'Test' exists in the list
#### 1.3. Edit an existing chore
**File:** `e2e/mode_parent/tasks/chore-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`; create `Wash dishes ${suffix}` if it doesn't already exist
- expect: The chore appears in the list
2. Click the chore row to open the edit form
- expect: Edit form appears with 'Chore Name' label visible
3. Change the name to `Wash car ${suffix}` and points using DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: No errors are displayed
- expect: The updated chore name `Wash car ${suffix}` appears in the list
#### 1.4. Cancel chore edit
**File:** `e2e/mode_parent/tasks/chore-cancel.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`; create 'Wash dishes' if it doesn't exist; click the row
- expect: Edit form is displayed
2. Change the name to 'Should not save' via DOM events, then click 'Cancel'
- expect: The list is visible again (default chore 'Clean your mess' shown with exact match)
- expect: 'Wash dishes' (exact) is still present; 'Should not save' does not appear
#### 1.5. Convert a default chore to a user item by editing
**File:** `e2e/mode_parent/tasks/chore-convert-default.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores` and locate the default chore 'Clean your mess'
- expect: Item is visible and has no delete button (`button[aria-label="Delete item"]` count is 0)
2. Click the row to open the edit form
- expect: Edit form opens with `input#name` value of 'Clean your mess'
3. Change the name to 'Clean your mess (custom)' and points to '20' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: The chore row for 'Clean your mess' now has a visible delete button
#### 1.6. Delete a chore
**File:** `e2e/mode_parent/tasks/chore-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`; create a uniquely-named chore if necessary
- expect: The chore appears in the list with a delete control
2. Click the delete icon (`button[aria-label="Delete item"]`) and confirm the dialog
- expect: The item is removed from the list
#### 1.7. Edit default chore points and verify system restoration on delete
**File:** `e2e/mode_parent/tasks/chore-delete-default.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`; clean up any leftover user-copy of 'Take out trash' (with delete icon) from previous runs
2. Verify the default 'Take out trash' is visible with no delete icon
3. Click 'Take out trash' to open the edit form; confirm `input#name` reads 'Take out trash'; change points to '5' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: Exactly one 'Take out trash' row is in the list
- expect: A delete icon is now visible on the 'Take out trash' row
- expect: The points display reads '5 pts'
5. Click the delete icon and confirm the dialog
- expect: 'Take out trash' is still visible in the list (system default restored)
- expect: No delete icon on the 'Take out trash' row
- expect: Points display is restored to '20 pts'
### 2. Kindness act management
**Seed:** `e2e/seed.spec.ts`
#### 2.1. Create a new kindness act (parent mode)
**File:** `e2e/mode_parent/tasks/kindness-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores` and click the 'Kindness Acts' tab
- expect: Kindness acts list is visible
2. Click 'Create Kindness Act'
- expect: Create form appears with a 'Name' field
3. Enter a unique name (e.g. `Share toys ${suffix}`) and '5' in Points via DOM events
- expect: Create button becomes enabled
4. Click Create
- expect: No `.error` elements shown
- expect: The new kindness act name appears in the list
#### 2.2. Cancel kindness act creation
**File:** `e2e/mode_parent/tasks/kindness-cancel.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts', then click 'Create Kindness Act'
- expect: Create form is visible
2. Fill in 'Test' for name and '5' for points, then click 'Cancel'
- expect: Returned to the list
- expect: 'Should not save' does not appear in the list
#### 2.3. Edit an existing kindness act
**File:** `e2e/mode_parent/tasks/kindness-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; create `Share toys ${suffix}` fresh
- expect: Item is displayed in the list
2. Click the row to open the edit form
- expect: Edit form appears with 'Name' label and Save button visible
3. Change name to `Help with homework ${suffix}` and points to '8' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: The updated name `Help with homework ${suffix}` appears in the list
#### 2.4. Cancel kindness act edit
**File:** `e2e/mode_parent/tasks/kindness-cancel.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; ensure 'Share toys' (exact) exists; click its row
- expect: Edit form is displayed
2. Change name to 'Never saved' via DOM events, then click 'Cancel'
- expect: 'Share toys' (exact) is still visible in the list
#### 2.5. Convert a default kindness act to a user item by editing
**File:** `e2e/mode_parent/tasks/kindness-convert-default.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; locate the default 'Be good for the day'
- expect: Item is visible with no delete icon
2. Click the row; rename to 'Be good today (edited)' and change points to '7' via `#name`/`#points` inputs
- expect: Save button becomes enabled
3. Click Save
- expect: 'Be good today (edited)' row has a visible delete icon
4. _(Cleanup)_ Click the delete icon and confirm — restores the default so other tests see a clean state
#### 2.6. Delete a kindness act
**File:** `e2e/mode_parent/tasks/kindness-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; create a uniquely-named act
- expect: The item appears in the list with a delete control
2. Click the delete icon and confirm
- expect: The act is removed from the list
#### 2.7. Edit default kindness act points and verify system restoration on delete
**File:** `e2e/mode_parent/tasks/kindness-delete-default.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; clean up any leftover user-copy of 'Be good for the day' from previous runs
2. Verify the default 'Be good for the day' is visible with no delete icon
3. Click the row; confirm `input#name` reads 'Be good for the day'; change points to '3' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: Exactly one 'Be good for the day' row is in the list
- expect: A delete icon is now visible on the row
- expect: Points display reads '3 pts'
5. Click the delete icon and confirm the dialog
- expect: 'Be good for the day' is still visible (system default restored)
- expect: No delete icon on the row
- expect: Points display is restored to '15 pts'
### 3. Penalty management
**Seed:** `e2e/seed.spec.ts`
#### 3.1. Create a new penalty (parent mode)
**File:** `e2e/mode_parent/tasks/penalty-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores` and click the 'Penalties' tab
- expect: Penalties list is shown
2. Click 'Create Penalty'
- expect: Create Penalty form appears
3. Enter a unique name (e.g. `No screen time ${suffix}`) and '30' in Points via DOM events
- expect: Create button becomes enabled
4. Click Create
- expect: No validation errors
- expect: The new penalty name appears in the list
#### 3.2. Cancel penalty creation
**File:** `e2e/mode_parent/tasks/penalty-cancel.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Penalties', then click 'Create Penalty'
- expect: Create penalty form is visible
2. Fill 'Should not save' and '10' points via DOM events, then click 'Cancel'
- expect: 'Should not save' does not appear in the list
#### 3.3. Edit an existing penalty
**File:** `e2e/mode_parent/tasks/penalty-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; create `No screen time ${suffix}` fresh
- expect: Item appears in list
2. Click the row; wait for `#name` to have the original value
- expect: Edit form opens with existing name pre-filled
3. Change name to `No dessert ${suffix}` and points to '20' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: `No dessert ${suffix}` appears in the list
#### 3.4. Cancel penalty edit
**File:** `e2e/mode_parent/tasks/penalty-cancel.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; ensure 'No screen time' (exact) exists; click the row
- expect: Edit form is displayed
2. Change name to 'Never saved' via DOM events, then click 'Cancel'
- expect: 'No screen time' (exact) is still visible
#### 3.5. Convert a default penalty to a user item by editing
**File:** `e2e/mode_parent/tasks/penalty-convert-default.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; locate the default 'Fighting'
- expect: Item is visible with no delete icon
2. Click the row; rename to 'Fighting (custom)' and change points to '15' via `#name`/`#points` inputs
- expect: Save button becomes enabled
3. Click Save
- expect: 'Fighting' row now has a visible delete icon
#### 3.6. Delete a penalty
**File:** `e2e/mode_parent/tasks/penalty-create-edit.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; create a uniquely-named penalty
- expect: Delete control (`button[aria-label="Delete item"]`) is visible on the row
2. Click the delete icon and confirm via `button.btn-danger`
- expect: Item is removed from the list
#### 3.7. Edit default penalty points and verify system restoration on delete
**File:** `e2e/mode_parent/tasks/penalty-delete-default.spec.ts`
**Steps:**
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; clean up any leftover user-copy of 'Fighting' from previous runs
2. Verify the default 'Fighting' is visible with no delete icon
3. Click the row; confirm `input#name` reads 'Fighting'; change points to '3' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: Exactly one 'Fighting' row is in the list
- expect: A delete icon is now visible on the row
- expect: Points display reads '3 pts'
5. Click the delete icon and confirm the dialog
- expect: 'Fighting' is still visible (system default restored)
- expect: No delete icon on the row
- expect: Points display is restored to '10 pts'

View File

@@ -0,0 +1,602 @@
# Parent Notifications E2E Tests
## Application Overview
The parent notifications feature adds two channels for alerting parents when a child performs an action requiring their attention:
1. **Web Push Notifications** — When a child confirms a chore is complete, or requests an affordable reward, the parent receives a native browser push notification via a Service Worker. The payload contains `child_id`, `entity_id`, and `entity_type`. Supporting browsers (Chrome desktop) show "Approve" and "Deny" action buttons; clicking an action button calls the matching backend API (`approve-chore`, `reject-chore`, `trigger-reward`, or `deny-reward-request`) without opening the app. Tapping the notification body opens or focuses the app and navigates to `/parent/:id?scrollTo=<entityId>&entityType=<chore|reward>`.
2. **Daily Email Digest** — At 9 pm in the parent's local timezone, a HTML digest email is sent for any still-pending items (only if at least one exists). Each item has three links: **View** (ParentView deep link), **Approve** (`GET /api/digest-action/<token>`), and **Deny** (`GET /api/digest-action/<token>`). Tokens are HMAC-SHA256 signed, valid for 24 hours, and single-use. On success the endpoint executes the action and 302-redirects to the ParentView deep link; on error (expired, already-used, tampered) it returns a 400 HTML page with no redirect.
**In-app notification view**`/parent/notifications` lists all pending confirmations via `GET /api/pending-confirmations`. Clicking an item navigates to `/parent/:id?scrollTo=<entityId>&entityType=<type>`. Approval and rejection happen via **ParentView**:
- **Pending chores**: card shows a `.chore-stamp.pending-stamp` "PENDING" badge; clicking the card opens `ChoreApproveDialog` which has Approve and Reject buttons.
- **Pending rewards**: card shows a `.pending` "PENDING" badge when `item.redeeming === true`; clicking opens a reward-confirm dialog that calls `POST trigger-reward`. A **Deny** button (added by this feature) calls `POST deny-reward-request`.
The notification view refreshes reactively via SSE events: `child_chore_confirmation` (operations: `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`) and `child_reward_request` (operations: `CREATED`, `CANCELLED`, `GRANTED`).
**Spec covered:** `feat-parent-chore-notifications.md`
---
## Implementation
Tests live in `e2e/mode_parent/notifications/`. Each spec creates its own isolated child and entities via API so files can run in parallel within the bucket. All created resources are deleted in `afterAll`.
## Playwright Projects
| Project | testMatch | Notes |
| ------------------------------- | -------------------------------------------- | ----------------------- |
| `chromium-parent-notifications` | `/mode_parent\/notifications\/.+\.spec\.ts/` | Depends on `setup` only |
**Config:** Add project to `playwright.config.ts` with `dependencies: ['setup']` and `storageState: STORAGE_STATE`. Add `/mode_parent\/notifications\//` to the `testIgnore` list of any existing catch-all project so notification specs do not run in two buckets simultaneously.
---
## Seed Strategy
Each spec file uses `beforeAll` to create isolated children, tasks, and rewards via the authenticated `request` fixture, and deletes them all in `afterAll`. The standard pattern is: pre-delete by name, create via `PUT`, re-fetch to get the ID.
**Push subscription tests:** Service Worker registration and `PushManager.subscribe()` require a VAPID public key configured in the dev server environment. Tests 1.1 and 1.2 (intercepting the subscription POST to verify endpoint/timezone) depend on VAPID being configured and `PushManager` being available — both absent in headless Chromium. These tests have been **removed** from the spec. The remaining tests (1.31.5) cover the permission-denied path and unauthenticated API rejection, which do not require VAPID.
**Digest action token prerequisite:** The email digest scheduler is disabled in `DB_ENV=e2e` (to avoid sending real email). To exercise `GET /api/digest-action/<token>` in E2E tests, the backend must expose a test-only helper endpoint active **only** when `DB_ENV=e2e`:
```
POST /api/admin/test/digest-token
Auth: required (uses the E2E parent session)
Body: { child_id, entity_id, entity_type, action, expires_in_hours? }
Response 200: { token: string }
```
This endpoint creates a valid `DigestActionToken` (with correct HMAC-SHA256 signature) in TinyDB and returns the token string. All digest-action tests call this helper in `beforeAll` to obtain tokens for their scenarios.
---
## Test Scenarios
### 1. Push Subscription Registration
**File:** `e2e/mode_parent/notifications/push-subscription.spec.ts`
**Seed:** No child/task setup required. Tests use `page.context().grantPermissions(['notifications'])` as needed. No `afterAll` cleanup.
> **Note on trigger**: Push subscription is now initiated by the Push Notifications toggle in `/parent/profile` (which calls `subscribeToPushWithResult()` directly). For these tests, triggering is done via the profile toggle rather than navigating to `/parent/tasks/chores`. PIN entry also triggers subscription in production but pre-authenticated E2E sessions bypass the PIN flow.
#### 1.3. No subscription POST is sent when notification permission is not granted
- Do NOT grant notification permission (leave default)
- Track calls to `POST /api/push-subscription` via `page.route()`
- Navigate to `/parent/profile` and attempt to click the Push Notifications toggle
- expect: No POST to `/api/push-subscription` was captured
#### 1.4. Backend rejects unauthenticated subscription POST (401)
- Create a fresh `APIRequestContext` without storageState (no auth cookies) via `playwright.request.newContext()`
- POST to `/api/push-subscription` with a minimal payload
- expect: Response status is 401
#### 1.5. Backend rejects unauthenticated subscription DELETE (401)
- Using the same unauthenticated context as 1.4, send `DELETE /api/push-subscription`
- expect: Response status is 401
---
### 2. Chore Notification — Appearance and In-App Approval
**File:** `e2e/mode_parent/notifications/chore-notification-approve.spec.ts`
**Seed:** Creates child `ChoreNotifApproveChild` and chore `ChoreNotifApproveChore` (5 points, type `chore`) via API. Assigns chore to child. Sets a daily interval schedule (`mode: 'interval', interval_days: 1, interval_has_deadline: false`) so the chore persists post-approval. In `beforeAll`, calls `POST /child/<childId>/confirm-chore` with `{ task_id: choreId }`. Deletes child and task in `afterAll`.
#### 2.1. Confirmed chore appears in the notification view
- Navigate to `/parent/notifications`
- expect: A list item is visible containing the text `ChoreNotifApproveChore`
- expect: The item contains the word "completed" (not "requested")
#### 2.2. Notification item displays the child's name
- Navigate to `/parent/notifications`
- expect: The notification item for `ChoreNotifApproveChore` also shows `ChoreNotifApproveChild`
#### 2.3. Clicking notification navigates to ParentView with correct query params
- Navigate to `/parent/notifications`
- Click the `ChoreNotifApproveChore` notification item
- `await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=chore'), { timeout: 5000 })`
- expect: URL contains `scrollTo=<choreId>` and `entityType=chore`
- expect: The "Chores" section heading is visible
#### 2.4. Pending chore card shows PENDING stamp in ParentView
- Navigate to `/parent/<childId>`
- Locate the chore card for `ChoreNotifApproveChore` in the Chores section
- expect: Card contains an element with text "PENDING"
#### 2.5. Clicking a pending chore card opens ChoreApproveDialog
- Navigate to `/parent/<childId>`
- Click the `ChoreNotifApproveChore` card
- expect: A dialog element is visible containing both an "Approve" button and a "Reject" button
#### 2.6. Approving via ChoreApproveDialog removes the PENDING stamp
- Click the "Approve" button in the dialog
- expect: The dialog closes
- expect: The "PENDING" stamp is no longer present on the `ChoreNotifApproveChore` card
#### 2.7. Approving a chore clears it from the notification view
- After approving (2.6), navigate to `/parent/notifications`
- expect: No list item containing `ChoreNotifApproveChore` is present
#### 2.8. Approving a chore awards points to the child
- Note child's point balance before approval
- Approve the chore via the dialog
- expect: Child's point balance increases by the chore's point value (5)
---
### 3. Chore Notification — In-App Rejection
**File:** `e2e/mode_parent/notifications/chore-notification-deny.spec.ts`
**Seed:** Creates child `ChoreNotifDenyChild` and chore `ChoreNotifDenyChore` (5 points) via API. Assigns chore. Sets daily interval schedule. In `beforeAll`, calls `POST /child/<childId>/confirm-chore`. Deletes all in `afterAll`.
#### 3.1. Confirmed chore appears in the notification view
- Navigate to `/parent/notifications`
- expect: A list item is visible containing `ChoreNotifDenyChore`
#### 3.2. ChoreApproveDialog shows both Approve and Reject buttons
- Navigate to `/parent/<childId>`, click the `ChoreNotifDenyChore` card
- expect: Dialog with "Approve" and "Reject" buttons is visible
#### 3.3. Rejecting via ChoreApproveDialog removes the PENDING stamp
- Click the "Reject" button
- expect: Dialog closes
- expect: "PENDING" stamp is no longer present on the `ChoreNotifDenyChore` card
#### 3.4. Rejecting a chore clears it from the notification view
- After rejecting (3.3), navigate to `/parent/notifications`
- expect: No list item containing `ChoreNotifDenyChore` is present
#### 3.5. Rejecting a chore does not award points
- Note child's point balance before rejection
- Reject the pending chore via ChoreApproveDialog
- expect: Child's point balance is unchanged
---
### 4. Reward Notification — Appearance and In-App Grant
**File:** `e2e/mode_parent/notifications/reward-notification-approve.spec.ts`
**Seed:** Creates child `RewardNotifApproveChild` with `points = 30` via API. Creates reward `RewardNotifApproveReward` with `cost = 10`. Assigns reward to child. In `beforeAll`, calls `POST /child/<childId>/request-reward` with `{ reward_id: rewardId }`. Deletes child and reward in `afterAll`.
#### 4.1. Requested reward appears in the notification view
- Navigate to `/parent/notifications`
- expect: A list item is visible containing `RewardNotifApproveReward`
- expect: The item contains the word "requested" (not "completed")
#### 4.2. Notification item displays the child's name
- Navigate to `/parent/notifications`
- expect: The notification item for `RewardNotifApproveReward` also shows `RewardNotifApproveChild`
#### 4.3. Clicking reward notification navigates to ParentView with correct query params
- Navigate to `/parent/notifications`
- Click the `RewardNotifApproveReward` notification item
- `await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=reward'), { timeout: 5000 })`
- expect: URL contains `scrollTo=<rewardId>` and `entityType=reward`
#### 4.4. Pending reward card shows PENDING badge in ParentView
- Navigate to `/parent/<childId>`
- Locate the reward card for `RewardNotifApproveReward` in the Rewards section
- expect: Card contains an element with text "PENDING"
#### 4.5. Clicking a pending reward card opens the grant confirmation dialog
- Navigate to `/parent/<childId>`, click the `RewardNotifApproveReward` card
- expect: A reward confirmation dialog appears
#### 4.6. Confirming the grant removes the PENDING badge and updates child points
- In the grant confirmation dialog, click the confirm button
- expect: Dialog closes
- expect: The "PENDING" badge is no longer visible on the reward card
- expect: Child's point balance decreases by the reward cost (30 → 20)
#### 4.7. Granting a reward clears it from the notification view
- After confirming the grant (4.6), navigate to `/parent/notifications`
- expect: No list item containing `RewardNotifApproveReward` is present
---
### 5. Reward Notification — In-App Denial
**File:** `e2e/mode_parent/notifications/reward-notification-deny.spec.ts`
**Seed:** Creates child `RewardNotifDenyChild` with `points = 30`. Creates reward `RewardNotifDenyReward` with `cost = 10`. Assigns reward to child. Uses `beforeEach` to call `POST /child/<childId>/request-reward` (creates a fresh pending request before each test) and `afterEach` to call `POST /child/<childId>/cancel-request-reward` to clean up any unconsumed requests. Deletes child and reward in `afterAll`.
#### 5.1. Requested reward appears in the notification view
- Navigate to `/parent/notifications`
- expect: A list item is visible containing `RewardNotifDenyReward`
#### 5.2. Pending reward card shows PENDING badge in ParentView
- Navigate to `/parent/<childId>`
- expect: The `RewardNotifDenyReward` card shows a "PENDING" badge
#### 5.3. Denying a reward request removes the PENDING badge
- Trigger the deny action for the pending reward (target the Deny button by its label or role — exact placement determined by implementation)
- expect: The "PENDING" badge disappears from the reward card
- expect: Child's point balance is unchanged
#### 5.4. Denying a reward request clears it from the notification view
- After denying (5.3), navigate to `/parent/notifications`
- expect: No list item containing `RewardNotifDenyReward` is present
#### 5.5. `POST deny-reward-request` returns success when a pending request exists (API layer)
- Via the authenticated `request` fixture, call `POST /child/<childId>/deny-reward-request` with `{ reward_id: rewardId }`
- expect: Response status is 200
#### 5.6. `POST deny-reward-request` returns 404 when no pending request exists (API layer)
- Cancel any existing pending request first via `POST /child/<childId>/cancel-request-reward`
- Call `POST /child/<childId>/deny-reward-request` with `{ reward_id: rewardId }`
- expect: Response status is 404
---
### 6. ParentView Deep-Link Navigation
**File:** `e2e/mode_parent/notifications/deep-link-navigation.spec.ts`
**Seed:** Creates child `DeepLinkChild` with `points = 100`. Creates 10 chores `DeepLinkChore1``DeepLinkChore10` (each 5 points, type `chore`). Creates reward `DeepLinkReward` with `cost = 20`. Assigns all 10 chores and the reward to the child. Confirms `DeepLinkChore10` via `POST /child/<childId>/confirm-chore`. Requests the reward via `POST /child/<childId>/request-reward`. Uses narrow viewport `{ width: 480, height: 800 }` so cards near the bottom require actual scrolling. Deletes child, all tasks, and reward in `afterAll`.
#### 6.1. Deep link to chore — correct card is scrolled into viewport
- `await page.setViewportSize({ width: 480, height: 800 })`
- Navigate to `/parent/<childId>?scrollTo=<choreId>&entityType=chore`
- Wait for chore list to load and allow 800 ms for the scroll delay to complete
- expect: The `DeepLinkChore10` card is within the visible viewport
#### 6.2. Deep link to reward — correct card is scrolled into viewport
- `await page.setViewportSize({ width: 480, height: 800 })`
- Navigate to `/parent/<childId>?scrollTo=<rewardId>&entityType=reward`
- Wait for reward list to load and scroll delay
- expect: The `DeepLinkReward` card is within the visible viewport
#### 6.3. Deep link with unknown `entityType` — page loads without JS error
- Listen for `page.on('pageerror', ...)` before navigating
- Navigate to `/parent/<childId>?scrollTo=<choreId>&entityType=unknown`
- expect: No pageerror event fires
- expect: Page loads and the "Chores" section heading is visible
#### 6.4. Deep link with missing `scrollTo` — page loads normally
- Navigate to `/parent/<childId>?entityType=chore` (no `scrollTo` query param)
- expect: Page loads without error and "Chores" section is visible
#### 6.5. Deep link with no query params — page loads normally
- Navigate to `/parent/<childId>` (bare URL, no query params)
- expect: Page loads and both "Chores" and "Rewards" section headings are visible
#### 6.6. Clicking a chore notification produces the correct deep link
- Navigate to `/parent/notifications`
- Click the `DeepLinkChore10` notification item (confirmed chore)
- expect: URL matches `/parent/<childId>?scrollTo=<choreId>&entityType=chore`
- Wait 800 ms for scroll delay
- expect: `DeepLinkChore10` card is in viewport
#### 6.7. Clicking a reward notification produces the correct deep link
- Navigate to `/parent/notifications`
- Click the `DeepLinkReward` notification item
- expect: URL matches `/parent/<childId>?scrollTo=<rewardId>&entityType=reward`
- Wait 800 ms
- expect: `DeepLinkReward` card is in viewport
---
### 7. Digest Action Token — Happy Paths
Each combination of `entity_type × action` gets its own spec file to keep seeds isolated and specs independently runnable.
**Files:**
- `e2e/mode_parent/notifications/digest-action-approve-chore.spec.ts`
- `e2e/mode_parent/notifications/digest-action-deny-chore.spec.ts`
- `e2e/mode_parent/notifications/digest-action-approve-reward.spec.ts`
- `e2e/mode_parent/notifications/digest-action-deny-reward.spec.ts`
**Seed (shared pattern for all four specs):** Creates an isolated child, task or reward, assigns it, creates a pending confirmation (via `confirm-chore` or `request-reward`), then calls the authenticated `POST /api/admin/test/digest-token` helper to obtain a valid signed token for the required action. All `GET /digest-action/<token>` calls use a **fresh unauthenticated `APIRequestContext`** (no cookies) created via `playwright.request.newContext({ ignoreHTTPSErrors: true })`. The verifying `GET /api/pending-confirmations` and `GET /api/child/<id>` calls use the authenticated `request` fixture. Deletes child and task/reward in `afterAll`.
#### 7.1. Approve-chore token — 302 redirect to correct ParentView URL
- Call `GET /api/digest-action/<approve_chore_token>` (unauthenticated, redirect following disabled)
- expect: Response status is 302
- expect: `response.headers()['location']` contains `/parent/<childId>`, `scrollTo=<choreId>`, and `entityType=chore`
#### 7.2. Approve-chore token — approve action is performed, points awarded
- After calling token (7.1), wait 200 ms for the action to complete
- Via authenticated `request`: `GET /api/pending-confirmations`
- expect: No pending confirmation record exists for this chore and child
- Via authenticated `request`: `GET /api/child/<childId>`
- expect: Points increased by the chore's point value
#### 7.3. Deny-chore token — 302 redirect to correct ParentView URL
- Call `GET /api/digest-action/<deny_chore_token>` (unauthenticated, redirect following disabled)
- expect: Response status is 302
- expect: Location header contains the correct ParentView deep link (`entityType=chore`)
#### 7.4. Deny-chore token — rejection action is performed, no points awarded
- After calling token (7.3), verify:
- expect: Pending confirmation for this chore is removed from `GET /api/pending-confirmations`
- expect: Child's point balance is unchanged
#### 7.5. Approve-reward token — 302 redirect to correct ParentView URL
- Call `GET /api/digest-action/<approve_reward_token>` (unauthenticated, redirect following disabled)
- expect: Response status is 302
- expect: Location header contains `/parent/<childId>`, `scrollTo=<rewardId>`, and `entityType=reward`
#### 7.6. Approve-reward token — reward is triggered and child points are deducted
- After calling token (7.5):
- expect: Pending confirmation for this reward is gone from `GET /api/pending-confirmations`
- expect: Child's point balance decreased by the reward cost
#### 7.7. Deny-reward token — 302 redirect to correct ParentView URL
- Call `GET /api/digest-action/<deny_reward_token>` (unauthenticated, redirect following disabled)
- expect: Response status is 302
- expect: Location header contains the correct ParentView deep link for this child and reward
#### 7.8. Deny-reward token — pending reward request is removed, points unchanged
- After calling token (7.7):
- expect: Pending confirmation for this reward is gone
- expect: Child's point balance is unchanged
---
### 8. Digest Action Token — Error Paths
**File:** `e2e/mode_parent/notifications/digest-action-errors.spec.ts`
**Seed:** Creates child `DigestErrorChild` and chore `DigestErrorChore` via API. Assigns chore. Calls `POST /child/<childId>/confirm-chore` to create a pending confirmation. Uses `POST /api/admin/test/digest-token` to generate tokens with specific expiry configurations. All `GET /digest-action/<token>` calls use a fresh unauthenticated context. Deletes child and task in `afterAll`.
#### 8.1. Expired token returns 400
- Call `POST /api/admin/test/digest-token` with `expires_in_hours: -1` (already expired at creation)
- Call `GET /api/digest-action/<expired_token>` (unauthenticated, redirect following disabled)
- expect: Response status is 400
- expect: No `Location` header is present (no redirect occurred)
#### 8.2. Already-used token returns 400 on second use
- Create a valid token via test helper
- First call: `GET /api/digest-action/<token>` — expect: status 302 (token consumed, action executed)
- Second call: `GET /api/digest-action/<token>` with the same token string
- expect: Response status is 400
#### 8.3. Tampered token returns 400
- Create a valid token via test helper; store the raw token string
- Modify one character of the token (e.g. replace the final character with a different letter)
- Call `GET /api/digest-action/<tampered_token>` (unauthenticated)
- expect: Response status is 400
#### 8.4. Completely fake token returns 400
- Call `GET /api/digest-action/this-token-does-not-exist`
- expect: Response status is 400
#### 8.5. Error response is a plain HTML page with no redirect
- For any 400 response from 8.18.4:
- expect: `content-type` header contains `text/html`
- expect: The response body does not include a `Location` header
---
### 9. Service Worker Push Delivery — Manual Tests and Mock Strategy
> **Playwright limitation:** The following scenarios **cannot be automated** with standard Playwright:
>
> - Playwright has no API to dispatch a `push` event to a registered Service Worker.
> - OS-level notification tray interactions ("Approve"/"Deny" action buttons) are outside the browser's JavaScript sandbox.
> - Delivering a real push message requires a live Web Push server sending to the browser's push endpoint, which is impractical in a local E2E environment.
>
> These scenarios must be verified **manually** during feature QA, or via the **mock strategy** below.
#### Recommended Mock Strategy — Service Worker Test Hook
Add a test-only message handler to `public/sw.js`, guarded by `self.location.hostname === 'localhost'`:
```js
// sw.js — localhost test hook only
self.addEventListener('message', async (event) => {
if (event.data?.type !== '__TEST_NOTIFICATION_CLICK__') return
const { action, childId, entityId, entityType } = event.data
if (action === 'approve') {
const url =
entityType === 'chore'
? `/api/child/${childId}/approve-chore`
: `/api/child/${childId}/trigger-reward`
const body =
entityType === 'chore'
? JSON.stringify({ task_id: entityId })
: JSON.stringify({ reward_id: entityId })
await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body,
})
} else if (action === 'deny') {
const url =
entityType === 'chore'
? `/api/child/${childId}/reject-chore`
: `/api/child/${childId}/deny-reward-request`
const body =
entityType === 'chore'
? JSON.stringify({ task_id: entityId })
: JSON.stringify({ reward_id: entityId })
await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body,
})
} else {
// body tap — focus or open the app at the deep link
const deepLink = `/parent/${childId}?scrollTo=${entityId}&entityType=${entityType}`
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
if (clients[0]) {
await clients[0].focus()
await clients[0].navigate(deepLink)
} else {
await self.clients.openWindow(deepLink)
}
}
})
```
Invoke from a Playwright spec after the SW is registered and controlling the page:
```ts
await page.evaluate(
({ childId, entityId, entityType, action }) => {
navigator.serviceWorker.controller?.postMessage({
type: '__TEST_NOTIFICATION_CLICK__',
action,
childId,
entityId,
entityType,
})
},
{ childId, entityId, entityType, action: 'approve' },
)
```
This allows a spec to simulate notification action clicks without OS interaction, enabling API verification and navigation assertions on top of the mock.
#### Manual QA Checklist (no spec file — document only)
- Parent tab active: SSE in-app toast appears; no OS push notification is sent
- Parent tab in background or closed: OS push notification appears showing the child name and chore/reward name
- Notification does NOT appear for a reward request when the child cannot afford the reward
- Notification shows "Approve" and "Deny" action buttons on Chrome desktop; no action buttons on iOS Safari (fallback: body tap only)
- Clicking "Approve" on a chore notification calls `POST approve-chore`, dismisses the notification tray entry, and does NOT open or focus the app
- Clicking "Deny" on a chore notification calls `POST reject-chore`, dismisses, no app focus
- Clicking "Approve" on a reward notification calls `POST trigger-reward`, dismisses, no app focus
- Clicking "Deny" on a reward notification calls `POST deny-reward-request`, dismisses, no app focus
- Tapping the chore notification body opens (or focuses) the app at `/parent/<childId>?scrollTo=<choreId>&entityType=chore` with the chore card scrolled into view
- Tapping the reward notification body opens/focuses the app at `/parent/<childId>?scrollTo=<rewardId>&entityType=reward` with the reward card scrolled into view
- If the app is already open in another tab, tapping the notification body focuses the existing tab and navigates it — a second tab is NOT opened
- iOS Safari fall-back: notification body tap still navigates correctly to the deep link; no action buttons are rendered
- After the app tab is fully closed and a push notification arrives and is tapped, the app correctly opens at the deep-link URL
---
### 10. User Profile Notification Settings
**File:** `e2e/mode_parent/notifications/user-profile-notification-settings.spec.ts`
**Seed:** No child/task setup required. Tests navigate directly to the User Profile page. No `afterAll` cleanup beyond any push subscription state left by the tests (handled internally by `beforeEach`/`afterEach` where needed).
#### 10.1. Email Digest toggle is visible on User Profile page
- Navigate to `/parent/profile` (or the route that renders `UserProfile.vue`)
- expect: An element with the label text "Daily Digest" is visible
#### 10.2. Email Digest toggle initialises from the server value
- Via authenticated `request`, call `PUT /api/user/profile` with `{ email_digest_enabled: true }` to set a known state
- Navigate to `/parent/profile`
- expect: The Daily Digest toggle is checked/on
- Via authenticated `request`, call `PUT /api/user/profile` with `{ email_digest_enabled: false }`
- Reload the page
- expect: The Daily Digest toggle is unchecked/off
#### 10.3. Toggling Email Digest off calls the correct API
- Via `request`, ensure `email_digest_enabled` is `true` before the test
- Navigate to `/parent/profile`
- Intercept `PUT /api/user/profile` with `page.route()`
- Click the Daily Digest toggle to switch it off
- expect: Intercepted PUT body contains `email_digest_enabled: false`
- expect: Toggle visually reflects the off state
#### 10.4. Toggling Email Digest on calls the correct API
- Via `request`, ensure `email_digest_enabled` is `false` before the test
- Navigate to `/parent/profile`
- Intercept `PUT /api/user/profile` with `page.route()`
- Click the Daily Digest toggle to switch it on
- expect: Intercepted PUT body contains `email_digest_enabled: true`
- expect: Toggle visually reflects the on state
#### 10.5. Push Notifications toggle is visible on User Profile page
- Navigate to `/parent/profile`
- expect: An element with the label text "Push Notifications" is visible
#### 10.6. Push Notifications toggle initialises to off when no subscription exists
- Do NOT call `page.context().grantPermissions(['notifications'])` (leave default denied/prompt)
- Navigate to `/parent/profile`
- expect: The Push Notifications toggle is unchecked/off
#### 10.8. Push Notifications toggle is disabled when browser permission is denied
- Call `page.context().clearPermissions()` then navigate to `/parent/profile` with notifications permission absent (or set to `'denied'` if Playwright supports it)
- expect: The Push Notifications toggle input is disabled (`aria-disabled` or `disabled` attribute set)
---
## Test Summary
| # | Group | Spec File(s) | Test Cases |
| --- | --------------------------------- | -------------------------------------------- | ---------- |
| 1 | Push subscription registration | `push-subscription.spec.ts` | 3 |
| 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 |
| 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 |
| 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 |
| 5 | Reward notification — deny flow | `reward-notification-deny.spec.ts` | 6 |
| 6 | ParentView deep-link navigation | `deep-link-navigation.spec.ts` | 7 |
| 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 |
| 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 |
| 9 | SW push — manual/mock | (no spec file) | checklist |
| 10 | User Profile notification toggles | `user-profile-notification-settings.spec.ts` | 7 |
## Implementation Notes
- A backend test-only endpoint `POST /api/admin/test/digest-token` (guarded by `DB_ENV=e2e`) is a **prerequisite** for scenario groups 7 and 8 to work.
- The SW mock strategy in group 9 requires a `__TEST_NOTIFICATION_CLICK__` message handler added to `sw.js` (localhost-only, not present in production builds).
- The `playwright.config.ts` catch-all project must get `e2e/mode_parent/notifications/` added to its `testIgnore` list to prevent double-running.
- Tests 1.1, 1.2, 10.7, 10.9, and 10.10 were removed: all required `PushManager.subscribe()` which is unavailable in headless Chromium without a VAPID key. The features they covered work correctly in a real browser; these scenarios are verified via manual QA (see §9).

View File

@@ -0,0 +1,65 @@
# Parent Profile Button
## Application Overview
In parent mode, the top right of the screen should display the parent's profile icon. When clicking this icon, a menu should appear giving options to select.
This is a parent mode only test, and probably should be in it's own bucket of tests
**Profile Button Badge:**
- There are 2 modes for the parent profile button in parent mode: Temporary access and Permanent access.
- In temporary mode, there is no badge overlaying the profile icon button. This mode will revert back to child mode after after 1 minute or a reload of the site (see feat-parent-mode-expire.md)
- In permanent mode, there should be a lock emoji badge over the icon button. This lets the user know that parent mode is active for 2 days. Reloading the page still keeps permanent mode. (see feat-parent-mode-expire.md)
**Profile Button Menu Options:**
When clicking the profile button, we should see the user's selected profile picture, name, and email. Verify those are present. Under this are options.
1. Profile
- This option should take the user to the Edit Profile page. We just need to verify that page is there, testing the page will be for another plan.
2. Child Mode
- Clicking this will take us out of parent mode and back to child mode. This can be verified by no longer having the view-selector navigation tab at the top-middle of the screen.
- In Child mode, clicking the profile button again should prompt us for the parent PIN.
- Entering the correct PIN will take us back into temporary parent mode. It will take us back into permanent parent mode if the checkbox is selected during PIN entry.
3. Sign Out
- This will take us back to the landing page.
---
## Implementation
### Constants
`E2E_FIRST_NAME` and `STORAGE_STATE_TEMP_PARENT` were added to `e2e/e2e-constants.ts`. `E2E_FIRST_NAME` holds the first name of the seeded test user and is used to verify the name appears in the profile menu.
### Auth Setup
`e2e/auth-temp-parent.setup.ts` was created to produce a temporary parent mode storage state (logs in and enters PIN without checking "Stay in parent mode"). It depends on `setup` so it runs after the database is seeded.
### Playwright Projects (`playwright.config.ts`)
Two new isolated projects were added:
- **`chromium-profile-button`** — uses the standard permanent parent mode storage state. Runs all spec files under `e2e/mode_parent/profile-button/`.
- **`chromium-profile-button-temp`** — uses the temporary parent mode storage state. Runs the temporary-mode badge spec only.
Both projects are excluded from the catch-all `chromium-tasks-rewards` project.
### Test Files
**`e2e/mode_parent/profile-button/profile-button.spec.ts`** ✅ IMPLEMENTED
Covers all permanent parent mode scenarios (7 tests):
1. **Badge 🔒 visible in permanent mode**
2. **Menu shows user name and email**
3. **Menu → Profile navigates to User Profile page**
4. **Menu → Child Mode exits parent mode** ✅ (view-selector disappears, button label changes to "Parent login")
5. **Menu → Child Mode re-entry without checkbox enters temporary mode** ✅ (no 🔒 badge after re-entry)
6. **Menu → Child Mode re-entry with checkbox enters permanent mode** ✅ (🔒 badge visible after re-entry)
7. **Menu → Sign Out navigates to landing page** ✅ ("Sign In" nav button visible)
**`e2e/mode_parent/profile-button/profile-button-temporary.spec.ts`** ✅ IMPLEMENTED
Covers the temporary parent mode badge scenario (1 test):
1. **Badge no 🔒 badge in temporary mode** ✅ (enters temp mode by exiting to child mode then re-entering PIN without "Stay" checkbox)

View File

@@ -0,0 +1,113 @@
# Parent Rewards Management
## Application Overview
Focus on parent-mode flows interacting with the Rewards section. A parent must be authenticated via PIN and can create, cancel, edit, delete, and manage default rewards. The scenarios mirror the chore/kindness/penalty plans but target `/parent/rewards` and the reward-specific fields (name, cost, description). All tests run under parent mode in the `mode_parent/rewards` directory. Unique names use a `${Date.now()}` suffix to avoid collisions.
## Test Scenarios
### 1. Create and manage rewards
**Seed:** `e2e/seed.spec.ts`
#### 1.1. Create a new reward
**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards` and verify the default reward list loads (some reward name visible)
2. Click the 'Create Reward' FAB
- expect: The create form appears with 'Reward Name' and 'Cost' fields
- expect: Create button is disabled initially
3. Enter a unique name (e.g. `Toy car ${suffix}`) and `20` in Cost via DOM events
- expect: Create button becomes enabled
4. Click the Create button
- expect: No `.error` elements are shown
- expect: The new reward appears in the list
#### 1.2. Cancel reward creation
**File:** `e2e/mode_parent/rewards/reward-cancel.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards` and click 'Create Reward'
- expect: Create form is displayed with name label visible
2. Fill 'Test' for name and '5' for cost then click 'Cancel'
- expect: User is returned to the rewards list
- expect: No element with text 'Test' exists
#### 1.3. Edit an existing reward
**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards`; create `Toy car ${suffix}` if absent
- expect: The reward appears in the list
2. Click the reward row to open edit form
- expect: Edit form appears with 'Reward Name' label visible
3. Change the name to `Toy boat ${suffix}` and cost to '25' via DOM events
- expect: Save button becomes enabled
4. Click Save
- expect: No errors are displayed
- expect: Updated reward name appears in the list
#### 1.4. Cancel reward edit
**File:** `e2e/mode_parent/rewards/reward-cancel.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards`; ensure `Toy car` exists; click its row
- expect: Edit form is displayed
2. Change the name to 'Should not save' via DOM events then click 'Cancel'
- expect: Rewards list visible again
- expect: `Toy car` is still present
- expect: `Should not save` does not appear
#### 1.5. Convert a default reward to a user item by editing
**File:** `e2e/mode_parent/rewards/reward-convert-default.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards` and locate a default reward such as 'Choose meal'
- expect: Item is visible with no delete button
2. Click the row to open edit form
- expect: Edit form opens with `input#name` value matching the default
3. Change the name to 'Choose meal (custom)' and cost to '35'
- expect: Save button becomes enabled
4. Click Save
- expect: Row now shows a visible delete button
#### 1.6. Delete a reward
**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards`; create a unique reward if necessary
- expect: The reward appears in the list with a delete control
2. Click the delete icon and confirm the dialog
- expect: Item is removed from the list
#### 1.7. Edit default reward cost and verify system restoration on delete
**File:** `e2e/mode_parent/rewards/reward-delete-default.spec.ts`
**Steps:**
1. -
- expect: Navigate to `/parent/rewards`; clean up any user-copy of 'Choose meal' from prior runs
- expect: Verify default 'Choose meal' is present with no delete icon
2. Click 'Choose meal' to open edit form; confirm input#name reads 'Choose meal'; change cost to '50' via DOM events
- expect: Save button becomes enabled
3. Click Save
- expect: Exactly one 'Choose meal' row exists
- expect: Delete icon now visible on that row
- expect: Cost display reads '50 pts'
4. Click delete icon and confirm
- expect: 'Choose meal' remains in list (default restored)
- expect: No delete icon present
- expect: Cost display returns to original value

View File

@@ -0,0 +1,72 @@
# Parent Activate Task Points, and Reward Redemption, Flow
## Application Overview
In parent mode, with a populated list of chores, kindness acts, penalties, and rewards, tests must be run to activate tasks to give a child points, and redeem rewards (remove child points.)
- When the parent activates a chore, a confimation box should appear. Both "Yes" and "Cancel" should be tested. "Yes" will give points to the child. The activated chore should then be disabled and showing the text "Completed" If this chore is activated again, nothing happens.
- When the parent activates a kindness act, a confimation box should appear. Both "Yes" and "Cancel" should be tested. "Yes" will give points to the child. Kindness acts can be activated more than once.
- When the parent activates a negitive task (penalty), a confimation box should appear. Both "Yes" and "Cancel" should be tested. "Yes" will remove points from the child. The points can not be negitive. Penalties can be activated more than once.
- A reward can show how many more points are required to redeem the reward. If a parent activates in this state, nothing happens. When a point can be redeemed, it will have a green text stating that it can be redeemed. When a parent activates this a confirmation dialog appears. Both "Yes" and "Cancel" should be tested. "Yes" removes points from the child. This will cause the rewards status text to be changed in most cases to show how many points are required to redeem.
- Flow: The rows for chores, kindness acts, penalties, and rewards are scrollable. If there are many items in a row, the user can scroll through them.
- Activation: Activation happens with 2 clicks. The first click will center an item in the scroll box, and the next click will activate the item.
- Kebab and Edit button: When a chore is clicked, the top right of the chore will show a kebab icon, when the chore is activated or when somewhere else is clicked, that kebab menu will disappear. No need to test the kebab items yet. On kindness acts, penalties, and rewards, an edit icon will appear in the top right. The behavior is the same as the kebab menu. No need to test what the edit button does yet.
- Points will be displayed in a child card at the top of the screen, as well as the child's name, age, and profile icon.
---
## Implementation Status
All tests live in `e2e/mode_parent/task-activation/` and run under the `chromium-task-activation` Playwright project. **38 tests total — all passing.**
### chore-activation.spec.ts ✅
- Confirmation dialog appears on chore click
- Cancelling the dialog leaves points unchanged
- Confirming the dialog awards the chore's points to the child
- Completed chore shows "Completed" text and is disabled
- Clicking a completed chore a second time does nothing (no dialog, no extra points)
- Kebab menu disappears when a different chore card is clicked
### kindness-activation.spec.ts ✅
- Confirmation dialog appears on kindness act click
- Cancelling the dialog leaves points unchanged
- Confirming the dialog awards points and kindness acts can be triggered more than once (points accumulate)
- Edit button appears on the first click of a kindness card
- Edit button disappears when a different kindness card is clicked
### penalty-activation.spec.ts ✅
- Confirmation dialog appears on penalty click
- Cancelling the dialog leaves points unchanged
- Confirming the dialog deducts points from the child
- Points cannot go below zero (floor at 0)
- Penalties can be activated more than once
- Edit button disappears when a different penalty card is clicked
### reward-redemption.spec.ts ✅
- Reward shows a "points required" status when the child does not have enough points
- Clicking a non-redeemable reward opens no dialog (nothing happens)
- Reward shows a "REWARD READY" status when the child has enough points
- Edit button appears on the first click of a ready reward card
- Edit button disappears when a different reward card is clicked
- Cancelling the redemption dialog leaves points unchanged
- Confirming the dialog deducts the reward cost from the child's points and updates the status text
### scroll-and-flow.spec.ts ✅
- Kindness list is horizontally scrollable when many items are assigned (content width exceeds the viewport and the last card is off-screen)
- Scrolling the list reveals hidden items (last card enters the viewport; first card leaves it)
- First click on an off-screen item scrolls it into view and marks it ready (the `centerItem` animation completes and the card is visible within the viewport)
- Scrolling does not prevent first-click centering on a partially visible item (pre-scrolled list recenters on click)
### pending-reward-warning.spec.ts ✅
- Reward card shows a "PENDING" banner when a child has requested that reward
- Activating a chore while a reward is pending shows a warning dialog; cancelling leaves points and the pending request unchanged
- Activating a chore while a reward is pending shows a warning dialog; confirming cancels the pending request and proceeds to the normal task confirmation
- Activating a kindness act while a reward is pending shows a warning dialog; both cancel and confirm paths verified
- Activating a penalty while a reward is pending shows a warning dialog; both cancel and confirm paths verified
- Activating a different ready reward while a reward request is pending shows a warning dialog; cancelling preserves the pending request; confirming cancels it (the reward confirm dialog does not auto-open — the user must re-click)

View File

@@ -0,0 +1,149 @@
# Parent Assign Chores, Kindness Acts, Penalties, and Rewards to a Child
## Application Overview
Tests for the parent-mode flows where a parent assigns task items (chores, kindness acts, penalties) and rewards to a specific child. Each suite navigates from the child's ParentView (`/parent/:id`) to the relevant assign view and verifies that selections are saved and reflected back in the ParentView.
The parent is authenticated via stored auth state (`e2e/.auth/user.json`). Each spec creates its own test child and items via the API so it is fully isolated from parallel test projects that mutate seed data. All created resources are deleted in `afterAll`. Tests run in `serial` mode within each suite to preserve assignment state across the sequence.
## Playwright Project
**Project name:** `chromium-task-assignment`
**Config:** `playwright.config.ts`
**testMatch:** `/mode_parent/task-assignment/.+\.spec\.ts/`
**Dependencies:** `setup`
**Excluded from:** `chromium-tasks-rewards` (via `testIgnore`)
---
## Test Scenarios
### 1. Chore Assignment ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-assignment/chore-assignment.spec.ts`
**Seed:** Creates child `AssignChoreChild` and tasks `AssignTestChore1/2/3` (type `chore`) via API in `beforeAll`. Deletes all in `afterAll`.
#### 1.1. "Assign Chores" button navigates to the chore assign view ✅
- Click `Assign Chores` from the child's ParentView
- expect: navigates to `/parent/:id/assign-chores` with the heading and test chores listed; nothing pre-checked on a fresh child
#### 1.2. Select chores and submit — items appear in ParentView ✅
- Select `AssignTestChore1` and `AssignTestChore2`, click `Submit`
- expect: returns to ParentView; both chores are visible
#### 1.3. Re-opening assign view shows assigned items as pre-checked ✅
- Navigate back to the assign-chores view
- expect: `AssignTestChore1` and `AssignTestChore2` are pre-checked; `AssignTestChore3` is not
#### 1.4. Uncheck all chores and submit — items are removed from ParentView ✅
- Uncheck both assigned chores, click `Submit`
- expect: returns to ParentView; neither chore is visible
#### 1.5. Cancel does not persist selection changes ✅
- Check `AssignTestChore3`, click `Cancel`, then navigate back to the view
- expect: `AssignTestChore3` is still unchecked
---
### 2. Kindness Act Assignment ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-assignment/kindness-assignment.spec.ts`
**Seed:** Creates child `AssignKindnessChild` and task `AssignTestKindness1` (type `kindness`) via API in `beforeAll`. Deletes all in `afterAll`.
#### 2.1. "Assign Kindness Acts" button navigates to the kindness assign view ✅
- Click `Assign Kindness Acts` from the child's ParentView
- expect: navigates to `/parent/:id/assign-kindness` with the heading and `AssignTestKindness1` listed and unchecked
#### 2.2. Select a kindness act and submit — item appears in ParentView ✅
- Select `AssignTestKindness1`, click `Submit`
- expect: returns to ParentView; item is visible
#### 2.3. Re-opening assign view shows the assigned item as pre-checked ✅
- Navigate back to the assign-kindness view
- expect: `AssignTestKindness1` is pre-checked
#### 2.4. Uncheck the kindness act and submit — item is removed from ParentView ✅
- Uncheck `AssignTestKindness1`, click `Submit`
- expect: returns to ParentView; item is no longer visible
#### 2.5. Cancel does not persist selection changes ✅
- Check `AssignTestKindness1`, click `Cancel`, then navigate back to the view
- expect: `AssignTestKindness1` is still unchecked
---
### 3. Penalty Assignment ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-assignment/penalty-assignment.spec.ts`
**Seed:** Creates child `AssignPenaltyChild` and tasks `AssignTestPenalty1/2/3` (type `penalty`) via API in `beforeAll`. Deletes all in `afterAll`.
#### 3.1. "Assign Penalties" button navigates to the penalty assign view ✅
- Click `Assign Penalties` from the child's ParentView
- expect: navigates to `/parent/:id/assign-penalties` with the heading and test penalties listed and unchecked
#### 3.2. Select penalties and submit — items appear in ParentView ✅
- Select `AssignTestPenalty1` and `AssignTestPenalty2`, click `Submit`
- expect: returns to ParentView; both penalties are visible
#### 3.3. Re-opening assign view shows assigned penalties as pre-checked ✅
- Navigate back to the assign-penalties view
- expect: `AssignTestPenalty1` and `AssignTestPenalty2` are pre-checked; `AssignTestPenalty3` is not
#### 3.4. Uncheck all penalties and submit — items are removed from ParentView ✅
- Uncheck both assigned penalties, click `Submit`
- expect: returns to ParentView; neither penalty is visible
#### 3.5. Cancel does not persist selection changes ✅
- Check `AssignTestPenalty3`, click `Cancel`, then navigate back to the view
- expect: `AssignTestPenalty3` is still unchecked
---
### 4. Reward Assignment ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-assignment/reward-assignment.spec.ts`
**Seed:** Creates child `AssignRewardChild` and rewards `AssignTestReward1/2/3` (cost 10) via API in `beforeAll`. Deletes all in `afterAll`.
#### 4.1. "Assign Rewards" button navigates to the reward assign view ✅
- Click `Assign Rewards` from the child's ParentView
- expect: navigates to `/parent/:id/assign-rewards` with the heading and test rewards listed and unchecked
#### 4.2. Select rewards and submit — items appear in ParentView ✅
- Select `AssignTestReward1` and `AssignTestReward2`, click `Submit`
- expect: returns to ParentView; both rewards are visible
#### 4.3. Re-opening assign view shows assigned rewards as pre-checked ✅
- Navigate back to the assign-rewards view
- expect: `AssignTestReward1` and `AssignTestReward2` are pre-checked; `AssignTestReward3` is not
#### 4.4. Uncheck all rewards and submit — items are removed from ParentView ✅
- Uncheck both assigned rewards, click `Submit`
- expect: returns to ParentView; neither reward is visible
#### 4.5. Cancel does not persist selection changes ✅
- Check `AssignTestReward3`, click `Cancel`, then navigate back to the view
- expect: `AssignTestReward3` is still unchecked

View File

@@ -0,0 +1,239 @@
# Parent Activate Modifies Task (changing points and resetting completed tasks)
## Application Overview
In parent mode, with a populated list of chores, kindness acts, penalties, and rewards, tests must be run to make sure a chore has a kebab menu that lets a parent make modifications to the chore. For kindness acts, penalties, and rewards, parents should be able to click on the edit button and change the amount of points or cost of the item.
This is a continuation of tests from task-assignment.plan.md
**Chores:**
- When the parent clicks on a chore's kebab menu, there should be an item that allows the parent to "Edit Points"
- When the parent clicks on a chore's kebab menu with the chore already in the COMPLETED state, there should be a "Reset" option.
- When a chore's "Edit Points" are clicked, a new dialog should appear with the chore's title, icon, and point value. These points should be editable. The value should not be negitive. If cancelled, nothing happens. If Save is clicked, the chore's point value should match what was entered. There should also be a star emoji that appears on the chore's card on the top right. The maximum value for the points should be 10000. Anything higher, the save button should disable.
- Clicking outside the points dialog should not close it. It should be modal.
- When the parent clicks "Reset" from a completed chore. That chore should become active again. The completed banner should be gone and the chore can be activated again.
- Everytime a resetted chore is activated it should go back to the completed state until reset again.
**Kindness Acts, Penalties, Rewards**
- When the parent clicks on these item's edit button, a new dialog should appear with the item's title, icon, and point/cost value. These points/costs should be editable. The value should not be negitive. If cancelled, nothing happens. If Save is clicked, the item's point value should match what was entered. There should also be a star emoji that appears on the chore's card on the top right. The maximum value for the points should be 10000. Anything higher, the save button should disable.
**Other Tests:**
When a point/cost value is changed, make sure the new value take affect when the parent activates the task. This testing is done in task-assignment.plan.md, but we want to verify the new point values work. There may be some test overlap.
---
## Implementation
Tests are in `e2e/mode_parent/task-modification/`. Each spec creates its own isolated child via the API so all 6 files run in parallel under the `chromium-task-modification` project.
## Playwright Project
**Project name:** `chromium-task-modification`
**Config:** `playwright.config.ts`
**testMatch:** `/mode_parent/task-modification/.+\.spec\.ts/`
**Dependencies:** `setup`
**Excluded from:** `chromium-tasks-rewards` (via `testIgnore`)
---
## Test Scenarios
### 1. Chore Edit Points ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-modification/chore-edit-points.spec.ts`
**Seed:** Creates an isolated child and a chore task via API in `beforeAll`. Deletes all in `afterAll`.
#### 1.1. Kebab button visibility ✅
- Kebab button is hidden initially; becomes visible after the chore card is clicked; hides again when a different card is clicked
#### 1.2. Kebab menu contents ✅
- Menu contains "Edit Points" and "Schedule" options
#### 1.3. Override modal opens pre-populated ✅
- "Edit Points" opens the override modal with the current point value already filled in
#### 1.4. Modal is blocking ✅
- Clicking outside the modal does not close it
#### 1.5. Cancel does not save ✅
- Card still shows original points after Cancel; no ⭐ badge appears
#### 1.6. Save updates displayed points ✅
- Saving a valid new value updates the point display on the card
#### 1.7. ⭐ override badge appears after save ✅
- ⭐ badge appears on the card after saving a custom value
#### 1.8. ⭐ badge persists on revert ✅
- ⭐ badge remains even when the value is changed back to the original default
#### 1.9. Validation — negative and over-limit ✅
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
#### 1.10. Zero is valid ✅
- Card shows "0 Points" with ⭐ badge when zero is saved
---
### 2. Chore Reset ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-modification/chore-reset.spec.ts`
**Seed:** Creates an isolated child and a chore task via API in `beforeAll`. Activates and confirms the chore as needed per test. Deletes all in `afterAll`.
#### 2.1. "Reset" absent before completion ✅
- "Reset" is not present in the kebab menu when the chore is not yet completed
#### 2.2. COMPLETED stamp after activation ✅
- Chore shows COMPLETED stamp after activation and parent confirmation
#### 2.3. "Reset" appears after completion ✅
- "Reset" appears in the kebab menu once the chore is in COMPLETED state
#### 2.4. Reset removes COMPLETED stamp ✅
- Clicking "Reset" removes the COMPLETED stamp and returns the chore to active
#### 2.5. Chore is re-activatable after reset ✅
- After reset, the chore can be activated again and returns to COMPLETED on confirmation
#### 2.6. Reset → activate cycle is repeatable ✅
- The reset and re-activate cycle can be repeated multiple times without error
---
### 3. Kindness Act Edit Points ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-modification/kindness-edit-points.spec.ts`
**Seed:** Creates an isolated child and two kindness act tasks via API in `beforeAll`. Deletes all in `afterAll`.
#### 3.1. Edit button visibility ✅
- Edit button appears on first click of a kindness card; disappears when a different card is clicked
#### 3.2. Override modal opens pre-populated ✅
- Edit button opens modal with the kindness name, "Assign new points" subtitle, and current point value filled in
#### 3.3. Cancel does not save ✅
- Points remain unchanged and no ⭐ badge appears after Cancel
#### 3.4. Save updates displayed points and badge ✅
- Saving a new value updates the displayed points and shows ⭐ badge
#### 3.5. Validation — negative and over-limit ✅
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
---
### 4. Penalty Edit Points ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-modification/penalty-edit-points.spec.ts`
**Seed:** Creates an isolated child and two penalty tasks via API in `beforeAll`. Deletes all in `afterAll`.
#### 4.1. Edit button visibility ✅
- Edit button appears on first click of a penalty card; disappears when a different card is clicked
#### 4.2. Override modal opens pre-populated ✅
- Edit button opens modal with the penalty name, "Assign new points" subtitle, and the raw (positive) point value filled in — not the negated display value
#### 4.3. Cancel does not save ✅
- Display remains unchanged after Cancel
#### 4.4. Save updates card and badge ✅
- Saving a new value updates the card to show the negated override value and ⭐ badge
#### 4.5. Validation — negative and over-limit ✅
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
---
### 5. Reward Edit Cost ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-modification/reward-edit-cost.spec.ts`
**Seed:** Creates an isolated child and a reward (with a pending redemption for warning-dialog tests) via API in `beforeAll`. Deletes all in `afterAll`.
#### 5.1. Edit button visibility ✅
- Edit button appears on first click of a reward card
#### 5.2. Override modal opens pre-populated ✅
- Edit button opens modal with the reward name, "Assign new cost" subtitle, and the current cost filled in
#### 5.3. Cancel does not save ✅
- Points needed display remains unchanged after Cancel
#### 5.4. Save updates cost and badge ✅
- Saving a new cost updates the points needed display and shows ⭐ badge
#### 5.5. Zero cost makes reward immediately redeemable ✅
- Setting cost to 0 marks the reward as immediately redeemable
#### 5.6. Validation — negative and over-limit ✅
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
#### 5.7. Warning dialog for pending redemption ✅
- Editing a reward with an active pending redemption shows a warning dialog; Cancel aborts the edit
#### 5.8. Confirming warning opens override modal ✅
- Confirming the warning dialog proceeds to open the override modal normally
---
### 6. Modified Points Take Effect on Activation ✅ IMPLEMENTED
**File:** `e2e/mode_parent/task-modification/modified-points-activation.spec.ts`
**Seed:** Creates an isolated child, a chore, a kindness act, a penalty, and a reward via API in `beforeAll`. Sets overrides for each. Deletes all in `afterAll`.
#### 6.1. Modified chore points awarded on activation ✅
- Child receives the overridden point value (not the default) when the chore is activated and confirmed
#### 6.2. Modified kindness points awarded on activation ✅
- Child receives the overridden point value when the kindness act is activated and confirmed
#### 6.3. Modified penalty points deducted on activation ✅
- Child loses the overridden point value when the penalty is applied
#### 6.4. Modified reward cost reflected in display ✅
- Points needed shown on the reward card reflects the overridden cost value

Some files were not shown because too many files have changed in this diff Show More