Add end-to-end tests for task modification and assignment features
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m33s

- Implemented tests for editing penalty points and reward costs in `penalty-edit-points.spec.ts` and `reward-edit-cost.spec.ts`.
- Created detailed plans for task activation and assignment scenarios in `task-activated.plan.md` and `task-assignment.plan.md`.
- Added comprehensive test cases for modifying tasks, including editing points for chores, kindness acts, penalties, and rewards in `task-modified.plan.md`.
- Ensured all tests are isolated and run in serial mode to maintain state integrity.
This commit is contained in:
2026-03-17 22:46:27 -04:00
parent b2115ceb57
commit a9131242a7
28 changed files with 3724 additions and 106 deletions

53
.gitignore vendored
View File

@@ -1,50 +1,7 @@
.env
backend/test_data/db/children.json
backend/test_data/db/images.json
backend/test_data/db/pending_rewards.json
backend/test_data/db/rewards.json
backend/test_data/db/tasks.json
backend/test_data/db/users.json
logs/account_deletion.log
backend/test_data/db/tracking_events.json
backend/test_data/
logs/
resources/
frontend/vue-app/playwright-report/index.html
frontend/vue-app/playwright-report/data/7d9e8e8c2aa259dc9ad2c4715b0cf596bf05b8ad.webm
frontend/vue-app/playwright-report/data/53a9d25a05f8605aaf31a0831a88d4d108e65031.png
frontend/vue-app/playwright-report/data/97a02a9005e1cf49de3250991c2dfc24e1845eda.zip
frontend/vue-app/playwright-report/data/278ccf1fa441cc9997d89650beac0252c6bd72c7.zip
frontend/vue-app/playwright-report/data/831e7a25fc01d2aea65ef4b9590141615157afa6.webm
frontend/vue-app/playwright-report/data/12526c507e79af2d09d56299c0bf7a147d27f0c3.md
frontend/vue-app/playwright-report/data/a0602f28b9051f5191f9f9c04caf6e2be1fcf939.zip
frontend/vue-app/playwright-report/data/e8146067f1903cc173e1cc4f5c59b4796fcbb901.zip
frontend/vue-app/playwright-report/trace/codeMirrorModule.DYBRYzYX.css
frontend/vue-app/playwright-report/trace/codicon.DCmgc-ay.ttf
frontend/vue-app/playwright-report/trace/defaultSettingsView.7ch9cixO.css
frontend/vue-app/playwright-report/trace/index.BDwrLSGN.js
frontend/vue-app/playwright-report/trace/index.BVu7tZDe.css
frontend/vue-app/playwright-report/trace/index.html
frontend/vue-app/playwright-report/trace/manifest.webmanifest
frontend/vue-app/playwright-report/trace/playwright-logo.svg
frontend/vue-app/playwright-report/trace/snapshot.html
frontend/vue-app/playwright-report/trace/sw.bundle.js
frontend/vue-app/playwright-report/trace/uiMode.Btcz36p_.css
frontend/vue-app/playwright-report/trace/uiMode.CQJ9SCIQ.js
frontend/vue-app/playwright-report/trace/uiMode.html
frontend/vue-app/playwright-report/trace/xtermModule.DYP7pi_n.css
frontend/vue-app/playwright-report/trace/assets/codeMirrorModule-a5XoALAZ.js
frontend/vue-app/playwright-report/trace/assets/defaultSettingsView-CJSZINFr.js
frontend/vue-app/test-results/.last-run.json
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke/error-context.md
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke/test-failed-1.png
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke/trace.zip
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/error-context.md
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/test-failed-1.png
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/trace.zip
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/video.webm
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke/error-context.md
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke/test-failed-1.png
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke/trace.zip
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/error-context.md
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/test-failed-1.png
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/trace.zip
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/video.webm
frontend/vue-app/playwright-report/
frontend/vue-app/test-results/
backend/test-results/

11
.vscode/launch.json vendored
View File

@@ -33,7 +33,12 @@
"dev"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
"console": "integratedTerminal",
"serverReadyAction": {
"pattern": "Local:.*https://localhost:([0-9]+)",
"uriFormat": "https://localhost:%s",
"action": "debugWithChrome"
}
},
{
"name": "Chrome: Launch (Vue App)",
@@ -80,10 +85,10 @@
"compounds": [
{
"name": "Full Stack (Backend + Frontend)",
"stopAll": true,
"configurations": [
"Python: Flask",
"Vue: Dev Server",
"Chrome: Launch (Vue App)"
"Vue: Dev Server"
]
}
]

20
.vscode/settings.json vendored
View File

@@ -29,5 +29,23 @@
"editor.gotoLocation.multipleDefinitions": "gotoAndPeek",
"editor.gotoLocation.multipleImplementations": "gotoAndPeek",
"editor.gotoLocation.multipleReferences": "gotoAndPeek",
"editor.gotoLocation.multipleTypeDefinitions": "gotoAndPeek"
"editor.gotoLocation.multipleTypeDefinitions": "gotoAndPeek",
//"editor.fontFamily": "Consolas, 'Courier New', monospace",
"editor.fontFamily": "JetBrains Mono",
"editor.fontSize": 13,
"editor.fontLigatures": true,
"squarl.bookmarks": [
{
"name": "seed.spec.ts",
"path": "frontend\\vue-app\\e2e\\seed.spec.ts",
"description": "seed.spec.ts",
"type": "file"
},
{
"name": "file://C:/tmp",
"path": "file://C:/tmp",
"description": "",
"type": "link"
}
]
}

107
.vscode/tasks.json vendored
View File

@@ -1,45 +1,66 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Git: Save WIP",
"type": "shell",
"command": "git save-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Load WIP",
"type": "shell",
"command": "git load-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Reset Cloud WIP",
"type": "shell",
"command": "git push origin --delete wip-sync",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Abort WIP (Reset Local)",
"type": "shell",
"command": "git abort-wip",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true
}
}
]
"version": "2.0.0",
"tasks": [
{
"label": "Git: Save WIP",
"type": "shell",
"command": "git save-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Load WIP",
"type": "shell",
"command": "git load-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Reset Cloud WIP",
"type": "shell",
"command": "git push origin --delete wip-sync",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Abort WIP (Reset Local)",
"type": "shell",
"command": "git abort-wip",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true
}
},
{
"label": "PW: Task Modification Tests",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Task Modification Tests (PS)",
"type": "shell",
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend/vue-app'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Task Modification Tests (cmd)",
"type": "shell",
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\vue-app\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
"isBackground": false,
"group": "test"
}
]
}

View File

@@ -2,17 +2,17 @@
"cookies": [
{
"name": "refresh_token",
"value": "cR3-UAhFv9MEhSxeLcU2J-BVxuhbFH4nBYJ3_ot_ylo",
"value": "13xlv0Lpv2WCL1slwsRXhVfCEBTuzXREnnUYgpfkmRU",
"domain": "localhost",
"path": "/api/auth",
"expires": 1781234351.04593,
"expires": 1781577686.529928,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNWYyNzM5NS0xMGRhLTQzMTQtYjVjNC1jMDIwMmJkYjNiNDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM0NTkyNTF9.cqUw3IjBTkTvRFinuQCZ_DPZJsmfFeTxyGAnKt-AozQ",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjYjFhOTYzNy01ZDRkLTRiYTEtODFjNi0xYmM0MjA4M2MyY2YiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4MDI1ODZ9.jxoB6roEbEMI0NQT3THPC68Q2L-i1gN1KCheJGCUT4U",
"domain": "localhost",
"path": "/",
"expires": -1,
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1773458350728}"
"value": "{\"type\":\"logout\",\"at\":1773801686181}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1773631151242}"
"value": "{\"expiresAt\":1773974486751}"
}
]
}

View File

@@ -11,13 +11,14 @@ test.describe('Reward cancellation', () => {
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(() => {
await page.evaluate((n) => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
@@ -26,13 +27,13 @@ test.describe('Reward cancellation', () => {
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Test')
setVal('#name', n)
setVal('#cost', '5')
})
}, name)
await page.getByRole('button', { name: 'Cancel' }).click()
// verify return to list and no "Test" item
await expect(page.getByText('Test')).toHaveCount(0)
// 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 }) => {
@@ -74,4 +75,4 @@ test.describe('Reward cancellation', () => {
await expect(page.locator('text=Should not save')).toHaveCount(0)
await expect(page.locator(`text=${original}`)).toBeVisible()
})
})
})

View File

@@ -0,0 +1,168 @@
// 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' },
})
})
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,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,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,161 @@
// 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' },
})
})
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,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,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

@@ -10,6 +10,24 @@ test.describe('Default penalty management', () => {
test('Convert a default penalty to a user item by editing', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
// Cleanup: delete any leftover 'Fighting (custom)' from a prior run
while (
(await page
.getByText('Fighting (custom)', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.count()) > 0
) {
await page
.getByText('Fighting (custom)', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.first()
.click()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
}
// locate default penalty
await expect(page.locator('text=Fighting')).toBeVisible()
await expect(

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

View File

@@ -71,6 +71,33 @@ export default defineConfig({
fullyParallel: false,
},
{
// Bucket: task and reward assignment tests for individual children.
// Serial mode within each file ensures assign → verify → unassign → cancel flow order.
name: 'chromium-task-assignment',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
testMatch: [/mode_parent\/task-assignment\/.+\.spec\.ts/],
},
{
// Bucket: task activation and reward redemption tests — each spec creates its own
// isolated child so the 4 files can safely run in parallel within this bucket.
name: 'chromium-task-activation',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
testMatch: [/mode_parent\/task-activation\/.+\.spec\.ts/],
},
{
// Bucket: task modification tests — each spec creates its own isolated child
// so the 6 files can safely run in parallel within this bucket.
name: 'chromium-task-modification',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
testMatch: [/mode_parent\/task-modification\/.+\.spec\.ts/],
},
{
name: 'chromium-tasks-rewards',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
@@ -79,6 +106,9 @@ export default defineConfig({
/\/mode_child\//,
/mode_parent\/(create-child|child-options)\//,
/mode_parent\/tasks\/.*default\.spec\.ts/,
/mode_parent\/task-assignment\//,
/mode_parent\/task-activation\//,
/mode_parent\/task-modification\//,
],
},

View File

@@ -246,6 +246,7 @@ function handleOverrideSet(event: Event) {
// Refresh the appropriate list to show the override badge
if (payload.override.entity_type === 'task') {
childChoreListRef.value?.refresh()
childKindnessListRef.value?.refresh()
childPenaltyListRef.value?.refresh()
} else if (payload.override.entity_type === 'reward') {
childRewardListRef.value?.refresh()
@@ -259,6 +260,7 @@ function handleOverrideDeleted(event: Event) {
// Refresh the appropriate list to remove the override badge
if (payload.entity_type === 'task') {
childChoreListRef.value?.refresh()
childKindnessListRef.value?.refresh()
childPenaltyListRef.value?.refresh()
} else if (payload.entity_type === 'reward') {
childRewardListRef.value?.refresh()