Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
39
frontend/e2e/.auth/user-cc.json
Normal file
39
frontend/e2e/.auth/user-cc.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "oh4sQB_Q1Se4xydZHTIolU6FhvlKPIlDPsxfd_qe4wQ",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1784867824.852037,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI4NGY1Y2I3NS05NWU1LTRjMTItOTExOS02MzdkYjJlMWY1MGYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcwOTI3MjR9.7-d4EvJEYtYSi_5ZFiU7tuxgc4ilVMBxhwzgAQZNTvY",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777092724.851439,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1777091824594}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1777264625262}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
frontend/e2e/.auth/user-delete.json
Normal file
39
frontend/e2e/.auth/user-delete.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "lIhurtvmo0oGcimN_8v59h9y3CrglEAloQO1QFw8RsQ",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1784867824.995155,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiOWYzYTljNWMtM2IwMy00YTZlLTliNmUtYjE0MTQzZWNhMzNkIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3MDkyNzI0fQ.UB5MCIZ2PHbn3UyohNzmFbOCb3TR-KHxJPWB9qkWwCQ",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777092724.99406,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1777091824687}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1777264625511}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
35
frontend/e2e/.auth/user-no-pin.json
Normal file
35
frontend/e2e/.auth/user-no-pin.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "azzLrjadyNjF1jFgenfC4sY2WNd3I6Sk53lLjVgzq6Y",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1781031244.039823,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJhZjkzYTYwYy02MTBjLTQ2ZjUtOTM3NS02ZTZhMTVhNzk5NDIiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzMyNTYxNDR9.Y1weoDtuQDMYO0PrKHGq97s0bV6ldbYO9JwXZCS4jak",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1773255243826}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
35
frontend/e2e/.auth/user-temp-parent.json
Normal file
35
frontend/e2e/.auth/user-temp-parent.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "rwrt2Ui8ecL_ZSoiWpPY5Ahvp-L9Egbegz_FaYn9KA8",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1781620038.006557,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZTc3OTM1Yi1jYThlLTRiOGYtYmFlMi00NGQyNDA5MjVjODAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4NDQ5Mzd9.NHp7pbMRgbO1jamIJBVwlv75OzbTFoOR801C9gTBNEY",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1773844037684}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
frontend/e2e/.auth/user.json
Normal file
39
frontend/e2e/.auth/user.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "uMExObHukEdFnlszorcDq2XlbRW0AeIV71x_vSovEF4",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1784867821.107956,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI2ZWE5YWZmZC1jMGExLTRkMzUtOGJmYS1mZDBkOWY5NzFmOWQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcwOTI3MjF9.rb64zKqS6uXqkgX_4kke37Rc6om68elt4Qa24Fy9NPo",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777092721.107403,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1777091820880}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1777264621275}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/e2e/.resources/crown.png
Normal file
BIN
frontend/e2e/.resources/crown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
42
frontend/e2e/auth-cc.setup.ts
Normal file
42
frontend/e2e/auth-cc.setup.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import { STORAGE_STATE_CC, E2E_CC_EMAIL, E2E_CC_PASSWORD, E2E_CC_PIN } from './e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
setup('authenticate create-child isolated user', async ({ page }) => {
|
||||
// Create the isolated create-child test user (separate from the main E2E user).
|
||||
// This user starts with no children so deleteAllChildren() in the empty-state test
|
||||
// never interferes with concurrent tests running under the main E2E user.
|
||||
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-cc-user`)
|
||||
if (!createRes.ok()) {
|
||||
throw new Error(`e2e-create-cc-user failed: ${createRes.status()} ${await createRes.text()}`)
|
||||
}
|
||||
|
||||
await page.goto('/auth/login')
|
||||
await page.getByLabel('Email address').fill(E2E_CC_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_CC_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await page.waitForURL(/\/(parent|child)/)
|
||||
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_CC_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
|
||||
} catch (e) {
|
||||
await page.screenshot({ path: 'auth-setup-cc-parent-fail.png' })
|
||||
throw new Error(
|
||||
'CC user parent mode not reached after PIN entry. See auth-setup-cc-parent-fail.png for details.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_CC })
|
||||
})
|
||||
47
frontend/e2e/auth-delete.setup.ts
Normal file
47
frontend/e2e/auth-delete.setup.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import {
|
||||
STORAGE_STATE_DELETE,
|
||||
E2E_DELETE_EMAIL,
|
||||
E2E_DELETE_PASSWORD,
|
||||
E2E_DELETE_PIN,
|
||||
} from './e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
setup('authenticate delete user', async ({ page }) => {
|
||||
// Create the isolated delete-test user (separate from the main E2E user)
|
||||
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-delete-user`)
|
||||
if (!createRes.ok()) {
|
||||
throw new Error(
|
||||
`e2e-create-delete-user failed: ${createRes.status()} ${await createRes.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
await page.goto('/auth/login')
|
||||
await page.getByLabel('Email address').fill(E2E_DELETE_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_DELETE_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await page.waitForURL(/\/(parent|child)/)
|
||||
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_DELETE_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
|
||||
} catch (e) {
|
||||
await page.screenshot({ path: 'auth-setup-delete-parent-fail.png' })
|
||||
throw new Error(
|
||||
'Delete user parent mode not reached after PIN entry. See auth-setup-delete-parent-fail.png for details.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_DELETE })
|
||||
})
|
||||
35
frontend/e2e/auth-no-pin.setup.ts
Normal file
35
frontend/e2e/auth-no-pin.setup.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import { STORAGE_STATE_NO_PIN, E2E_EMAIL, E2E_PASSWORD } from './e2e-constants'
|
||||
|
||||
// the default 30s timeout was occasionally exceeded when running the entire
|
||||
// task suite (login redirect could be delayed by other tests). bump it so the
|
||||
// fixture itself never times out during a large run.
|
||||
setup.setTimeout(60_000)
|
||||
|
||||
// Skipping this setup for now — it was timing out repeatedly during large runs.
|
||||
// The parent PIN removal scenario is exercised indirectly in other tests.
|
||||
setup.skip('authenticate without parent pin', async ({ page }) => {
|
||||
await page.goto('/auth/login')
|
||||
|
||||
await page.getByLabel('Email address').fill(E2E_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_PASSWORD)
|
||||
// perform login click and wait for the auth API call rather than relying on a
|
||||
// navigation, which was flaking under the full-suite run and causing
|
||||
// timeouts.
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((res) => res.url().endsWith('/auth/login') && res.status() === 200),
|
||||
page.getByRole('button', { name: 'Sign in' }).click(),
|
||||
])
|
||||
// ensure we actually got a successful login back
|
||||
if (!response.ok()) {
|
||||
throw new Error('Login failed in setup-no-pin')
|
||||
}
|
||||
|
||||
// small sanity wait for some element that only appears after auth
|
||||
await page.waitForSelector('button[aria-label="Parent menu"]', { timeout: 30000 })
|
||||
|
||||
// Remove parent auth from localStorage so the PIN prompt appears
|
||||
await page.evaluate(() => localStorage.removeItem('parentAuth'))
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_NO_PIN })
|
||||
})
|
||||
44
frontend/e2e/auth.setup.ts
Normal file
44
frontend/e2e/auth.setup.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import { STORAGE_STATE, E2E_EMAIL, E2E_PASSWORD, E2E_PIN } from './e2e-constants'
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Seed backend test data
|
||||
const backendUrl = 'http://localhost:5000'
|
||||
const seedRes = await page.request.post(`${backendUrl}/auth/e2e-seed`)
|
||||
if (!seedRes.ok()) {
|
||||
throw new Error(`e2e-seed failed: ${seedRes.status()} ${await seedRes.text()}`)
|
||||
}
|
||||
|
||||
await page.goto('/auth/login')
|
||||
await page.getByLabel('Email address').fill(E2E_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
// After login the router redirects to /child (not parent-authenticated yet)
|
||||
await page.waitForURL(/\/(parent|child)/)
|
||||
|
||||
// Click the LoginButton in the header to open the PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
// Fill in the PIN and submit
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// LoginButton does router.push('/parent') after PIN - wait for it
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// Confirm parent mode is active by waiting for the Add Child FAB at /parent
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
|
||||
} catch (e) {
|
||||
await page.screenshot({ path: 'auth-setup-parent-fail.png' })
|
||||
throw new Error(
|
||||
'Parent mode not reached after PIN entry. See auth-setup-parent-fail.png for details.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE })
|
||||
})
|
||||
15
frontend/e2e/e2e-constants.ts
Normal file
15
frontend/e2e/e2e-constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const STORAGE_STATE = 'e2e/.auth/user.json'
|
||||
export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
|
||||
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
|
||||
export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json'
|
||||
export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json'
|
||||
export const E2E_EMAIL = 'e2e@test.com'
|
||||
export const E2E_PASSWORD = 'E2eTestPass1!'
|
||||
export const E2E_PIN = '1234'
|
||||
export const E2E_FIRST_NAME = 'E2E'
|
||||
export const E2E_DELETE_EMAIL = 'e2e-delete@test.com'
|
||||
export const E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
|
||||
export const E2E_DELETE_PIN = '5678'
|
||||
export const E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||
export const E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||
export const E2E_CC_PIN = '3456'
|
||||
18
frontend/e2e/example.spec.ts
Normal file
18
frontend/e2e/example.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test('get started link', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole('link', { name: 'Get started' }).click();
|
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
});
|
||||
54
frontend/e2e/mode_parent/child-options/delete-child.spec.ts
Normal file
54
frontend/e2e/mode_parent/child-options/delete-child.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age } })
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const data = await listRes.json()
|
||||
const child = (data.children ?? []).find((c: any) => c.name === name)
|
||||
return child?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Child kebab menu – Delete Child', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const CHILD_NAME = 'KebabDelete'
|
||||
let childId = ''
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Best-effort cleanup in case the test did not delete the child
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
childId = ''
|
||||
})
|
||||
|
||||
test('Confirm deletes the child from the list', async ({ page, request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
await page.goto('/parent')
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await card.getByRole('button', { name: 'Delete Child' }).click()
|
||||
|
||||
await expect(page.getByText('Are you sure you want to delete this child?')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).not.toBeVisible()
|
||||
childId = '' // already deleted, skip afterEach cleanup
|
||||
})
|
||||
|
||||
test('Cancel does not delete the child', async ({ page, request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
await page.goto('/parent')
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await card.getByRole('button', { name: 'Delete Child' }).click()
|
||||
|
||||
await expect(page.getByText('Are you sure you want to delete this child?')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByText('Are you sure you want to delete this child?')).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
})
|
||||
})
|
||||
40
frontend/e2e/mode_parent/child-options/delete-points.spec.ts
Normal file
40
frontend/e2e/mode_parent/child-options/delete-points.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age } })
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const data = await listRes.json()
|
||||
const child = (data.children ?? []).find((c: any) => c.name === name)
|
||||
return child?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Child kebab menu – Delete Points', () => {
|
||||
const CHILD_NAME = 'KebabPoints'
|
||||
let childId = ''
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: 50 } })
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
childId = ''
|
||||
})
|
||||
|
||||
test('Delete Points resets child points to 0', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
)
|
||||
|
||||
await card.getByRole('button', { name: 'Delete Points' }).click()
|
||||
|
||||
await expect(card.getByText('Points: 0')).toBeVisible()
|
||||
})
|
||||
})
|
||||
40
frontend/e2e/mode_parent/child-options/edit-child.spec.ts
Normal file
40
frontend/e2e/mode_parent/child-options/edit-child.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age } })
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const data = await listRes.json()
|
||||
const child = (data.children ?? []).find((c: any) => c.name === name)
|
||||
return child?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Child kebab menu – Edit Child', () => {
|
||||
const CHILD_NAME = 'KebabEdit'
|
||||
let childId = ''
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
childId = ''
|
||||
})
|
||||
|
||||
test('Edit Child menu item navigates to the child editor', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
|
||||
|
||||
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
)
|
||||
|
||||
await card.getByRole('button', { name: 'Edit Child' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/[^/]+\/edit/)
|
||||
await expect(page.getByRole('heading', { name: 'Edit Child' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 6: Child Mode Chore Visibility & States
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
choreSection,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ChildCardChild'
|
||||
const CHILD_ALWAYS = 'ChildAlwaysChore'
|
||||
const CHILD_TODAY = 'ChildTodayChore'
|
||||
const CHILD_NOT_TODAY = 'ChildNotTodayChore'
|
||||
const CHILD_EXPIRED = 'ChildExpiredChore'
|
||||
const CHILD_ANYTIME = 'ChildAnytimeChore'
|
||||
|
||||
test.describe('Child Mode — Chore Visibility and Schedule States', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let alwaysId = ''
|
||||
let todayId = ''
|
||||
let notTodayId = ''
|
||||
let expiredId = ''
|
||||
let anytimeId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
alwaysId = await createChoreTask(request, CHILD_ALWAYS)
|
||||
todayId = await createChoreTask(request, CHILD_TODAY)
|
||||
notTodayId = await createChoreTask(request, CHILD_NOT_TODAY)
|
||||
expiredId = await createChoreTask(request, CHILD_EXPIRED)
|
||||
anytimeId = await createChoreTask(request, CHILD_ANYTIME)
|
||||
|
||||
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// ChildTodayChore: scheduled for today, deadline 23:59
|
||||
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
|
||||
|
||||
// ChildNotTodayChore: scheduled for a non-today day
|
||||
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
// ChildExpiredChore: scheduled for today, deadline 00:01 (guaranteed expired)
|
||||
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||
|
||||
// ChildAnytimeChore: scheduled for today, no deadline
|
||||
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
|
||||
|
||||
// ChildAlwaysChore: no schedule
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('6.1 Chore with no schedule — visible normally in child mode', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_ALWAYS)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('6.2 Chore scheduled for today — visible with "Due by" label', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_TODAY)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM')
|
||||
})
|
||||
|
||||
test('6.3 Chore not scheduled for today — HIDDEN in child mode', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
|
||||
// Wait for the chores section to fully load (uses other cards as anchor)
|
||||
const alwaysCard = choreCard(page, CHILD_ALWAYS)
|
||||
await expect(alwaysCard).toBeVisible()
|
||||
|
||||
// Off-day chore must not appear at all
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: CHILD_NOT_TODAY }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('6.4 Chore with expired deadline — grayed out with TOO LATE badge (not hidden)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_EXPIRED)
|
||||
|
||||
// Expired chore is still visible (was scheduled for today)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
})
|
||||
|
||||
test('6.5 Chore with "Anytime" — visible, no badge, no "Due by"', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_ANYTIME)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('6.6 No kebab menu or schedule controls in child mode', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHILD_TODAY)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Click the card — no Options button should appear
|
||||
await card.click()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
121
frontend/e2e/mode_parent/chore-scheduler/extend-time.spec.ts
Normal file
121
frontend/e2e/mode_parent/chore-scheduler/extend-time.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 8: Extend Time
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
openChoreOptions,
|
||||
todayDayIndex,
|
||||
todayISO,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ExtendTimeChild'
|
||||
const EXPIRED_NAME = 'ExtendTimeChore'
|
||||
const FUTURE_NAME = 'FutureDeadlineChore'
|
||||
|
||||
test.describe('Extend Time', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let expiredId = ''
|
||||
let futureId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
expiredId = await createChoreTask(request, EXPIRED_NAME)
|
||||
futureId = await createChoreTask(request, FUTURE_NAME)
|
||||
|
||||
await assignChore(request, childId, [expiredId, futureId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
|
||||
// ExpiredChore: scheduled for today, deadline at 00:01 (guaranteed past)
|
||||
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||
|
||||
// FutureChore: scheduled for today, deadline at 23:59 (not expired)
|
||||
await setDaysSchedule(request, childId, futureId, [today], { hour: 23, minute: 59 })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (expiredId) await request.delete(`/api/task/${expiredId}`)
|
||||
if (futureId) await request.delete(`/api/task/${futureId}`)
|
||||
})
|
||||
|
||||
test('8.1 "Extend Time" appears in kebab menu only when chore is expired', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Expired chore shows TOO LATE badge
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Extend Time' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('8.2 "Extend Time" is absent when chore is not expired', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, FUTURE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.3 Clicking "Extend Time" removes the TOO LATE badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
|
||||
await openChoreOptions(page, card)
|
||||
await page.getByRole('button', { name: 'Extend Time' }).click()
|
||||
|
||||
// After extension the badge disappears and card is no longer grayed out
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('8.4 Extended chore no longer shows "Due by" label', async ({ page }) => {
|
||||
// Card was extended in 8.3 so this verifies the extension persists
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.5 "Extend Time" disappears from kebab after extension', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Chore was extended in 8.3 — should no longer be expired
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.6 Extension effect visible in child mode', async ({ page }) => {
|
||||
// After extending in parent mode (done in 8.3), verify child view
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, EXPIRED_NAME)
|
||||
|
||||
// Chore is still visible (was scheduled for today + extended) and no TOO LATE badge
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('8.6b Extending same chore twice via API returns 409', async ({ request }) => {
|
||||
// The chore was already extended in 8.3; a second API extension should fail
|
||||
const res = await request.post(`/api/child/${childId}/task/${expiredId}/extend`, {
|
||||
data: { date: todayISO() },
|
||||
})
|
||||
expect(res.status()).toBe(409)
|
||||
})
|
||||
})
|
||||
233
frontend/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
233
frontend/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// shared helpers for chore-scheduler spec files
|
||||
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
// ─── Date Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function todayISO(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function yesterdayISO(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function futureDateISO(daysAhead = 7): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + daysAhead)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Today's weekday (0 = Sunday, 6 = Saturday) — matches DayConfig.day */
|
||||
export function todayDayIndex(): number {
|
||||
return new Date().getDay()
|
||||
}
|
||||
|
||||
/** A weekday guaranteed not to be today */
|
||||
export function nonTodayDayIndex(): number {
|
||||
return (new Date().getDay() + 1) % 7
|
||||
}
|
||||
|
||||
// ─── API Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (
|
||||
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
export async function createChoreTask(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (
|
||||
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
export async function assignChore(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
choreIds: string[],
|
||||
): Promise<void> {
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
}
|
||||
|
||||
export interface DaysScheduleOpts {
|
||||
hour?: number
|
||||
minute?: number
|
||||
hasDeadline?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export async function setDaysSchedule(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
days: number[],
|
||||
opts: DaysScheduleOpts = {},
|
||||
): Promise<void> {
|
||||
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
|
||||
const day_configs = days.map((day) => ({ day, hour, minute }))
|
||||
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs,
|
||||
default_hour: hour,
|
||||
default_minute: minute,
|
||||
default_has_deadline: hasDeadline,
|
||||
enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface IntervalScheduleOpts {
|
||||
hour?: number
|
||||
minute?: number
|
||||
hasDeadline?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export async function setIntervalSchedule(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
intervalDays: number,
|
||||
anchorDate: string,
|
||||
opts: IntervalScheduleOpts = {},
|
||||
): Promise<void> {
|
||||
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
|
||||
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: intervalDays,
|
||||
anchor_date: anchorDate,
|
||||
interval_has_deadline: hasDeadline,
|
||||
interval_hour: hour,
|
||||
interval_minute: minute,
|
||||
enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSchedule(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
// 404 is acceptable (no schedule existed)
|
||||
await request.delete(`/api/child/${childId}/task/${taskId}/schedule`)
|
||||
}
|
||||
|
||||
// ─── UI Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Locator for the Chores section */
|
||||
export function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Locator for a named chore card in the Chores section */
|
||||
export function choreCard(page: Page, name: string): Locator {
|
||||
return choreSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({
|
||||
has: page.locator('.item-name').getByText(name, { exact: true }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Click the chore card (select it), wait for ready state, then click Options */
|
||||
export async function openChoreOptions(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
}
|
||||
|
||||
/** Open the schedule modal for a named chore card */
|
||||
export async function openScheduleModal(page: Page, card: Locator): Promise<void> {
|
||||
await openChoreOptions(page, card)
|
||||
await page.getByRole('button', { name: 'Schedule' }).click()
|
||||
await expect(page.getByText('Schedule Chore')).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a TimePickerPopover to a specific time.
|
||||
* @param timePicker - the `.time-picker-popover` locator wrapping the popover
|
||||
*/
|
||||
export async function setTimePicker(
|
||||
page: Page,
|
||||
timePicker: Locator,
|
||||
hour12: number,
|
||||
minute: number,
|
||||
period: 'AM' | 'PM',
|
||||
): Promise<void> {
|
||||
await timePicker.locator('.time-display').click()
|
||||
// Scope panel to this specific time picker to avoid strict-mode violations
|
||||
const panel = timePicker.locator('.popover-panel')
|
||||
await panel.waitFor()
|
||||
// Use exact regex patterns — getByRole name uses substring matching by default,
|
||||
// so "3" would also match "30" and "1" would match "10", "11", "12" etc.
|
||||
await panel.getByRole('button', { name: new RegExp(`^${period}$`) }).click()
|
||||
await panel.getByRole('button', { name: new RegExp(`^${hour12}$`) }).click()
|
||||
await panel
|
||||
.getByRole('button', { name: new RegExp(`^${String(minute).padStart(2, '0')}$`) })
|
||||
.click()
|
||||
// Close popover by clicking the modal title (outside popover bounds)
|
||||
await page.locator('.modal-title').click()
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/** Set the hidden date input and fire a change event */
|
||||
export async function setDateInput(page: Page, isoDate: string): Promise<void> {
|
||||
await page.locator('input.date-input-hidden').evaluate((el: HTMLInputElement, val: string) => {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, isoDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the child view for a given child.
|
||||
* Parent-authenticated users are blocked from /child by the auth guard, so we
|
||||
* must clear the parentAuth localStorage key before navigating there.
|
||||
*/
|
||||
export async function goToChildView(page: Page, childId: string): Promise<void> {
|
||||
// Go to the app domain first so we can access its localStorage
|
||||
await page.goto(`/parent/${childId}`)
|
||||
// Clear the parent auth token — router will now allow /child/:id
|
||||
await page.evaluate(() => localStorage.removeItem('parentAuth'))
|
||||
// Navigate to child view and wait for the chore section to appear
|
||||
await page.goto(`/child/${childId}`)
|
||||
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate (back) to the parent view from any page.
|
||||
* Restores the parentAuth localStorage key so the router allows /parent/:id.
|
||||
*/
|
||||
export async function goToParentView(page: Page, childId: string): Promise<void> {
|
||||
// Restore parent auth so the router guard allows /parent/:id
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'parentAuth',
|
||||
JSON.stringify({ expiresAt: Date.now() + 2 * 24 * 60 * 60 * 1000 }),
|
||||
)
|
||||
})
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
|
||||
}
|
||||
113
frontend/e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts
Normal file
113
frontend/e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 9: Interval Mode Anchor Date Logic
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setIntervalSchedule,
|
||||
choreCard,
|
||||
choreSection,
|
||||
todayISO,
|
||||
yesterdayISO,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'IntervalAnchorChild'
|
||||
const CHORE_NAME = 'IntervalAnchorChore'
|
||||
|
||||
test.describe('Schedule Interval Mode — Anchor Date Logic', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test('9.1 Interval every 1 day starting today — chore active today', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
})
|
||||
|
||||
// Parent mode: not grayed out
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, CHORE_NAME)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: visible
|
||||
await goToChildView(page, childId)
|
||||
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('9.2 Interval every 2 days starting yesterday — chore NOT active today', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// anchor=yesterday, interval=2: yesterday+2=tomorrow → today is skipped
|
||||
await setIntervalSchedule(request, childId, choreId, 2, yesterdayISO(), {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
})
|
||||
|
||||
// Parent mode: grayed out (not hitting today)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, CHORE_NAME)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: hidden (not scheduled for today)
|
||||
await goToChildView(page, childId)
|
||||
// Wait for list to finish loading — the chore should be filtered out
|
||||
await expect(choreSection(page).locator('.empty, .scroll-wrapper')).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('9.3 Interval every 2 days starting today — active today', async ({ page, request }) => {
|
||||
// anchor=today, interval=2: diffDays=0, 0%2=0 → hits today
|
||||
await setIntervalSchedule(request, childId, choreId, 2, todayISO(), {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
})
|
||||
|
||||
// Parent mode: not grayed out
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, CHORE_NAME)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: visible
|
||||
await goToChildView(page, childId)
|
||||
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('9.4 Interval "Anytime" deadline — no "Due by" label', async ({ page, request }) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
|
||||
hasDeadline: false,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Anytime = no due label
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 5: Parent Mode Chore Card Schedule States
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ParentCardChild'
|
||||
const ALWAYS_ACTIVE = 'AlwaysActiveChore'
|
||||
const SCHEDULED_TODAY = 'ScheduledTodayChore'
|
||||
const NOT_SCHEDULED_TODAY = 'NotScheduledTodayChore'
|
||||
const EXPIRED_CHORE = 'ExpiredChore'
|
||||
const ANYTIME_CHORE = 'AnytimeChore'
|
||||
|
||||
test.describe('Parent Mode — Chore Card Schedule States', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let alwaysId = ''
|
||||
let todayId = ''
|
||||
let notTodayId = ''
|
||||
let expiredId = ''
|
||||
let anytimeId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
alwaysId = await createChoreTask(request, ALWAYS_ACTIVE)
|
||||
todayId = await createChoreTask(request, SCHEDULED_TODAY)
|
||||
notTodayId = await createChoreTask(request, NOT_SCHEDULED_TODAY)
|
||||
expiredId = await createChoreTask(request, EXPIRED_CHORE)
|
||||
anytimeId = await createChoreTask(request, ANYTIME_CHORE)
|
||||
|
||||
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// ScheduledTodayChore: scheduled for today, deadline 23:59 (far future)
|
||||
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
|
||||
|
||||
// NotScheduledTodayChore: scheduled for a non-today day
|
||||
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
// ExpiredChore: scheduled for today, deadline 00:01 (guaranteed past)
|
||||
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||
|
||||
// AnytimeChore: scheduled for today, no deadline
|
||||
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
|
||||
|
||||
// AlwaysActiveChore: no schedule (already default)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('5.1 Chore with no schedule — normal appearance, no annotations', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, ALWAYS_ACTIVE)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.chore-stamp')).not.toBeVisible()
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('5.2 Chore scheduled for today — shows "Due by" label, not grayed out', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, SCHEDULED_TODAY)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('5.3 Chore not scheduled for today — grayed out in parent mode but visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, NOT_SCHEDULED_TODAY)
|
||||
await card.waitFor()
|
||||
|
||||
// Always visible in parent mode
|
||||
await expect(card).toBeVisible()
|
||||
// Grayed out because off-day
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
// No TOO LATE badge (not expired — just off-day)
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('5.4 Chore with expired deadline — grayed out with TOO LATE badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_CHORE)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
})
|
||||
|
||||
test('5.5 Chore with "Anytime" deadline — no "Due by" label, no TOO LATE badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, ANYTIME_CHORE)
|
||||
await card.waitFor()
|
||||
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('5.6 Kebab menu always accessible on grayed-out chores', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, NOT_SCHEDULED_TODAY)
|
||||
await card.waitFor()
|
||||
|
||||
// Grayed out card still has Options button after clicking
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||
|
||||
// Schedule modal opens normally
|
||||
await page.getByRole('button', { name: 'Schedule' }).click()
|
||||
await expect(page.getByText('Schedule Chore')).toBeVisible()
|
||||
})
|
||||
|
||||
test('5.7 Expired chore cannot be triggered by tapping — no confirm dialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, EXPIRED_CHORE)
|
||||
await card.waitFor()
|
||||
|
||||
// First click selects the card even when expired
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
|
||||
// Second click attempts to trigger — but expired chores are blocked in triggerTask()
|
||||
await card.click()
|
||||
// Confirm dialog should NOT appear for expired chores
|
||||
await expect(page.locator('.modal-dialog')).not.toBeVisible({ timeout: 1000 })
|
||||
})
|
||||
})
|
||||
130
frontend/e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts
Normal file
130
frontend/e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 7: Parent vs Child Mode Comparison
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
choreSection,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'CompareChild'
|
||||
const VISIBLE_BOTH = 'VisibleBothChore'
|
||||
const PARENT_ONLY = 'ParentOnlyChore'
|
||||
|
||||
test.describe('Parent vs Child Mode Comparison', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let visibleBothId = ''
|
||||
let parentOnlyId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
visibleBothId = await createChoreTask(request, VISIBLE_BOTH)
|
||||
parentOnlyId = await createChoreTask(request, PARENT_ONLY)
|
||||
|
||||
await assignChore(request, childId, [visibleBothId, parentOnlyId])
|
||||
|
||||
const today = todayDayIndex()
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// VisibleBothChore: scheduled for today (visible everywhere)
|
||||
await setDaysSchedule(request, childId, visibleBothId, [today], { hour: 23, minute: 59 })
|
||||
|
||||
// ParentOnlyChore: scheduled for a non-today day (grayed/hidden)
|
||||
await setDaysSchedule(request, childId, parentOnlyId, [nonToday], { hour: 8, minute: 0 })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (visibleBothId) await request.delete(`/api/task/${visibleBothId}`)
|
||||
if (parentOnlyId) await request.delete(`/api/task/${parentOnlyId}`)
|
||||
})
|
||||
|
||||
test('7.1 Parent mode shows all chores including off-day ones', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const visibleCard = choreCard(page, VISIBLE_BOTH)
|
||||
const parentOnlyCard = choreCard(page, PARENT_ONLY)
|
||||
|
||||
await visibleCard.waitFor()
|
||||
await parentOnlyCard.waitFor()
|
||||
|
||||
// Both visible in parent mode
|
||||
await expect(visibleCard).toBeVisible()
|
||||
await expect(parentOnlyCard).toBeVisible()
|
||||
|
||||
// parentOnly is grayed out (off-day), visible is not
|
||||
await expect(parentOnlyCard).toHaveClass(/chore-inactive/)
|
||||
await expect(visibleCard).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('7.2 Child mode hides off-day chores', async ({ page }) => {
|
||||
await goToChildView(page, childId)
|
||||
|
||||
const visibleCard = choreCard(page, VISIBLE_BOTH)
|
||||
await expect(visibleCard).toBeVisible()
|
||||
|
||||
// Off-day chore is hidden entirely
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: PARENT_ONLY }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('7.3 Adding a schedule via parent mode hides chore in child mode on wrong day', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const nonToday = nonTodayDayIndex()
|
||||
|
||||
// Remove today's schedule from VisibleBothChore and set it to non-today
|
||||
await setDaysSchedule(request, childId, visibleBothId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
await goToChildView(page, childId)
|
||||
|
||||
// VisibleBothChore is now off-day — should be hidden in child mode
|
||||
await expect(
|
||||
choreSection(page).locator('.item-card').filter({ hasText: VISIBLE_BOTH }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('7.4 Removing a schedule via parent mode shows chore in child mode again', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Delete the schedule via API (acts as if saved with no days)
|
||||
await deleteSchedule(request, childId, visibleBothId)
|
||||
|
||||
await goToChildView(page, childId)
|
||||
|
||||
// No schedule = always visible in child mode
|
||||
const card = choreCard(page, VISIBLE_BOTH)
|
||||
await expect(card).toBeVisible()
|
||||
})
|
||||
|
||||
test('7.5 Paused schedule — chore visible in both modes', async ({ page, request }) => {
|
||||
// Re-add a non-today schedule, but paused (enabled=false)
|
||||
await setDaysSchedule(request, childId, parentOnlyId, [nonTodayDayIndex()], {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
// Parent mode: visible and NOT grayed out (paused = acts unscheduled)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const parentCard = choreCard(page, PARENT_ONLY)
|
||||
await parentCard.waitFor()
|
||||
await expect(parentCard).toBeVisible()
|
||||
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Child mode: also visible (paused = shows every day)
|
||||
await goToChildView(page, childId)
|
||||
const childCard = choreCard(page, PARENT_ONLY)
|
||||
await expect(childCard).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 2: Schedule Modal Specific Days Mode
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
setTimePicker,
|
||||
nonTodayDayIndex,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleDaysChild'
|
||||
const CHORE_NAME = 'ScheduleDaysChore'
|
||||
|
||||
test.describe('Schedule Modal — Specific Days mode', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('2.1 Clicking day chips toggles them active/inactive', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
const moChip = page.locator('.day-chips').getByRole('button', { name: 'Mo' })
|
||||
await moChip.click()
|
||||
await expect(moChip).toHaveClass(/active/)
|
||||
|
||||
await moChip.click()
|
||||
await expect(moChip).not.toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('2.2 Default deadline row appears when at least one day is selected', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await expect(page.locator('.default-deadline-row')).not.toBeVisible()
|
||||
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
|
||||
await expect(page.locator('.default-deadline-row')).toBeVisible()
|
||||
await expect(page.locator('.default-deadline-row')).toContainText('Deadline')
|
||||
})
|
||||
|
||||
test('2.3 Default deadline "Anytime" toggle hides and restores time picker', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select a day to show the deadline row
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
|
||||
await expect(page.locator('.default-deadline-row')).toBeVisible()
|
||||
|
||||
// Time picker is visible by default
|
||||
await expect(
|
||||
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||
).toBeVisible()
|
||||
|
||||
// Click "Clear (Anytime)" — time picker hides, "Anytime" label appears
|
||||
await page
|
||||
.locator('.default-deadline-row')
|
||||
.getByRole('button', { name: 'Clear (Anytime)' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||
).not.toBeVisible()
|
||||
await expect(page.locator('.default-deadline-row').getByText('Anytime')).toBeVisible()
|
||||
|
||||
// Click "Set deadline" — time picker reappears
|
||||
await page
|
||||
.locator('.default-deadline-row')
|
||||
.getByRole('button', { name: 'Set deadline' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('2.4 Exception time — "Set different time" creates per-day override', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select Monday and Wednesday
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
|
||||
|
||||
// Wednesday row: click "Set different time"
|
||||
const weRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
|
||||
})
|
||||
await weRow.getByRole('button', { name: 'Set different time' }).click()
|
||||
|
||||
// Wednesday now shows its own time picker
|
||||
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
|
||||
|
||||
// Monday row still shows the default label (no time picker override)
|
||||
const moRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
|
||||
})
|
||||
await expect(moRow.locator('.default-label')).toBeVisible()
|
||||
await expect(moRow.locator('.time-picker-popover')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('2.5 Exception time — "Reset to default" removes the override', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Set up Monday + Wednesday, add Wednesday exception
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
|
||||
const weRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
|
||||
})
|
||||
await weRow.getByRole('button', { name: 'Set different time' }).click()
|
||||
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
|
||||
|
||||
// Reset Wednesday to default
|
||||
await weRow.getByRole('button', { name: 'Reset to default' }).click()
|
||||
await expect(weRow.locator('.default-label')).toBeVisible()
|
||||
await expect(weRow.locator('.time-picker-popover')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('2.6 Save with specific days — schedule persists on reopen', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select Monday and Friday
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Fr' }).click()
|
||||
|
||||
// Set deadline to 03:00 PM
|
||||
const timePicker = page.locator('.default-deadline-row').locator('.time-picker-popover')
|
||||
await setTimePicker(page, timePicker, 3, 0, 'PM')
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
|
||||
'03:00 PM',
|
||||
)
|
||||
})
|
||||
|
||||
test('2.7 Save with zero days deletes the schedule', async ({ page, request }) => {
|
||||
// Seed: create a days schedule so the modal opens with existing days
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 9, minute: 0 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Deselect all active chips
|
||||
const chips = page.locator('.day-chips').getByRole('button')
|
||||
for (const chip of await chips.all()) {
|
||||
const cls = await chip.getAttribute('class')
|
||||
if (cls?.includes('active')) await chip.click()
|
||||
}
|
||||
|
||||
// Save (removing schedule)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify no days selected
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
for (const chip of await page.locator('.day-chips').getByRole('button').all()) {
|
||||
await expect(chip).not.toHaveClass(/active/)
|
||||
}
|
||||
})
|
||||
|
||||
test('2.8 Dirty detection — changing days enables Save', async ({ page, request }) => {
|
||||
const nonToday = nonTodayDayIndex()
|
||||
await setDaysSchedule(request, childId, choreId, [nonToday], { hour: 8, minute: 0 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save should be disabled (nothing changed yet)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
// Toggle a different day chip → dirty
|
||||
const otherDay = (nonToday + 1) % 7
|
||||
const dayLabels = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
await page.locator('.day-chips').getByRole('button', { name: dayLabels[otherDay] }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 4: Schedule Enable/Disable Toggle
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setDaysSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
goToChildView,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleToggleChild'
|
||||
const CHORE_NAME = 'ScheduleToggleChore'
|
||||
|
||||
test.describe('Schedule Enable/Disable Toggle', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('4.1 Toggle row is visible in the schedule modal', async ({ page, request }) => {
|
||||
// Seed a schedule so the modal has something to show
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await expect(page.locator('.schedule-toggle-row')).toBeVisible()
|
||||
await expect(page.getByRole('switch')).toBeVisible()
|
||||
await expect(page.locator('.toggle-label')).toBeVisible()
|
||||
})
|
||||
|
||||
test('4.2 Toggling OFF shows "Paused" label and dims the form body', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle is ON initially
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Toggle OFF
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
|
||||
// Form body dims
|
||||
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
|
||||
|
||||
// Cancel/Save buttons remain interactive
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('4.3 Toggling ON restores "Enabled" label and form body', async ({ page, request }) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle OFF then back ON
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
await page.getByRole('switch').click()
|
||||
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||
await expect(page.locator('.schedule-body')).not.toHaveClass(/disabled/)
|
||||
})
|
||||
|
||||
test('4.4 Save paused state — persists on reopen', async ({ page, request }) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle OFF and save
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
|
||||
})
|
||||
|
||||
test('4.5 Dirty detection — toggling enabled state enables/disables Save', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save is disabled initially
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
// Toggle OFF → dirty
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
|
||||
// Toggle back ON → not dirty again
|
||||
await page.getByRole('switch').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('4.6 Paused schedule in parent mode — chore is visible and not grayed out', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Schedule for a non-today weekday, but paused (enabled=false)
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Chore is visible
|
||||
await expect(card).toBeVisible()
|
||||
// Paused = acts like unscheduled = not grayed out
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
})
|
||||
|
||||
test('4.7 Paused schedule in child mode — chore is visible', async ({ page, request }) => {
|
||||
// Non-today schedule, paused
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
|
||||
|
||||
await goToChildView(page, childId)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
// Paused = isScheduledToday returns true → chore is visible in child mode
|
||||
await expect(card).toBeVisible()
|
||||
})
|
||||
|
||||
test('4.8 Paused schedule shows no "Due by" label', async ({ page, request }) => {
|
||||
// Schedule for today with a deadline, but paused
|
||||
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], {
|
||||
hour: 23,
|
||||
minute: 59,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// Paused = getDueTimeToday returns null → no "Due by" label
|
||||
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 3: Schedule Modal Every X Days Mode
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setIntervalSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
setTimePicker,
|
||||
setDateInput,
|
||||
todayISO,
|
||||
futureDateISO,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleIntervalChild'
|
||||
const CHORE_NAME = 'ScheduleIntervalChore'
|
||||
|
||||
test.describe('Schedule Modal — Every X Days mode', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('3.1 Interval stepper increments and decrements within 1–7', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Default value is 1
|
||||
await expect(page.locator('.stepper-value')).toHaveText('1')
|
||||
|
||||
// Decrement is disabled at 1
|
||||
const decBtn = page.locator('.stepper').getByRole('button').first()
|
||||
await expect(decBtn).toBeDisabled()
|
||||
|
||||
// Increment 6 times → value becomes 7
|
||||
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||
for (let i = 0; i < 6; i++) await incBtn.click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('7')
|
||||
|
||||
// Increment is disabled at 7
|
||||
await expect(incBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
test("3.2 Anchor date field shows today's date by default", async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
await expect(page.locator('input.date-input-hidden')).toHaveValue(todayISO())
|
||||
})
|
||||
|
||||
test('3.3 Next occurrence preview label updates correctly', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Set interval to 2 (click + once from default 1)
|
||||
await page.locator('.stepper').getByRole('button').last().click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('2')
|
||||
|
||||
// Ensure anchor is today
|
||||
await setDateInput(page, todayISO())
|
||||
|
||||
// Next occurrence row should be visible
|
||||
await expect(page.locator('.next-occurrence-label')).toBeVisible()
|
||||
await expect(page.locator('.next-occurrence-label')).toContainText('Next occurrence:')
|
||||
})
|
||||
|
||||
test('3.4 Interval deadline "Anytime" toggle works', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Time picker visible by default
|
||||
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
|
||||
|
||||
// Click "Clear (Anytime)"
|
||||
await page
|
||||
.locator('.interval-time-row')
|
||||
.getByRole('button', { name: 'Clear (Anytime)' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('.interval-time-row').locator('.time-picker-popover'),
|
||||
).not.toBeVisible()
|
||||
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
|
||||
|
||||
// Click "Set deadline" — time picker reappears
|
||||
await page.locator('.interval-time-row').getByRole('button', { name: 'Set deadline' }).click()
|
||||
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
|
||||
})
|
||||
|
||||
test('3.5 Save interval schedule — persists on reopen', async ({ page }) => {
|
||||
const anchor = futureDateISO(60) // 60 days ahead — well past today min constraint
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
|
||||
// Set interval to 3 (click + twice from default 1)
|
||||
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||
await incBtn.click()
|
||||
await incBtn.click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('3')
|
||||
|
||||
// Set anchor date
|
||||
await setDateInput(page, anchor)
|
||||
|
||||
// Set deadline to 04:30 PM
|
||||
const timePicker = page.locator('.interval-time-row').locator('.time-picker-popover')
|
||||
await setTimePicker(page, timePicker, 4, 30, 'PM')
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||
await expect(page.locator('.stepper-value')).toHaveText('3')
|
||||
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchor)
|
||||
await expect(page.locator('.interval-time-row').locator('.time-display')).toHaveText('04:30 PM')
|
||||
})
|
||||
|
||||
test('3.6 Dirty detection — changing interval enables Save', async ({ page, request }) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 3, futureDateISO(30))
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save is disabled (no changes yet)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
// Change interval from 3 to 5 (click + twice)
|
||||
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||
await incBtn.click()
|
||||
await incBtn.click()
|
||||
await expect(page.locator('.stepper-value')).toHaveText('5')
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
171
frontend/e2e/mode_parent/chore-scheduler/schedule-load.spec.ts
Normal file
171
frontend/e2e/mode_parent/chore-scheduler/schedule-load.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 11: Schedule Loading / Backward Compatibility
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
setIntervalSchedule,
|
||||
choreCard,
|
||||
openScheduleModal,
|
||||
futureDateISO,
|
||||
nonTodayDayIndex,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'LoadCompatChild'
|
||||
const CHORE_NAME = 'LoadCompatChore'
|
||||
|
||||
test.describe('Schedule Loading — Backward Compatibility', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('11.1 Schedule without "enabled" field defaults to enabled in modal', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Create schedule via raw API call — omit the `enabled` field
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
// enabled intentionally omitted — backend defaults to true
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Toggle should default to "Enabled"
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||
})
|
||||
|
||||
test('11.2 Schedule with interval_has_deadline=false shows "Anytime" in modal', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setIntervalSchedule(request, childId, choreId, 2, futureDateISO(30), {
|
||||
hasDeadline: false,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// "Every X Days" mode is active
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||
|
||||
// Anytime label is shown instead of time picker
|
||||
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.interval-time-row').locator('.time-picker-popover'),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('11.3 Days schedule with per-day exception loads deadline rows correctly', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Monday=08:00, Wednesday=08:00 (default), Friday=10:30 (exception)
|
||||
// Use raw API to set up mixed schedule
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [
|
||||
{ day: 1, hour: 8, minute: 0 }, // Monday
|
||||
{ day: 3, hour: 8, minute: 0 }, // Wednesday
|
||||
{ day: 5, hour: 10, minute: 30 }, // Friday — exception from default 08:00
|
||||
],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Monday, Wednesday, Friday chips should be active
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'We' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
|
||||
// Default deadline shows 08:00 AM
|
||||
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
|
||||
'08:00 AM',
|
||||
)
|
||||
|
||||
// Friday has its own time picker (exception) — "Reset to default" button visible
|
||||
const frRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Friday' }),
|
||||
})
|
||||
await expect(frRow.getByRole('button', { name: 'Reset to default' })).toBeVisible()
|
||||
await expect(frRow.locator('.time-display')).toHaveText('10:30 AM')
|
||||
|
||||
// Monday has no exception — shows default label
|
||||
const moRow = page.locator('.exception-row').filter({
|
||||
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
|
||||
})
|
||||
await expect(moRow.locator('.default-label')).toBeVisible()
|
||||
})
|
||||
|
||||
test('11.4 Interval schedule restores anchor date and interval on reopen', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const anchorDate = '2026-06-15'
|
||||
const intervalDays = 3
|
||||
|
||||
await setIntervalSchedule(request, childId, choreId, intervalDays, anchorDate, {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// "Every X Days" mode is active
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||
|
||||
// Stepper shows 3
|
||||
await expect(page.locator('.stepper-value')).toHaveText(`${intervalDays}`)
|
||||
|
||||
// Date input has the correct anchor date
|
||||
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchorDate)
|
||||
})
|
||||
})
|
||||
129
frontend/e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts
Normal file
129
frontend/e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 1: Schedule Modal Opening & Structure
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
deleteSchedule,
|
||||
choreCard,
|
||||
openChoreOptions,
|
||||
openScheduleModal,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'ScheduleModalChild'
|
||||
const CHORE_NAME = 'ScheduleModalChore'
|
||||
|
||||
test.describe('Schedule Modal — Opening & Structure', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('1.1 Schedule option appears in kebab menu', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openChoreOptions(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('1.2 Schedule modal opens with correct title and subtitle', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.locator('.modal-title')).toHaveText('Schedule Chore')
|
||||
await expect(page.locator('.modal-subtitle')).toHaveText(CHORE_NAME)
|
||||
})
|
||||
|
||||
test('1.3 Default state: Specific Days mode, no chips selected, toggle Enabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Specific Days mode button is active
|
||||
await expect(page.getByRole('button', { name: 'Specific Days' })).toHaveClass(/active/)
|
||||
await expect(page.getByRole('button', { name: 'Every X Days' })).not.toHaveClass(/active/)
|
||||
|
||||
// No day chips are active
|
||||
const chips = page.locator('.day-chips').getByRole('button')
|
||||
for (const chip of await chips.all()) {
|
||||
await expect(chip).not.toHaveClass(/active/)
|
||||
}
|
||||
|
||||
// Toggle is ON and shows "Enabled"
|
||||
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||
})
|
||||
|
||||
test('1.4 Mode toggle switches between Specific Days and Every X Days', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Switch to Every X Days
|
||||
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||
await expect(page.locator('.interval-form')).toBeVisible()
|
||||
await expect(page.locator('.days-form')).not.toBeVisible()
|
||||
|
||||
// Switch back to Specific Days
|
||||
await page.getByRole('button', { name: 'Specific Days' }).click()
|
||||
await expect(page.locator('.days-form')).toBeVisible()
|
||||
await expect(page.locator('.interval-form')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('1.5 Cancel closes the modal without saving', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Select Monday chip
|
||||
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
|
||||
// Cancel — modal closes
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh to reset card ready-state, then reopen modal
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).not.toHaveClass(
|
||||
/active/,
|
||||
)
|
||||
})
|
||||
|
||||
test('1.6 Save is disabled when form is not dirty', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await openScheduleModal(page, card)
|
||||
|
||||
// Save button is disabled — no changes made
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// spec: e2e/plans/chore-scheduler.plan.md — Section 10: SSE Real-Time Updates
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
createChild,
|
||||
createChoreTask,
|
||||
assignChore,
|
||||
setDaysSchedule,
|
||||
deleteSchedule,
|
||||
choreCard,
|
||||
todayDayIndex,
|
||||
nonTodayDayIndex,
|
||||
todayISO,
|
||||
} from './helpers'
|
||||
|
||||
const CHILD_NAME = 'SSEScheduleChild'
|
||||
const CHORE_NAME = 'SSEScheduleChore'
|
||||
|
||||
test.describe('SSE Real-Time Updates', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createChoreTask(request, CHORE_NAME)
|
||||
await assignChore(request, childId, [choreId])
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteSchedule(request, childId, choreId)
|
||||
})
|
||||
|
||||
test('10.1 Setting a schedule via API reflects in an already-open parent view', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
|
||||
// No schedule: card should be visible and not grayed out
|
||||
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||
|
||||
// Set a non-today schedule via API (triggers SSE chore_schedule_modified)
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
|
||||
|
||||
// SSE fires → card should become grayed out (chore-inactive)
|
||||
await expect(card).toHaveClass(/chore-inactive/, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.2 Deleting a schedule via API un-grays the chore in parent view', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Seed a non-today schedule first
|
||||
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await expect(card).toHaveClass(/chore-inactive/)
|
||||
|
||||
// Delete schedule via API
|
||||
await request.delete(`/api/child/${childId}/task/${choreId}/schedule`)
|
||||
|
||||
// SSE fires → card returns to normal
|
||||
await expect(card).not.toHaveClass(/chore-inactive/, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.3 Extending time via API reflects in an already-open parent view', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Set today schedule with past deadline (chore starts expired)
|
||||
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], { hour: 0, minute: 1 })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreCard(page, CHORE_NAME)
|
||||
await card.waitFor()
|
||||
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||
|
||||
// Extend via API
|
||||
await request.post(`/api/child/${childId}/task/${choreId}/extend`, {
|
||||
data: { date: todayISO() },
|
||||
})
|
||||
|
||||
// SSE chore_time_extended fires → TOO LATE badge disappears
|
||||
await expect(card.getByText('TOO LATE')).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
16
frontend/e2e/mode_parent/create-child/authorization.spec.ts
Normal file
16
frontend/e2e/mode_parent/create-child/authorization.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { STORAGE_STATE_NO_PIN } from '../../e2e-constants'
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_NO_PIN })
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test('Add Child FAB is hidden when parent auth is expired', async ({ page }) => {
|
||||
// Navigate to app root - with no parent auth, router redirects to /child
|
||||
await page.goto('/')
|
||||
|
||||
// expect: the 'Add Child' FAB is NOT visible (not in parent mode)
|
||||
await expect(page.getByRole('button', { name: 'Add Child' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
163
frontend/e2e/mode_parent/create-child/happy-path.spec.ts
Normal file
163
frontend/e2e/mode_parent/create-child/happy-path.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||
|
||||
async function deleteNamedChildren(request: any, names: string[]) {
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
if (names.includes(child.name)) {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllChildren(request: any) {
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
// Serial mode: the 'empty state' test calls deleteAllChildren() which would
|
||||
// race against sibling tests creating children if they ran in parallel.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Create a child with name and age only', async ({ page, request }) => {
|
||||
await deleteNamedChildren(request, ['Alice'])
|
||||
|
||||
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// 2. Click the 'Add Child' FAB
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
await expect(nameInput).toBeVisible()
|
||||
await expect(ageInput).toBeVisible()
|
||||
|
||||
// Submit should be disabled until all required fields are valid.
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
// 3. Enter 'Alice' in the Name field
|
||||
await nameInput.fill('Alice')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
// 4. Enter '8' in the Age field
|
||||
await ageInput.fill('8')
|
||||
|
||||
// 5. Leave Image as default and click Create
|
||||
// Use toPass() to handle SSE-triggered form resets from parallel tests
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
await deleteNamedChildren(request, ['Alice'])
|
||||
await nameInput.fill('Alice')
|
||||
await ageInput.fill('8')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page.locator('.error')).not.toBeVisible()
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Alice')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Create a child via the inline Create button in empty state', async ({ page, request }) => {
|
||||
// 1. Navigate to empty state and click inline Create.
|
||||
// Retry-loop handles SSE-triggered re-renders: a parallel test may add a child
|
||||
// between deleteAllChildren() and the click, detaching the empty-state button.
|
||||
await expect(async () => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('No children')).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole('button', { name: 'Create' }).click({ timeout: 5000 })
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible({
|
||||
timeout: 5000,
|
||||
})
|
||||
}).toPass({ timeout: 30000 })
|
||||
|
||||
// 2. Enter 'Bob' and '10', then submit
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
// Submit should be disabled until all required fields are valid
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await nameInput.fill('Bob')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await ageInput.fill('10')
|
||||
|
||||
// Handle async form initialization race and SSE-triggered form resets
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
await deleteNamedChildren(request, ['Bob'])
|
||||
await nameInput.fill('Bob')
|
||||
await ageInput.fill('10')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page.locator('.error')).not.toBeVisible()
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Bob')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Create a child with a custom uploaded image', async ({ page, request }) => {
|
||||
await deleteNamedChildren(request, ['Grace'])
|
||||
|
||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
|
||||
// 2. Enter 'Grace' and '6'
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
// Submit should be disabled until all required fields are valid
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await nameInput.fill('Grace')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await ageInput.fill('6')
|
||||
|
||||
// 3. Upload a local image file via 'Add from device'
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('button', { name: 'Add from device' }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(TEST_IMAGE)
|
||||
|
||||
// Handle async form initialization race and SSE-triggered form resets
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
await deleteNamedChildren(request, ['Grace'])
|
||||
await nameInput.fill('Grace')
|
||||
await ageInput.fill('6')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page.locator('.error')).not.toBeVisible()
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Grace')).toBeVisible()
|
||||
})
|
||||
})
|
||||
21
frontend/e2e/mode_parent/create-child/navigation.spec.ts
Normal file
21
frontend/e2e/mode_parent/create-child/navigation.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test('Cancel navigates back without saving', async ({ page }) => {
|
||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
|
||||
// 2. Fill in 'Frank' and '9', then click Cancel
|
||||
await page.getByLabel('Name').fill('Frank')
|
||||
await page.getByLabel('Age').fill('9')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// expect: back on /parent and 'Frank' is NOT listed
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByText('Frank')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
108
frontend/e2e/mode_parent/create-child/sse.spec.ts
Normal file
108
frontend/e2e/mode_parent/create-child/sse.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('New child appears in list without page reload', async ({ page, context, request }) => {
|
||||
// Clean up 'Hannah' before test
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
if (child.name === 'Hannah') {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Open two browser tabs both on /parent (children list)
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
const tab2 = await context.newPage()
|
||||
await tab2.goto('/')
|
||||
await expect(tab2).toHaveURL('/parent')
|
||||
|
||||
// 2. In Tab 1, create child 'Hannah' age '4'
|
||||
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
// Clean up any Hannah created in a previous attempt where navigation was cancelled
|
||||
const lr = await request.get('/api/child/list')
|
||||
for (const c of (await lr.json()).children ?? []) {
|
||||
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await page.getByLabel('Name').fill('Hannah')
|
||||
await page.getByLabel('Age').fill('4')
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
// 3. Tab 2 should show 'Hannah' via SSE without a manual refresh
|
||||
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
await tab2.close()
|
||||
})
|
||||
|
||||
test('New child appears in child mode list without page reload', async ({
|
||||
page,
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
// Clean up 'Hannah' before test
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = await res.json()
|
||||
for (const child of data.children ?? []) {
|
||||
if (child.name === 'Hannah') {
|
||||
await request.delete(`/api/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Tab 1: parent mode
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
|
||||
const childContext = await browser.newContext({ storageState: STORAGE_STATE_CC })
|
||||
const tab2 = await childContext.newPage()
|
||||
|
||||
// Load the app first (to ensure localStorage is seeded from storageState),
|
||||
// then remove parentAuth so the next navigation boots in child mode.
|
||||
await tab2.goto('/')
|
||||
await tab2.evaluate(() => localStorage.removeItem('parentAuth'))
|
||||
|
||||
// Full navigation triggers a fresh Vue init — auth store reads no parentAuth
|
||||
// so the router allows /child
|
||||
await tab2.goto('/child')
|
||||
await expect(tab2).toHaveURL('/child')
|
||||
|
||||
// 3. In Tab 1, create child 'Hannah' age '4'
|
||||
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
// Clean up any Hannah created in a previous attempt where navigation was cancelled
|
||||
const lr = await request.get('/api/child/list')
|
||||
for (const c of (await lr.json()).children ?? []) {
|
||||
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await page.getByLabel('Name').fill('Hannah')
|
||||
await page.getByLabel('Age').fill('4')
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
// 4. Tab 2 (child mode) should show 'Hannah' via SSE without a manual refresh
|
||||
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
|
||||
|
||||
await childContext.close()
|
||||
})
|
||||
})
|
||||
88
frontend/e2e/mode_parent/create-child/validation.spec.ts
Normal file
88
frontend/e2e/mode_parent/create-child/validation.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// spec: e2e/plans/create-child.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Create Child', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
|
||||
// Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reject submission when Name is empty', async ({ page }) => {
|
||||
// 2. Leave Name empty, enter '7' in Age - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Age').fill('7')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Reject submission when Name is whitespace only', async ({ page }) => {
|
||||
// 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill(' ')
|
||||
await page.getByLabel('Age').fill('7')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Reject submission when Age is empty', async ({ page }) => {
|
||||
// 2. Enter 'Charlie', clear Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Charlie')
|
||||
await page.getByLabel('Age').clear()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Reject negative age', async ({ page }) => {
|
||||
// 2. Enter 'Dave', enter '-1' - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill('Dave')
|
||||
await page.getByLabel('Age').fill('-1')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
|
||||
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
// Use toPass() to handle SSE-triggered form resets from parallel tests
|
||||
const FULL_NAME = 'A'.repeat(64)
|
||||
await expect(async () => {
|
||||
if (new URL(page.url()).pathname === '/parent') return
|
||||
// Clean up any previously-created 64-A child in case prev attempt created but nav was cancelled
|
||||
const lr = await request.get('/api/child/list')
|
||||
for (const c of (await lr.json()).children ?? []) {
|
||||
if (c.name === FULL_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
// Fill 65 chars — HTML maxlength=64 truncates to 64
|
||||
await nameInput.fill('A'.repeat(65))
|
||||
// Verify truncation via non-retrying read (throws if SSE cleared the field)
|
||||
const truncated = await nameInput.inputValue()
|
||||
if (truncated !== FULL_NAME)
|
||||
throw new Error(`maxlength truncation failed: got "${truncated}"`)
|
||||
await ageInput.fill('5')
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click({ timeout: 2000 })
|
||||
await expect(page).toHaveURL('/parent', { timeout: 5000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
})
|
||||
|
||||
test('Reject age greater than 120', async ({ page }) => {
|
||||
// 2. Enter 'Eve', enter '121' in Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Eve')
|
||||
await page.getByLabel('Age').fill('121')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §2
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ChoreNotifApproveChild'
|
||||
const CHORE_NAME = 'ChoreNotifApproveChore'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chore Notification — Appearance and In-App Approval', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let pointsBeforeApproval = 0
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Set a daily interval schedule so the chore persists post-approval
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Confirm the chore to create a pending notification
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 2.1 Confirmed chore appears in the notification view
|
||||
test('Confirmed chore appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText('completed')
|
||||
})
|
||||
|
||||
// 2.2 Notification item displays the child's name
|
||||
test('Notification item displays the child name', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText(CHILD_NAME)
|
||||
})
|
||||
|
||||
// 2.3 Clicking notification navigates to ParentView with correct query params
|
||||
test('Clicking notification navigates to ParentView with correct query params', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${choreId}`)
|
||||
expect(page.url()).toContain('entityType=chore')
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 2.4 Pending chore card shows PENDING stamp in ParentView
|
||||
test('Pending chore card shows PENDING stamp in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await expect(card).toContainText('PENDING')
|
||||
})
|
||||
|
||||
// 2.5 Clicking a pending chore card opens ChoreApproveDialog
|
||||
test('Clicking a pending chore card opens ChoreApproveDialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 2.6 Approving via ChoreApproveDialog removes the PENDING stamp
|
||||
test('Approving via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
// Capture points before approval for test 2.8 verification
|
||||
pointsBeforeApproval = await getPoints(page)
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const approveBtn = page.getByRole('button', { name: 'Approve' })
|
||||
await expect(approveBtn).toBeVisible({ timeout: 5000 })
|
||||
await approveBtn.click()
|
||||
await expect(approveBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
})
|
||||
|
||||
// 2.7 Approving a chore clears it from the notification view
|
||||
test('Approving a chore clears it from notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 2.8 Approving a chore awards points to the child
|
||||
test('Approving a chore awards points to the child', async ({ page }) => {
|
||||
// The chore was approved in test 2.6. Verify the points were updated correctly.
|
||||
// (Cannot re-confirm as the chore was already approved today.)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const currentPoints = await getPoints(page)
|
||||
expect(currentPoints).toBe(pointsBeforeApproval + CHORE_POINTS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §3
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ChoreNotifDenyChild'
|
||||
const CHORE_NAME = 'ChoreNotifDenyChore'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chore Notification — In-App Rejection', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Set a daily interval schedule so the chore persists post-rejection
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 3.1 Confirmed chore appears in the notification view
|
||||
test('Confirmed chore appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 3.2 ChoreApproveDialog shows both Approve and Reject buttons
|
||||
test('ChoreApproveDialog shows both Approve and Reject buttons', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 3.3 Rejecting via ChoreApproveDialog removes the PENDING stamp
|
||||
test('Rejecting via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const rejectBtn = page.getByRole('button', { name: 'Reject' })
|
||||
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
|
||||
await rejectBtn.click()
|
||||
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
})
|
||||
|
||||
// 3.4 Rejecting a chore clears it from the notification view
|
||||
test('Rejecting a chore clears it from notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 3.5 Rejecting a chore does not award points
|
||||
test('Rejecting a chore does not award points', async ({ page, request }) => {
|
||||
// Re-confirm the chore for this test
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const before = await getPoints(page)
|
||||
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const rejectBtn = page.getByRole('button', { name: 'Reject' })
|
||||
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
|
||||
await rejectBtn.click()
|
||||
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §6
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page, type Locator } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DeepLinkChild'
|
||||
const CHORE_PREFIX = 'DeepLinkChore'
|
||||
const CHORE_COUNT = 10
|
||||
const REWARD_NAME = 'DeepLinkReward'
|
||||
const REWARD_COST = 20
|
||||
const INITIAL_POINTS = 100
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'deep link test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('ParentView Deep-Link Navigation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const choreIds: string[] = []
|
||||
let targetChoreId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
|
||||
for (let i = 1; i <= CHORE_COUNT; i++) {
|
||||
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
|
||||
}
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
// Target the last chore (most likely off-screen)
|
||||
targetChoreId = choreIds[CHORE_COUNT - 1]
|
||||
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: targetChoreId },
|
||||
})
|
||||
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of choreIds) await request.delete(`/api/task/${id}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 6.1 Deep link to chore — correct card is scrolled into viewport
|
||||
test('Deep link to chore scrolls correct card into viewport', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=chore`)
|
||||
const card = choreSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
|
||||
// 6.2 Deep link to reward — correct card is scrolled into viewport
|
||||
test('Deep link to reward scrolls correct card into viewport', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}?scrollTo=${rewardId}&entityType=reward`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
|
||||
// 6.3 Deep link with unknown entityType — page loads without JS error
|
||||
test('Deep link with unknown entityType loads without JS error', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=unknown`)
|
||||
await page.waitForTimeout(500)
|
||||
expect(errors).toHaveLength(0)
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 6.4 Deep link with missing scrollTo — page loads normally
|
||||
test('Deep link with missing scrollTo loads normally', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}?entityType=chore`)
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 6.5 Deep link with no query params — page loads normally
|
||||
test('Deep link with no query params loads normally', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.locator('h3', { hasText: 'Rewards' })).toBeVisible()
|
||||
})
|
||||
|
||||
// 6.6 Clicking a chore notification produces the correct deep link
|
||||
test('Clicking a chore notification produces correct deep link', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${targetChoreId}`)
|
||||
expect(page.url()).toContain('entityType=chore')
|
||||
const card = choreSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
|
||||
// 6.7 Clicking a reward notification produces the correct deep link
|
||||
test('Clicking a reward notification produces correct deep link', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${rewardId}`)
|
||||
expect(page.url()).toContain('entityType=reward')
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await isInViewport(card)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — approve chore
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestApproveChoreChild'
|
||||
const CHORE_NAME = 'DigestApproveChoreChore'
|
||||
const CHORE_POINTS = 8
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Approve Chore', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let approveToken = ''
|
||||
|
||||
test.beforeAll(async ({ request, playwright }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
// Set interval schedule so chore persists post-approval
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
// Obtain a valid approve-chore token via the E2E test helper
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
approveToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 7.1 Approve-chore token returns 302 redirect to correct ParentView URL
|
||||
test('Approve-chore token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain(`scrollTo=${choreId}`)
|
||||
expect(location).toContain('entityType=chore')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.2 Approve-chore action is performed: pending confirmation removed, points awarded
|
||||
test('Approve-chore action is performed: points awarded and confirmation removed', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${approveToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get(`/api/child/list`)
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
expect(child.points).toBeGreaterThanOrEqual(CHORE_POINTS)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === choreId,
|
||||
)
|
||||
// Should be no pending (status == 'pending') confirmation
|
||||
expect(pending.filter((c: any) => c.status === 'pending')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — approve reward
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestApproveRewardChild'
|
||||
const REWARD_NAME = 'DigestApproveRewardReward'
|
||||
const REWARD_COST = 15
|
||||
const INITIAL_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'digest approve test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Approve Reward', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
let approveToken = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: rewardId,
|
||||
entity_type: 'reward',
|
||||
action: 'approve',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
approveToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 7.5 Approve-reward token returns 302 redirect to correct ParentView URL
|
||||
test('Approve-reward token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain(`scrollTo=${rewardId}`)
|
||||
expect(location).toContain('entityType=reward')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.6 Approve-reward action: reward triggered, child points deducted
|
||||
test('Approve-reward action: reward triggered and child points deducted', async ({ request }) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${approveToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get('/api/child/list')
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
expect(child.points).toBe(INITIAL_POINTS - REWARD_COST)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
|
||||
)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — deny chore
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestDenyChoreChild'
|
||||
const CHORE_NAME = 'DigestDenyChoreChore'
|
||||
const CHORE_POINTS = 8
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Deny Chore', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let denyToken = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'deny',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
denyToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 7.3 Deny-chore token returns 302 redirect to correct ParentView URL
|
||||
test('Deny-chore token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain('entityType=chore')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.4 Deny-chore action is performed: pending confirmation removed, no points awarded
|
||||
test('Deny-chore action is performed: confirmation removed, points unchanged', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${denyToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get(`/api/child/list`)
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
// No points awarded for rejection
|
||||
expect(child.points).toBe(0)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === choreId && c.status === 'pending',
|
||||
)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §7 — deny reward
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestDenyRewardChild'
|
||||
const REWARD_NAME = 'DigestDenyRewardReward'
|
||||
const REWARD_COST = 15
|
||||
const INITIAL_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'digest deny test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Deny Reward', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
let denyToken = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: rewardId,
|
||||
entity_type: 'reward',
|
||||
action: 'deny',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
denyToken = (await tokenRes.json()).token
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 7.7 Deny-reward token returns 302 redirect to correct ParentView URL
|
||||
test('Deny-reward token returns 302 redirect to correct ParentView URL', async ({
|
||||
playwright,
|
||||
}) => {
|
||||
const unauthCtx = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(302)
|
||||
const location = res.headers()['location'] ?? ''
|
||||
expect(location).toContain(`/parent/${childId}`)
|
||||
expect(location).toContain('entityType=reward')
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 7.8 Deny-reward action: pending request removed, points unchanged
|
||||
test('Deny-reward action: pending request removed and points unchanged', async ({ request }) => {
|
||||
// Execute the action via authenticated POST (GET only redirects, POST consumes the token)
|
||||
const execRes = await request.post(`/api/digest-action/${denyToken}`)
|
||||
expect(execRes.status()).toBe(200)
|
||||
|
||||
const childRes = await request.get('/api/child/list')
|
||||
const children = (await childRes.json()).children ?? []
|
||||
const child = children.find((c: any) => c.id === childId)
|
||||
expect(child).toBeTruthy()
|
||||
// Points should be unchanged (deny doesn't deduct anything)
|
||||
expect(child.points).toBe(INITIAL_POINTS)
|
||||
|
||||
const pendingRes = await request.get('/api/pending-confirmations')
|
||||
const confirmations = (await pendingRes.json()).confirmations ?? []
|
||||
const pending = confirmations.filter(
|
||||
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
|
||||
)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §8
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'DigestErrorChild'
|
||||
const CHORE_NAME = 'DigestErrorChore'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getUnauthContext(playwright: any) {
|
||||
return playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
baseURL: 'https://localhost:5173',
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Digest Action Token — Error Paths', () => {
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
// 8.1 Expired token returns 400
|
||||
test('Expired token returns 400', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
expires_in_hours: -1,
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
const expiredToken = (await tokenRes.json()).token
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
|
||||
expect(res.status()).toBe(400)
|
||||
expect(res.headers()['location']).toBeUndefined()
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 8.2 Already-used token returns 400 on second use
|
||||
test('Already-used token returns 400 on second use', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'deny',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
const token = (await tokenRes.json()).token
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
|
||||
// First use — authenticated POST executes and consumes the token
|
||||
const first = await request.post(`/api/digest-action/${token}`)
|
||||
expect(first.status()).toBe(200)
|
||||
|
||||
// Second use — token is consumed, unauthenticated GET should return 400
|
||||
const second = await unauthCtx.get(`/api/digest-action/${token}`, { maxRedirects: 0 })
|
||||
expect(second.status()).toBe(400)
|
||||
await unauthCtx.dispose()
|
||||
|
||||
// Re-create a pending chore confirmation for subsequent tests
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
// 8.3 Tampered token returns 400
|
||||
test('Tampered token returns 400', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
},
|
||||
})
|
||||
expect(tokenRes.status()).toBe(200)
|
||||
const validToken: string = (await tokenRes.json()).token
|
||||
|
||||
// Tamper: replace the last character
|
||||
const lastChar = validToken[validToken.length - 1]
|
||||
const replacement = lastChar === 'a' ? 'b' : 'a'
|
||||
const tamperedToken = validToken.slice(0, -1) + replacement
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get(`/api/digest-action/${tamperedToken}`, { maxRedirects: 0 })
|
||||
expect(res.status()).toBe(400)
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 8.4 Completely fake token returns 400
|
||||
test('Completely fake token returns 400', async ({ playwright }) => {
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get('/api/digest-action/this-token-does-not-exist', {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(res.status()).toBe(400)
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
|
||||
// 8.5 Error response is a plain HTML page with no redirect
|
||||
test('Error response is plain HTML with no Location header', async ({ request, playwright }) => {
|
||||
const tokenRes = await request.post('/api/admin/test/digest-token', {
|
||||
data: {
|
||||
child_id: childId,
|
||||
entity_id: choreId,
|
||||
entity_type: 'chore',
|
||||
action: 'approve',
|
||||
expires_in_hours: -1,
|
||||
},
|
||||
})
|
||||
const expiredToken = (await tokenRes.json()).token
|
||||
|
||||
const unauthCtx = await getUnauthContext(playwright)
|
||||
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
|
||||
expect(res.status()).toBe(400)
|
||||
const contentType = res.headers()['content-type'] ?? ''
|
||||
expect(contentType).toContain('text/html')
|
||||
expect(res.headers()['location']).toBeUndefined()
|
||||
await unauthCtx.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §4
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'NotifOverflowChild'
|
||||
const LONG_CHORE_NAME = 'This Is A Very Long Chore Name That Should Not Overflow The Card Boundary'
|
||||
const CHORE_POINTS = 5
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Notification card overflow', () => {
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, LONG_CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Confirm chore to create a pending notification
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
})
|
||||
|
||||
test('Notification text does not overflow card boundaries', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: LONG_CHORE_NAME })
|
||||
await expect(notifItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Get bounding boxes
|
||||
const cardBox = await notifItem.boundingBox()
|
||||
expect(cardBox).not.toBeNull()
|
||||
|
||||
// Verify the card doesn't overflow horizontally beyond its parent
|
||||
const parentBox = await notifItem.locator('..').boundingBox()
|
||||
expect(parentBox).not.toBeNull()
|
||||
|
||||
if (cardBox && parentBox) {
|
||||
// Card's right edge should not extend beyond parent's right edge
|
||||
expect(cardBox.x + cardBox.width).toBeLessThanOrEqual(parentBox.x + parentBox.width + 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('Long notification entity name wraps or clips within card', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: LONG_CHORE_NAME })
|
||||
await expect(notifItem).toBeVisible()
|
||||
|
||||
// The text content inside the notification should not cause horizontal scroll
|
||||
const hasHorizontalScroll = await notifItem.evaluate((el) => {
|
||||
return el.scrollWidth > el.clientWidth
|
||||
})
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §9
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'NotifScrollChild'
|
||||
const CHORE_COUNT = 10
|
||||
const CHORE_PREFIX = 'NotifScrollChore'
|
||||
const TARGET_CHORE_IDX = CHORE_COUNT // last chore, likely off-screen
|
||||
const REWARD_NAME = 'NotifScrollReward'
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'notif test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Notification click navigates and scrolls to task', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const choreIds: string[] = []
|
||||
let targetChoreId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
|
||||
for (let i = 1; i <= CHORE_COUNT; i++) {
|
||||
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
|
||||
}
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
// The target chore is the last one — most likely off-screen
|
||||
targetChoreId = choreIds[TARGET_CHORE_IDX - 1]
|
||||
|
||||
// Confirm the target chore → creates pending notification
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: targetChoreId },
|
||||
})
|
||||
|
||||
// Request the reward → creates pending notification
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of choreIds) await request.delete(`/api/task/${id}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('Clicking a chore notification navigates and scrolls to the chore card', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const targetChoreName = `${CHORE_PREFIX}${TARGET_CHORE_IDX}`
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: targetChoreName })
|
||||
await expect(notifItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await notifItem.click()
|
||||
|
||||
// Verify navigation to parent child view with scrollTo query param
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
// Verify the target chore card is scrolled into viewport
|
||||
const targetCard = choreSection(page).locator('.item-card').filter({ hasText: targetChoreName })
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(await isInViewport(targetCard)).toBe(true)
|
||||
})
|
||||
|
||||
test('Clicking a reward notification scrolls reward card into viewport', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto('/parent/notifications')
|
||||
|
||||
const notifItem = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(notifItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await notifItem.click()
|
||||
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const targetCard = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(await isInViewport(targetCard)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §1
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
function pushToggle(page: Page) {
|
||||
return page
|
||||
.locator('.toggle-field-row')
|
||||
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
test.describe('Push subscription registration', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.3 No subscription POST when notification permission not granted
|
||||
// -------------------------------------------------------------------------
|
||||
test('No subscription POST is sent when notification permission is not granted', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Do NOT grant notifications permission
|
||||
let postCaptured = false
|
||||
await page.route('**/api/push-subscription', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postCaptured = true
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/parent/profile')
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
const isDisabled = await toggle.isDisabled().catch(() => false)
|
||||
|
||||
if (!isDisabled) {
|
||||
// Try to click the toggle — it may prompt for permission but deny it
|
||||
await toggle.click().catch(() => {})
|
||||
await page
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click()
|
||||
.catch(() => {})
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
expect(postCaptured).toBe(false)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.4 Backend rejects unauthenticated subscription POST (401)
|
||||
// -------------------------------------------------------------------------
|
||||
test('Backend rejects unauthenticated subscription POST with 401', async ({ page }) => {
|
||||
// Navigate first, then use credentials:'omit' to strip cookies and verify
|
||||
// the backend enforces auth.
|
||||
await page.goto('/parent/profile')
|
||||
const status = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/push-subscription', {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
|
||||
keys: { p256dh: 'fake-key', auth: 'fake-auth' },
|
||||
}),
|
||||
})
|
||||
return res.status
|
||||
})
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.5 Backend rejects unauthenticated subscription DELETE (401)
|
||||
// -------------------------------------------------------------------------
|
||||
test('Backend rejects unauthenticated subscription DELETE with 401', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
const status = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/push-subscription', {
|
||||
method: 'DELETE',
|
||||
credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
|
||||
}),
|
||||
})
|
||||
return res.status
|
||||
})
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §4
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'RewardNotifApproveChild'
|
||||
const REWARD_NAME = 'RewardNotifApproveReward'
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_POINTS = 30
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'notif e2e test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Reward Notification — Appearance and In-App Grant', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
// 4.1 Requested reward appears in the notification view
|
||||
test('Requested reward appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText('requested')
|
||||
})
|
||||
|
||||
// 4.2 Notification item displays the child's name
|
||||
test('Notification item displays the child name', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await expect(item).toContainText(CHILD_NAME)
|
||||
})
|
||||
|
||||
// 4.3 Clicking reward notification navigates to ParentView with correct query params
|
||||
test('Clicking reward notification navigates to ParentView with correct query params', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
await item.click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
|
||||
timeout: 5000,
|
||||
})
|
||||
expect(page.url()).toContain(`scrollTo=${rewardId}`)
|
||||
expect(page.url()).toContain('entityType=reward')
|
||||
})
|
||||
|
||||
// 4.4 Pending reward card shows PENDING badge in ParentView
|
||||
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await expect(card).toContainText('PENDING')
|
||||
})
|
||||
|
||||
// 4.5 Clicking a pending reward card opens the grant confirmation dialog
|
||||
test('Clicking a pending reward card opens grant confirmation dialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
// A confirm dialog should appear — look for a confirmation button
|
||||
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 4.6 Confirming the grant removes PENDING badge and updates child points
|
||||
test('Confirming grant removes PENDING badge and updates child points', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const before = await getPoints(page)
|
||||
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
|
||||
await confirmBtn.click()
|
||||
await expect(confirmBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST), {
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
// 4.7 Granting a reward clears it from the notification view
|
||||
test('Granting a reward clears it from notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §5
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'RewardNotifDenyChild'
|
||||
const REWARD_NAME = 'RewardNotifDenyReward'
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_POINTS = 30
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'notif e2e deny test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Reward Notification — In-App Denial', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
// Create a fresh pending request before each test
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Clean up any unconsumed pending requests
|
||||
await request.post(`/api/child/${childId}/cancel-request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
})
|
||||
|
||||
// 5.1 Requested reward appears in the notification view
|
||||
test('Requested reward appears in notification view', async ({ page }) => {
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 5.2 Pending reward card shows PENDING badge in ParentView
|
||||
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await expect(card).toContainText('PENDING')
|
||||
})
|
||||
|
||||
// 5.3 Denying a reward request removes the PENDING badge
|
||||
test('Denying a reward request removes PENDING badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const before = await getPoints(page)
|
||||
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
// Look for a Deny button in the dialog
|
||||
const denyBtn = page.getByRole('button', { name: /deny/i })
|
||||
await expect(denyBtn).toBeVisible({ timeout: 5000 })
|
||||
await denyBtn.click()
|
||||
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
|
||||
|
||||
// Points should be unchanged
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
|
||||
})
|
||||
|
||||
// 5.4 Denying a reward request clears it from the notification view
|
||||
test('Denying a reward request clears it from notification view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await expect(card).toBeVisible({ timeout: 5000 })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
const denyBtn = page.getByRole('button', { name: /deny/i })
|
||||
await expect(denyBtn).toBeVisible({ timeout: 5000 })
|
||||
await denyBtn.click()
|
||||
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await page.goto('/parent/notifications')
|
||||
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
|
||||
await expect(item).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// 5.5 POST deny-reward-request returns 200 when pending request exists (API layer)
|
||||
test('POST deny-reward-request returns 200 when pending request exists', async ({ request }) => {
|
||||
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
expect(res.status()).toBe(200)
|
||||
})
|
||||
|
||||
// 5.6 POST deny-reward-request returns 200 with graceful message when no pending request
|
||||
test('POST deny-reward-request returns 200 gracefully when no pending request', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Cancel existing request first (afterEach will also try but that's fine)
|
||||
await request.post(`/api/child/${childId}/cancel-request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
// Backend returns 200 with a graceful "already resolved" message per the spec
|
||||
expect(res.status()).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.message).toContain('already been resolved')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// spec: e2e/plans/parent-notifications.plan.md §10
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
function digestToggle(page: Page) {
|
||||
return page
|
||||
.locator('.toggle-field-row')
|
||||
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Daily Digest' }) })
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
function pushToggle(page: Page) {
|
||||
return page
|
||||
.locator('.toggle-field-row')
|
||||
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
test.describe('User Profile Notification Settings', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.1 Email Digest toggle is visible on User Profile page
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.2 Email Digest toggle initialises from the server value
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Email Digest toggle initialises from the server value', async ({ page, request }) => {
|
||||
// Set known state: enabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
// Set known state: disabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.reload()
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
// Restore default
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.3 Toggling Email Digest off sends email_digest_enabled: false to API
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/user/profile', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
capturedBody = JSON.parse(route.request().postData() ?? '{}')
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await digestToggle(page).click()
|
||||
|
||||
// Submit the form (Save button)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 3000 })
|
||||
|
||||
// Restore
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.4 Toggling Email Digest on sends email_digest_enabled: true to API
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.goto('/parent/profile')
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/user/profile', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
capturedBody = JSON.parse(route.request().postData() ?? '{}')
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await digestToggle(page).click()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(true)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 3000 })
|
||||
|
||||
// Restore
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.5 Push Notifications toggle is visible on User Profile page
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.6 Push Notifications toggle initialises to off when no subscription exists
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle initialises to off when no subscription exists', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Do NOT grant notifications permission — toggle should start unchecked
|
||||
await page.goto('/parent/profile')
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// Either aria-pressed="false" or disabled (when browser denies permissions)
|
||||
const ariaPressed = await toggle.getAttribute('aria-pressed')
|
||||
expect(ariaPressed).toBe('false')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10.8 Push Notifications toggle is disabled when browser permission is denied
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle is disabled when browser permission is denied', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Do not grant permission (leave as denied/prompt)
|
||||
await context.clearPermissions()
|
||||
await page.goto('/parent/profile')
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// When permission is denied, the toggle should be disabled or aria-pressed="false"
|
||||
const isDisabled = await toggle.isDisabled().catch(() => false)
|
||||
const ariaPressed = await toggle.getAttribute('aria-pressed')
|
||||
// Either disabled or not pressed — permission denied prevents enabling it
|
||||
expect(isDisabled || ariaPressed === 'false').toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
test.describe('Parent profile button – temporary parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Switch from permanent mode to temporary mode:
|
||||
// 1. Exit parent mode via Child Mode
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
// 2. Re-enter parent mode WITHOUT the persistence checkbox
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
// Do NOT check 'Stay in parent mode'
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
})
|
||||
|
||||
test('Badge – no 🔒 badge in temporary parent mode', async ({ page }) => {
|
||||
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
test.describe('Parent profile button – permanent parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
})
|
||||
|
||||
test('Badge – shows 🔒 in permanent parent mode', async ({ page }) => {
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toHaveText('🔒')
|
||||
})
|
||||
|
||||
test('Menu – shows user name and email', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
|
||||
await expect(page.getByText(E2E_FIRST_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(E2E_EMAIL, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Profile option navigates to the User Profile page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode exits parent mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// View-selector navigation buttons disappear after exiting parent mode
|
||||
await expect(page.getByRole('button', { name: 'Children' })).not.toBeVisible()
|
||||
// Profile button changes label to "Parent login" when not in parent mode
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode re-entry without checkbox enters temporary mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// Click profile button to open PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
// Do NOT check "Stay in parent mode"
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// No persistent badge — temporary mode
|
||||
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode re-entry with checkbox enters permanent mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// Click profile button to open PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// Persistent badge visible — permanent mode
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Sign Out navigates to landing page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Sign out' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('button', { name: 'Sign In' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
78
frontend/e2e/mode_parent/rewards/reward-cancel.spec.ts
Normal file
78
frontend/e2e/mode_parent/rewards/reward-cancel.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Reward cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Cancel reward creation', async ({ page }) => {
|
||||
const name = `CancelMe-${Date.now()}`
|
||||
// 1. Navigate and open create form
|
||||
await page.goto('/parent/rewards')
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
|
||||
// 2. Fill then cancel
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#cost', '5')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// verify return to list and the cancelled item was not saved
|
||||
await expect(page.getByText(name, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel reward edit', async ({ page }) => {
|
||||
// 1. Ensure Toy car exists and open edit
|
||||
const suffix = Date.now()
|
||||
const original = `Toy car ${suffix}`
|
||||
await page.goto('/parent/rewards')
|
||||
if (!(await page.locator(`text=${original}`).count())) {
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
await page.click(`text=${original}`)
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
|
||||
// 2. make a change then cancel
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name') as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = 'Should not save'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.locator('text=Should not save')).toHaveCount(0)
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Convert default reward', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Convert a default reward to a user item', async ({ page }) => {
|
||||
await page.goto('/parent/rewards')
|
||||
|
||||
// remove any row that has a delete button (covers custom copies and renamed originals)
|
||||
const allRows = page.locator('text=Choose meal')
|
||||
const total = await allRows.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
const r = allRows.nth(i)
|
||||
const delBtn = r.locator('..').getByRole('button', { name: 'Delete' })
|
||||
if ((await delBtn.count()) > 0) {
|
||||
await delBtn.click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
}
|
||||
}
|
||||
|
||||
// locate the remaining default reward (exact text)
|
||||
const row = page.getByText('Choose meal', { exact: true }).first()
|
||||
await expect(row).toBeVisible()
|
||||
// delete button should not exist initially on default
|
||||
await expect(row.locator('..').getByRole('button', { name: 'Delete' })).toHaveCount(0)
|
||||
|
||||
// open edit form
|
||||
await row.click()
|
||||
await expect(page.locator('input#name')).toHaveValue('Choose meal')
|
||||
|
||||
// change fields
|
||||
await page.locator('#name').fill('Choose meal (custom)')
|
||||
await page.locator('#cost').fill('35')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// after save, ensure row now has delete control
|
||||
const updated = page.getByText('Choose meal (custom)', { exact: true }).first()
|
||||
await expect(updated).toBeVisible()
|
||||
await expect(updated.locator('..').getByRole('button', { name: 'Delete' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
139
frontend/e2e/mode_parent/rewards/reward-create-edit.spec.ts
Normal file
139
frontend/e2e/mode_parent/rewards/reward-create-edit.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Reward management', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Create a new reward', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Toy car ${suffix}`
|
||||
|
||||
// 1. Navigate to /parent/rewards and verify default list
|
||||
await page.goto('/parent/rewards')
|
||||
// expect: some reward name visible (use default item)
|
||||
await expect(page.getByText('Choose meal', { exact: true })).toBeVisible()
|
||||
|
||||
// 2. Click the 'Create Reward' FAB
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
// expect: form appears with fields
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
|
||||
// 3. Fill in name and cost via DOM events
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, name)
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
|
||||
// 4. Submit
|
||||
await page.click('button:has-text("Create")')
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/parent\//)
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing reward', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `Toy car ${suffix}`
|
||||
const updated = `Toy boat ${suffix}`
|
||||
|
||||
// ensure the item exists
|
||||
await page.goto('/parent/rewards')
|
||||
if (!(await page.locator(`text=${original}`).count())) {
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
|
||||
// open edit form
|
||||
await page.click(`text=${original}`)
|
||||
await expect(page.locator('text=Reward Name')).toBeVisible()
|
||||
|
||||
// change values
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string | number) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
if (typeof val === 'number') {
|
||||
el.valueAsNumber = val
|
||||
} else {
|
||||
el.value = val
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
// set numeric cost to ensure backend receives integer
|
||||
setVal('#cost', 25)
|
||||
}, updated)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/parent\//)
|
||||
// allow extra time for the updated row to appear via SSE
|
||||
await expect(page.locator(`text=${updated}`)).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('Delete a reward', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Toy car ${suffix}`
|
||||
|
||||
await page.goto('/parent/rewards')
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#cost', '20')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
|
||||
// delete using row-local button
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.first()
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
124
frontend/e2e/mode_parent/rewards/reward-delete-default.spec.ts
Normal file
124
frontend/e2e/mode_parent/rewards/reward-delete-default.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Default reward cost edit and restore', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Edit default cost and verify restoration on delete', async ({ page }) => {
|
||||
await page.goto('/parent/rewards')
|
||||
|
||||
// cleanup any user-copy of "Choose meal"
|
||||
// Keep deleting rows until there are none left – avoids DOM-shift race when
|
||||
// multiple matching rows exist (e.g. leftovers from previous specs). Because
|
||||
// other workers may modify the list concurrently, we also retry the entire
|
||||
// loop once after a short delay if a delete button still shows up.
|
||||
async function purge(): Promise<void> {
|
||||
while (
|
||||
await page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.count()
|
||||
) {
|
||||
const btn = page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.first()
|
||||
await btn.click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
// give UI a moment to refresh the list
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
}
|
||||
await purge()
|
||||
// if something else sneaks one in (concurrent spec), wait then try again
|
||||
if (
|
||||
await page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.count()
|
||||
) {
|
||||
await page.waitForTimeout(500)
|
||||
await purge()
|
||||
}
|
||||
// reload to make sure the list has settled after all deletions
|
||||
await page.reload()
|
||||
await page.waitForURL('/parent/rewards')
|
||||
|
||||
// ensure default exists with no delete icon
|
||||
const defaultRow = page.getByText('Choose meal', { exact: true }).first()
|
||||
await expect(defaultRow).toBeVisible()
|
||||
// sometimes concurrent specs race and temporarily re-add a delete icon; give
|
||||
// it a moment and retry once
|
||||
let deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count()
|
||||
if (deleteCount) {
|
||||
await page.waitForTimeout(500)
|
||||
deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count()
|
||||
}
|
||||
await expect(deleteCount).toBe(0)
|
||||
|
||||
// open edit and change cost
|
||||
await defaultRow.click()
|
||||
await expect(page.locator('input#name')).toHaveValue('Choose meal')
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#cost', '50')
|
||||
})
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
|
||||
// After save: pick the row wrapper that now shows 50 pts (user copy)
|
||||
const customRow = page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.filter({ has: page.locator('text=50 pts') })
|
||||
.first()
|
||||
await expect(customRow).toBeVisible({ timeout: 10000 })
|
||||
await expect(customRow.getByRole('button', { name: /Delete/ })).toBeVisible()
|
||||
|
||||
// delete and confirm restoration using the same customRow
|
||||
await customRow.getByRole('button', { name: /Delete/ }).click()
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
|
||||
// wait until the exact 'Choose meal' copy showing the edited cost is gone
|
||||
await expect(
|
||||
page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.filter({ has: page.locator('text=50 pts') }),
|
||||
).toHaveCount(0, { timeout: 10000 })
|
||||
|
||||
// reload the list to clear any lingering delete icon artifacts
|
||||
await page.reload()
|
||||
await page.waitForURL('/parent/rewards')
|
||||
|
||||
// ensure no delete button is visible on the exact default 'Choose meal' row
|
||||
await expect(
|
||||
page
|
||||
.getByText('Choose meal', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' }),
|
||||
).toHaveCount(0, { timeout: 10000 })
|
||||
|
||||
const finalRow = page.getByText('Choose meal', { exact: true }).first()
|
||||
await expect(finalRow).toBeVisible()
|
||||
await expect(finalRow).not.toContainText('50 pts')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivateChoreChild'
|
||||
const CHORE_NAME = 'TriggerTestChore'
|
||||
const KIND_NAME = 'TriggerChoreHelperKindness'
|
||||
const CHORE_POINTS = 10
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chore activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', 5)
|
||||
// assign chore
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
// assign kindness (set-tasks preserves other types)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
// Add a schedule so COMPLETED status persists after trigger
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
})
|
||||
|
||||
test('Kebab button appears after first chore click', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, kebab becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Kebab button disappears when clicking a non-chore item', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
const kindCard = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await kindCard.waitFor({ state: 'visible' })
|
||||
|
||||
// Click chore — kebab appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
|
||||
// Click a kindness item — selectedChoreId resets to null, kebab gone
|
||||
await kindCard.click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel chore confirmation — no points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
})
|
||||
|
||||
test('Confirm chore activation — points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + CHORE_POINTS))
|
||||
})
|
||||
|
||||
test('Confirmed chore shows COMPLETED stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking a completed chore does not open confirmation', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
// Two-click completed chore
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
// No confirmation dialog should appear; points must remain unchanged
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §2
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'GeneralChoreChild'
|
||||
const GENERAL_CHORE_NAME = 'GeneralTestChore'
|
||||
const SCHEDULED_CHORE_NAME = 'ScheduledTestChore'
|
||||
const CHORE_POINTS = 10
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('General chore — no COMPLETED stamp', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let generalChoreId = ''
|
||||
let scheduledChoreId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
generalChoreId = await createTask(request, GENERAL_CHORE_NAME, CHORE_POINTS)
|
||||
scheduledChoreId = await createTask(request, SCHEDULED_CHORE_NAME, CHORE_POINTS)
|
||||
|
||||
// Assign both chores
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [generalChoreId, scheduledChoreId], type: 'chore' },
|
||||
})
|
||||
|
||||
// Only the second chore gets a schedule
|
||||
await request.put(`/api/child/${childId}/task/${scheduledChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (generalChoreId) await request.delete(`/api/task/${generalChoreId}`)
|
||||
if (scheduledChoreId) await request.delete(`/api/task/${scheduledChoreId}`)
|
||||
})
|
||||
|
||||
test('Approving a general chore awards points', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + CHORE_POINTS))
|
||||
})
|
||||
|
||||
test('General chore does NOT show COMPLETED stamp after approval', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('General chore can be triggered again immediately after approval', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Scheduled chore DOES show COMPLETED after approval', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: SCHEDULED_CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivateKindnessChild'
|
||||
const KIND_NAME = 'TriggerTestKindness'
|
||||
const KIND_HELPER_NAME = 'TriggerKindnessHelper'
|
||||
const KIND_POINTS = 5
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Kindness act activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let kindId = ''
|
||||
let kindHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
kindHelperId = await createTask(request, KIND_HELPER_NAME, 'kindness', KIND_POINTS)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId, kindHelperId], type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (kindHelperId) await request.delete(`/api/task/${kindHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first kindness act click', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, edit button becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different kindness card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = kindnessSection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: KIND_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
// First click on target card — edit button appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
// Click on a different card — target's edit button disappears
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel kindness act confirmation — no points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
})
|
||||
|
||||
test('Confirm kindness act activation — points awarded', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + KIND_POINTS))
|
||||
})
|
||||
|
||||
test('Kindness act can be activated a second time — points awarded again', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
// Second activation of the same kindness act
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + KIND_POINTS))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivatePenaltyChild'
|
||||
const PEN_NAME = 'TriggerTestPenalty'
|
||||
const PEN_HELPER_NAME = 'TriggerPenaltyHelper'
|
||||
const PEN_POINTS = 20
|
||||
const INITIAL_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Penalty activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let penId = ''
|
||||
let penHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
penHelperId = await createTask(request, PEN_HELPER_NAME, 'penalty', PEN_POINTS)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId, penHelperId], type: 'penalty' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (penHelperId) await request.delete(`/api/task/${penHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first penalty click', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, edit button becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different penalty card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = penaltySection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: PEN_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
// First click on target card — edit button appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
// Click on a different card — target's edit button disappears
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel penalty confirmation — no points deducted', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
})
|
||||
|
||||
test('Confirm penalty activation — points deducted', async ({ page }) => {
|
||||
// Starts at INITIAL_POINTS (50)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// 50 - 20 = 30
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - PEN_POINTS))
|
||||
})
|
||||
|
||||
test('Penalty can be activated multiple times — points deducted again', async ({ page }) => {
|
||||
// Starts at 30 after previous test
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// 30 - 20 = 10
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - PEN_POINTS))
|
||||
})
|
||||
|
||||
test('Points floor at 0 — penalty cannot make points negative', async ({ page }) => {
|
||||
// Starts at 10 after previous test; penalty is 20 pts
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// 10 - 20 would be -10; app floors at 0
|
||||
await expect(page.locator('.points .value')).toHaveText('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,359 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
// Tests for the "pending reward warning" dialog that appears when the parent tries to
|
||||
// activate a chore, kindness act, penalty, or a different reward while one of the child's
|
||||
// reward requests is in a pending state.
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'PendingRewardWarnChild'
|
||||
const CHORE_NAME = 'PendingWarnChore'
|
||||
const KIND_NAME = 'PendingWarnKindness'
|
||||
const PEN_NAME = 'PendingWarnPenalty'
|
||||
const PENDING_REWARD_NAME = 'PendingWarnReward'
|
||||
const OTHER_REWARD_NAME = 'PendingWarnOtherReward'
|
||||
const REWARD_COST = 10
|
||||
const CHORE_POINTS = 5
|
||||
const KIND_POINTS = 5
|
||||
const PEN_POINTS = 5
|
||||
const INITIAL_POINTS = 30 // must be >= REWARD_COST so the request-reward call succeeds
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E pending-warn test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
// ── suite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Pending reward warning dialog', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
let penId = ''
|
||||
let pendingRewardId = ''
|
||||
let otherRewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
pendingRewardId = await createReward(request, PENDING_REWARD_NAME, REWARD_COST)
|
||||
otherRewardId = await createReward(request, OTHER_REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId], type: 'penalty' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [pendingRewardId, otherRewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (pendingRewardId) await request.delete(`/api/reward/${pendingRewardId}`)
|
||||
if (otherRewardId) await request.delete(`/api/reward/${otherRewardId}`)
|
||||
})
|
||||
|
||||
// Ensure each test starts with the pending reward freshly requested
|
||||
test.beforeEach(async ({ request }) => {
|
||||
// Reset child points so the request-reward call always succeeds
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: INITIAL_POINTS } })
|
||||
// Cancel any leftover request (404 if none — ignored)
|
||||
await request.post(`/api/child/${childId}/cancel-request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
// Place a fresh pending request
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
})
|
||||
|
||||
// ── banner ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('Pending reward card shows PENDING banner', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.locator('.pending')).toHaveText('PENDING')
|
||||
})
|
||||
|
||||
// ── chore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('Chore activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
// Pending reward warning dialog appears (has a "No" button)
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
// Dialog dismissed; task confirmation dialog must NOT have appeared
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
// Points unchanged
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
// PENDING banner still visible on the reward card
|
||||
const rewardCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
await expect(rewardCard.locator('.pending')).toHaveText('PENDING')
|
||||
})
|
||||
|
||||
test('Chore activation — Confirm on pending dialog cancels pending reward and triggers chore', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
// Pending reward warning dialog
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// After confirming, the task confirmation dialog appears (has "Cancel" button)
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Chore points awarded — points changed from initial value
|
||||
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
|
||||
timeout: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
// ── kindness ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('Kindness activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
})
|
||||
|
||||
test('Kindness activation — Confirm on pending dialog cancels pending reward and triggers kindness', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
|
||||
timeout: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
// ── penalty ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('Penalty activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
})
|
||||
|
||||
test('Penalty activation — Confirm on pending dialog cancels pending reward and triggers penalty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
const pointsBefore = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Penalty deducted points
|
||||
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
|
||||
timeout: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
// ── other reward ──────────────────────────────────────────────────────────
|
||||
|
||||
test('Other reward activation — Cancel on pending dialog leaves state unchanged', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
// Both rewards should be ready (INITIAL_POINTS >= REWARD_COST)
|
||||
const pendingCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
const otherCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: OTHER_REWARD_NAME })
|
||||
await pendingCard.waitFor({ state: 'visible' })
|
||||
await otherCard.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(pendingCard.locator('.pending')).toHaveText('PENDING')
|
||||
await expect(otherCard.getByText('REWARD READY')).toBeVisible()
|
||||
|
||||
const pointsBefore = await getPoints(page)
|
||||
await activateItem(otherCard)
|
||||
|
||||
// Pending reward warning dialog appears when another reward has a pending request
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||
// PENDING banner still visible — reward request was not cancelled
|
||||
await expect(pendingCard.locator('.pending')).toHaveText('PENDING')
|
||||
})
|
||||
|
||||
test('Other reward activation — Confirm on pending dialog cancels the pending reward', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const pendingCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENDING_REWARD_NAME })
|
||||
const otherCard = rewardSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: OTHER_REWARD_NAME })
|
||||
await pendingCard.waitFor({ state: 'visible' })
|
||||
await otherCard.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(otherCard)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Pending dialog closes; reward confirm does NOT auto-open (other reward must be re-clicked)
|
||||
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||
|
||||
// The pending reward request was cancelled — PENDING banner should disappear
|
||||
await expect(pendingCard.locator('.pending')).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ActivateRewardChild'
|
||||
const REWARD_NAME = 'TriggerTestReward'
|
||||
const REWARD_HELPER_NAME = 'TriggerRewardHelper'
|
||||
const REWARD_COST = 20
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Reward redemption', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
let rewardHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
rewardHelperId = await createReward(request, REWARD_HELPER_NAME, REWARD_COST)
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId, rewardHelperId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
if (rewardHelperId) await request.delete(`/api/reward/${rewardHelperId}`)
|
||||
})
|
||||
|
||||
test('Locked reward shows correct points needed text', async ({ page }) => {
|
||||
// Child starts at 0 pts; reward costs 20 → "20 more points"
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
|
||||
await expect(card.getByText('REWARD READY')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking a locked reward does not open confirmation dialog', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Two-click on locked reward
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
// triggerReward returns early when points_needed > 0 — no dialog
|
||||
await expect(page.locator('.points .value')).toHaveText('0')
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Reward shows REWARD READY when child has sufficient points', async ({ page, request }) => {
|
||||
// Set child points to match reward cost
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
await expect(card.getByText(/more points/)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a ready reward', async ({ page }) => {
|
||||
// Child has REWARD_COST pts (set in previous test)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
|
||||
// First click: item enters ready state, edit button becomes visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different reward card', async ({ page }) => {
|
||||
// Child has REWARD_COST pts (set in previous test); both rewards show REWARD READY
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = rewardSection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: REWARD_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
// First click on target card — edit button appears
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
// Click on a different reward card — target's edit button disappears
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel reward redemption — points not deducted', async ({ page }) => {
|
||||
// Child has REWARD_COST pts (from two tests ago; edit button test did not consume them)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Confirm reward redemption — points deducted and reward locks again', async ({ page }) => {
|
||||
// Child still has REWARD_COST pts after the cancelled redemption
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
// Points reduced by reward cost
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST))
|
||||
// Reward is now locked again — shows points needed
|
||||
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
|
||||
await expect(card.getByText('REWARD READY')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
194
frontend/e2e/mode_parent/task-activation/scroll-and-flow.spec.ts
Normal file
194
frontend/e2e/mode_parent/task-activation/scroll-and-flow.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// spec: e2e/plans/task-activated.plan.md
|
||||
// Tests for scrollable list flow and first-click scroll-to-center behavior.
|
||||
// 8 kindness items are assigned to a dedicated child so the list overflows
|
||||
// a narrow (480 px) viewport, reliably exercising the horizontal scroll path.
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Returns true if the element's bounding rect overlaps the current page viewport.
|
||||
* Uses getBoundingClientRect() — reliable across all Playwright versions and
|
||||
* correctly handles elements clipped by overflow scroll containers.
|
||||
*/
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const CHILD_NAME = 'ScrollFlowChild'
|
||||
const ITEM_COUNT = 8
|
||||
const ITEM_NAMES = Array.from({ length: ITEM_COUNT }, (_, i) => `ScrollTestKindness${i + 1}`)
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type: 'kindness' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Scroll and flow', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const taskIds: string[] = []
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
for (const name of ITEM_NAMES) {
|
||||
taskIds.push(await createTask(request, name))
|
||||
}
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: taskIds, type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of taskIds) await request.delete(`/api/task/${id}`)
|
||||
taskIds.length = 0
|
||||
})
|
||||
|
||||
test('Kindness list is horizontally scrollable when many items are assigned', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const scrollWrapper = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// Total content width (8 × 140 px + 7 × 12 px gaps ≈ 1204 px) must exceed container width
|
||||
const isOverflowing = await scrollWrapper.evaluate(
|
||||
(el: HTMLElement) => el.scrollWidth > el.clientWidth,
|
||||
)
|
||||
expect(isOverflowing).toBe(true)
|
||||
|
||||
// The last card in DOM order is outside the 480 px viewport at scrollLeft=0
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const lastCard = section.locator('.item-card').last()
|
||||
|
||||
expect(await isInViewport(lastCard)).toBe(false)
|
||||
})
|
||||
|
||||
test('Scrolling the list reveals hidden items', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const scrollWrapper = section.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// Use DOM-order first/last — independent of API return order
|
||||
const firstCard = section.locator('.item-card').first()
|
||||
const lastCard = section.locator('.item-card').last()
|
||||
|
||||
// Initially: first item is in viewport, last item is scrolled off to the right
|
||||
expect(await isInViewport(firstCard)).toBe(true)
|
||||
expect(await isInViewport(lastCard)).toBe(false)
|
||||
|
||||
// Programmatically scroll to the end — override smooth-scroll so position is immediate
|
||||
await scrollWrapper.evaluate((el: HTMLElement) => {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Last item is now visible; first item has scrolled off to the left
|
||||
expect(await isInViewport(lastCard)).toBe(true)
|
||||
expect(await isInViewport(firstCard)).toBe(false)
|
||||
})
|
||||
|
||||
test('First click on an off-screen item scrolls it into view and marks it ready', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const scrollWrapper = section.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// The last item in DOM order is definitely off-screen at scrollLeft=0 on a 480 px viewport
|
||||
const lastCard = section.locator('.item-card').last()
|
||||
expect(await isInViewport(lastCard)).toBe(false)
|
||||
|
||||
// force: true is required because the element's bounding-box center is outside
|
||||
// the page viewport — that is exactly the condition we are testing (scroll-to-center).
|
||||
await lastCard.click({ force: true })
|
||||
|
||||
// The first-click handler runs centerItem() then emits item-ready
|
||||
await expect(lastCard).toHaveClass(/item-ready/, { timeout: 5000 })
|
||||
|
||||
// Wait for the smooth-scroll animation to settle
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// After centering, the item must be visible within the 480 px viewport
|
||||
expect(await isInViewport(lastCard)).toBe(true)
|
||||
})
|
||||
|
||||
test('Scrolling does not prevent first-click centering on a partially visible item', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
const section = page
|
||||
.locator('.child-list-container')
|
||||
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
|
||||
const scrollWrapper = section.locator('.scroll-wrapper')
|
||||
await scrollWrapper.waitFor({ state: 'visible' })
|
||||
|
||||
// Scroll to the middle — override smooth-scroll so position is immediate
|
||||
await scrollWrapper.evaluate((el: HTMLElement) => {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = Math.floor(el.scrollWidth / 2)
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// The first item in DOM order is now off-screen to the left after scrolling to the middle
|
||||
const firstCard = section.locator('.item-card').first()
|
||||
expect(await isInViewport(firstCard)).toBe(false)
|
||||
|
||||
// Click the first item (force: true because its center is left of the viewport)
|
||||
await firstCard.click({ force: true })
|
||||
await expect(firstCard).toHaveClass(/item-ready/, { timeout: 5000 })
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// First item should now be centered and in the viewport
|
||||
expect(await isInViewport(firstCard)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §1
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignTitleChild'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Assign view heading shows child name', () => {
|
||||
let childId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
})
|
||||
|
||||
test('"Assign Chores" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Chores.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Assign Rewards" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Rewards.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Assign Kindness Acts" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Kindness Acts.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Assign Penalties" heading contains child name', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`Assign Penalties.*${CHILD_NAME}`) }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignChoreChild'
|
||||
const CHORE1_NAME = 'AssignTestChore1'
|
||||
const CHORE2_NAME = 'AssignTestChore2'
|
||||
const CHORE3_NAME = 'AssignTestChore3'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const res = await request.get('/api/task/list')
|
||||
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
|
||||
return data.tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Chore assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let chore1Id = ''
|
||||
let chore2Id = ''
|
||||
let chore3Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test tasks from a previous run
|
||||
const taskRes = await request.get('/api/task/list')
|
||||
const taskData = await taskRes.json()
|
||||
for (const t of taskData.tasks ?? []) {
|
||||
if ([CHORE1_NAME, CHORE2_NAME, CHORE3_NAME].includes(t.name)) {
|
||||
await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
chore1Id = await createTestTask(request, CHORE1_NAME, 'chore')
|
||||
chore2Id = await createTestTask(request, CHORE2_NAME, 'chore')
|
||||
chore3Id = await createTestTask(request, CHORE3_NAME, 'chore')
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (chore1Id) await request.delete(`/api/task/${chore1Id}`)
|
||||
if (chore2Id) await request.delete(`/api/task/${chore2Id}`)
|
||||
if (chore3Id) await request.delete(`/api/task/${chore3Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Chores" button navigates to the chore assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Test-specific chores are listed
|
||||
await expect(page.getByText(CHORE1_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(CHORE2_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select chores and submit — items appear in ParentView chores section', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Select two chores
|
||||
await page.getByText(CHORE1_NAME, { exact: true }).click()
|
||||
await page.getByText(CHORE2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
await expect(page.getByText(CHORE1_NAME)).toBeVisible()
|
||||
await expect(page.getByText(CHORE2_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows assigned items as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-chores`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Previously assigned items must be pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// An unassigned item must not be checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Uncheck the two previously assigned chores
|
||||
await page.getByText(CHORE1_NAME, { exact: true }).click()
|
||||
await page.getByText(CHORE2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
await expect(page.getByText(CHORE1_NAME)).not.toBeVisible()
|
||||
await expect(page.getByText(CHORE2_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
await page.getByText(CHORE3_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back to the assign view and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-chores`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignKindnessChild'
|
||||
const KIND1_NAME = 'AssignTestKindness1'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const res = await request.get('/api/task/list')
|
||||
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
|
||||
return data.tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Kindness act assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let kind1Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test task from a previous run
|
||||
const taskRes = await request.get('/api/task/list')
|
||||
const taskData = await taskRes.json()
|
||||
for (const t of taskData.tasks ?? []) {
|
||||
if (t.name === KIND1_NAME) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
kind1Id = await createTestTask(request, KIND1_NAME, 'kindness')
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (kind1Id) await request.delete(`/api/task/${kind1Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Kindness Acts" button navigates to the kindness assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Test-specific kindness act is listed
|
||||
await expect(page.getByText(KIND1_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select a kindness act and submit — item appears in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
await expect(page.getByText(KIND1_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows the assigned item as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-kindness`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck the kindness act and submit — item is removed from ParentView', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned item
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
await expect(page.getByText(KIND1_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Check the item
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-kindness`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignPenaltyChild'
|
||||
const PEN1_NAME = 'AssignTestPenalty1'
|
||||
const PEN2_NAME = 'AssignTestPenalty2'
|
||||
const PEN3_NAME = 'AssignTestPenalty3'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const res = await request.get('/api/task/list')
|
||||
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
|
||||
return data.tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Penalty assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let pen1Id = ''
|
||||
let pen2Id = ''
|
||||
let pen3Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test tasks from a previous run
|
||||
const taskRes = await request.get('/api/task/list')
|
||||
const taskData = await taskRes.json()
|
||||
for (const t of taskData.tasks ?? []) {
|
||||
if ([PEN1_NAME, PEN2_NAME, PEN3_NAME].includes(t.name)) {
|
||||
await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
pen1Id = await createTestTask(request, PEN1_NAME, 'penalty')
|
||||
pen2Id = await createTestTask(request, PEN2_NAME, 'penalty')
|
||||
pen3Id = await createTestTask(request, PEN3_NAME, 'penalty')
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (pen1Id) await request.delete(`/api/task/${pen1Id}`)
|
||||
if (pen2Id) await request.delete(`/api/task/${pen2Id}`)
|
||||
if (pen3Id) await request.delete(`/api/task/${pen3Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Penalties" button navigates to the penalty assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Test-specific penalty items are listed
|
||||
await expect(page.getByText(PEN1_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(PEN2_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select penalties and submit — items appear in ParentView penalties section', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Select two penalties
|
||||
await page.getByText(PEN1_NAME, { exact: true }).click()
|
||||
await page.getByText(PEN2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
await expect(page.getByText(PEN1_NAME)).toBeVisible()
|
||||
await expect(page.getByText(PEN2_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows assigned penalties as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-penalties`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Previously assigned items should be pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// An unassigned item should not be checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned penalties
|
||||
await page.getByText(PEN1_NAME, { exact: true }).click()
|
||||
await page.getByText(PEN2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
await expect(page.getByText(PEN1_NAME)).not.toBeVisible()
|
||||
await expect(page.getByText(PEN2_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
await page.getByText(PEN3_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-penalties`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignRewardChild'
|
||||
const REW1_NAME = 'AssignTestReward1'
|
||||
const REW2_NAME = 'AssignTestReward2'
|
||||
const REW3_NAME = 'AssignTestReward3'
|
||||
|
||||
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const res = await request.get('/api/child/list')
|
||||
const data = (await res.json()) as { children?: { name: string; id: string }[] }
|
||||
return data.children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTestReward(request: APIRequestContext, name: string): Promise<string> {
|
||||
await request.put('/api/reward/add', { data: { name, description: 'Test reward', cost: 10 } })
|
||||
const res = await request.get('/api/reward/list')
|
||||
const data = (await res.json()) as { rewards?: { name: string; id: string }[] }
|
||||
return data.rewards?.find((r) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Reward assignment', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rew1Id = ''
|
||||
let rew2Id = ''
|
||||
let rew3Id = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Clean up any stale child from a previous run
|
||||
const listRes = await request.get('/api/child/list')
|
||||
const listData = await listRes.json()
|
||||
for (const c of listData.children ?? []) {
|
||||
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
|
||||
// Clean up any stale test rewards from a previous run
|
||||
const rewardRes = await request.get('/api/reward/list')
|
||||
const rewardData = await rewardRes.json()
|
||||
for (const r of rewardData.rewards ?? []) {
|
||||
if ([REW1_NAME, REW2_NAME, REW3_NAME].includes(r.name)) {
|
||||
await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
childId = await createTestChild(request, CHILD_NAME)
|
||||
rew1Id = await createTestReward(request, REW1_NAME)
|
||||
rew2Id = await createTestReward(request, REW2_NAME)
|
||||
rew3Id = await createTestReward(request, REW3_NAME)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rew1Id) await request.delete(`/api/reward/${rew1Id}`)
|
||||
if (rew2Id) await request.delete(`/api/reward/${rew2Id}`)
|
||||
if (rew3Id) await request.delete(`/api/reward/${rew3Id}`)
|
||||
})
|
||||
|
||||
test('"Assign Rewards" button navigates to the reward assign view', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Test-specific rewards are listed
|
||||
await expect(page.getByText(REW1_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(REW2_NAME, { exact: true })).toBeVisible()
|
||||
|
||||
// Fresh child — nothing pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Select rewards and submit — items appear in ParentView rewards section', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Select two rewards
|
||||
await page.getByText(REW1_NAME, { exact: true }).click()
|
||||
await page.getByText(REW2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
await expect(page.getByText(REW1_NAME)).toBeVisible()
|
||||
await expect(page.getByText(REW2_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Re-opening assign view shows assigned rewards as pre-checked', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-rewards`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Previously assigned items should be pre-checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// An unassigned item should not be checked
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned rewards
|
||||
await page.getByText(REW1_NAME, { exact: true }).click()
|
||||
await page.getByText(REW2_NAME, { exact: true }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
await expect(page.getByText(REW1_NAME)).not.toBeVisible()
|
||||
await expect(page.getByText(REW2_NAME)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
await page.getByText(REW3_NAME, { exact: true }).click()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
|
||||
).toBeChecked()
|
||||
|
||||
// Cancel instead of submitting
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}$`))
|
||||
|
||||
// Navigate back and verify the selection was not saved
|
||||
await page.goto(`/parent/${childId}/assign-rewards`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
|
||||
).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyChoreChild'
|
||||
const CHORE_NAME = 'ModifyTestChore'
|
||||
const CHORE_POINTS = 10
|
||||
const HELPER_KIND_NAME = 'ModifyChoreHelperKindness'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openChoreOverrideModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await page.getByRole('button', { name: 'Edit Points' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Chore edit points', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let helperKindId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
helperKindId = await createTask(request, HELPER_KIND_NAME, 'kindness', 5)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [helperKindId], type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (helperKindId) await request.delete(`/api/task/${helperKindId}`)
|
||||
})
|
||||
|
||||
test('Kebab button is hidden initially, visible after first click, hidden after deselect', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Initially hidden (v-show=false while selectedChoreId != item.id)
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeHidden()
|
||||
|
||||
// First click: card enters ready state, kebab shown
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
|
||||
// Clicking a kindness card resets selectedChoreId to null → kebab hidden
|
||||
const kindCard = kindnessSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: HELPER_KIND_NAME })
|
||||
await kindCard.click()
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeHidden()
|
||||
})
|
||||
|
||||
test('Kebab menu contains "Edit Points" and "Schedule"', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Edit Points' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Edit Points" opens override modal pre-populated with current points', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await page.getByRole('button', { name: 'Edit Points' }).click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(CHORE_NAME)
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(CHORE_POINTS))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Clicking outside the modal does not close it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openChoreOverrideModal(page, card)
|
||||
|
||||
// Click the backdrop area far from the modal dialog
|
||||
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 }, force: true })
|
||||
|
||||
// Modal must still be visible
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not save — card still shows original points, no ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('99')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText(`${CHORE_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a valid new value updates the displayed points on the card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('50')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('50 Points')
|
||||
})
|
||||
|
||||
test('⭐ override badge appears on the card after saving a custom value', async ({ page }) => {
|
||||
// Badge state persists server-side; verify on a fresh page load
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('⭐ badge persists after changing value back to the original default', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Change override back to original value — badge stays until item is unassigned/reassigned
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill(String(CHORE_POINTS))
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText(`${CHORE_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('-1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Zero is a valid value — card shows "0 Points" with ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('0')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('0 Points')
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
})
|
||||
170
frontend/e2e/mode_parent/task-modification/chore-reset.spec.ts
Normal file
170
frontend/e2e/mode_parent/task-modification/chore-reset.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyResetChild'
|
||||
const CHORE_NAME = 'ModifyResetChore'
|
||||
const CHORE_POINTS = 10
|
||||
const HELPER_KIND_NAME = 'ModifyResetKindnessHelper'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openChoreKebab(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
}
|
||||
|
||||
test.describe('Chore reset', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let helperKindId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
helperKindId = await createTask(request, HELPER_KIND_NAME, 'kindness', 5)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [helperKindId], type: 'kindness' },
|
||||
})
|
||||
// Add a schedule so COMPLETED status persists after trigger
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (helperKindId) await request.delete(`/api/task/${helperKindId}`)
|
||||
})
|
||||
|
||||
test('"Reset" is absent from kebab menu when chore is not yet completed', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openChoreKebab(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Edit Points' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Chore shows COMPLETED stamp after activation and confirmation', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Reset" appears in kebab menu after chore is completed', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
|
||||
await openChoreKebab(page, card)
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking "Reset" removes the COMPLETED stamp', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
|
||||
await openChoreKebab(page, card)
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Chore is re-activatable after reset — returns to COMPLETED on confirm', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Chore is not completed after the previous test's reset
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
|
||||
await activateItem(card)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reset → activate cycle is repeatable without error', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Reset the now-completed chore
|
||||
await openChoreKebab(page, card)
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
await expect(card.getByText('COMPLETED')).not.toBeVisible()
|
||||
|
||||
// Navigate fresh so item-ready state is cleared before activating
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Activate again — COMPLETED returns
|
||||
await activateItem(card)
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyKindnessChild'
|
||||
const KIND_NAME = 'ModifyTestKindness'
|
||||
const KIND_HELPER_NAME = 'ModifyKindnessHelper'
|
||||
const KIND_POINTS = 15
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Kindness act edit points', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let kindId = ''
|
||||
let kindHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
kindHelperId = await createTask(request, KIND_HELPER_NAME, 'kindness', 5)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId, kindHelperId], type: 'kindness' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (kindHelperId) await request.delete(`/api/task/${kindHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a kindness card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different kindness card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = kindnessSection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: KIND_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button opens modal with kindness name and "Assign new points" subtitle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(KIND_NAME)
|
||||
await expect(page.locator('.modal-subtitle')).toContainText('new points')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Input is pre-populated with the current point value', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(KIND_POINTS))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not save — points remain unchanged, no ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('77')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText(`${KIND_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a new value updates displayed points and shows ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('20')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('20 Points')
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('-5')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyActivateChild'
|
||||
const CHORE_NAME = 'ModifyActChore'
|
||||
const KIND_NAME = 'ModifyActKindness'
|
||||
const PEN_NAME = 'ModifyActPenalty'
|
||||
const REWARD_NAME = 'ModifyActReward'
|
||||
|
||||
const CHORE_POINTS = 10
|
||||
const KIND_POINTS = 10
|
||||
const PEN_POINTS = 10
|
||||
const REWARD_COST = 100
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
const { rewards } = (await list.json()) as { rewards: { name: string; id: string }[] }
|
||||
return rewards?.find((r) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function getPoints(page: Page): Promise<number> {
|
||||
const text = await page.locator('.points .value').textContent()
|
||||
return parseInt(text?.trim() ?? '0', 10)
|
||||
}
|
||||
|
||||
async function activateItem(itemCard: Locator): Promise<void> {
|
||||
await itemCard.click()
|
||||
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await itemCard.click()
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openChoreOverrideModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
await page.getByRole('button', { name: 'Edit Points' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Modified points take effect on activation', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
let penId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId], type: 'penalty' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('Modified chore points are awarded on activation (override 25, default 10)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit override to 25 via kebab → Edit Points
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openChoreOverrideModal(page, card)
|
||||
await page.locator('#custom-value').fill('25')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — activate chore and verify points awarded at override rate
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const choreCard = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await choreCard.waitFor({ state: 'visible' })
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(choreCard)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + 25))
|
||||
})
|
||||
|
||||
test('Modified kindness points are awarded on activation (override 8, default 10)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit override to 8 via edit button
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('8')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — activate and verify points use override value
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const kindCard = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await kindCard.waitFor({ state: 'visible' })
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(kindCard)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before + 8))
|
||||
})
|
||||
|
||||
test('Modified penalty points are deducted at override rate (override 3, default 10)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit penalty override to 3 via edit button
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('3')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — activate penalty and verify deduction uses override value
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const penCard = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await penCard.waitFor({ state: 'visible' })
|
||||
const before = await getPoints(page)
|
||||
|
||||
await activateItem(penCard)
|
||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
|
||||
await expect(page.locator('.points .value')).toHaveText(String(before - 3))
|
||||
})
|
||||
|
||||
test('Modified reward cost is reflected in points needed display (override 30)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Edit reward override to 30 via edit button
|
||||
// Child currently has 25 + 8 - 3 = 30 points from previous tests
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('30')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
|
||||
// Fresh navigation — verify reward shows "REWARD READY" (30pts held, 30 cost override)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const rewardCard = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await rewardCard.waitFor({ state: 'visible' })
|
||||
await expect(rewardCard.getByText('REWARD READY')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §5
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'OverrideSSEChild'
|
||||
const CHORE_NAME = 'OverrideSSEChore'
|
||||
const KIND_NAME = 'OverrideSSEKindness'
|
||||
const PEN_NAME = 'OverrideSSEPenalty'
|
||||
|
||||
const CHORE_POINTS = 10
|
||||
const KIND_POINTS = 15
|
||||
const PEN_POINTS = 8
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function kindnessSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Kindness Acts' }),
|
||||
})
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Override SSE updates child view', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindId = ''
|
||||
let penId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
|
||||
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId], type: 'penalty' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindId) await request.delete(`/api/task/${kindId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
})
|
||||
|
||||
test('Changing chore override points updates child view in real time', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Verify original points displayed
|
||||
await expect(card.getByText(`${CHORE_POINTS} Points`)).toBeVisible()
|
||||
|
||||
// Set override via API
|
||||
await request.put(`/api/child/${childId}/override`, {
|
||||
data: { entity_id: choreId, entity_type: 'task', custom_value: 25 },
|
||||
})
|
||||
|
||||
// Without page reload, verify the card updates
|
||||
await expect(card.getByText('25 Points')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Changing kindness override points updates child view', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText(`${KIND_POINTS} Points`)).toBeVisible()
|
||||
|
||||
await request.put(`/api/child/${childId}/override`, {
|
||||
data: { entity_id: kindId, entity_type: 'task', custom_value: 30 },
|
||||
})
|
||||
|
||||
await expect(card.getByText('30 Points')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Changing penalty override points updates child view', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByText(`${PEN_POINTS} Points`)).toBeVisible()
|
||||
|
||||
await request.put(`/api/child/${childId}/override`, {
|
||||
data: { entity_id: penId, entity_type: 'task', custom_value: 20 },
|
||||
})
|
||||
|
||||
await expect(card.getByText('20 Points')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Deleting an override reverts child view to original points', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Currently showing overridden value from earlier test
|
||||
await expect(card.getByText('25 Points')).toBeVisible()
|
||||
|
||||
// Delete the override
|
||||
await request.delete(`/api/child/${childId}/override/${choreId}`)
|
||||
|
||||
// Without page reload, verify the card reverts to original points
|
||||
await expect(card.getByText(`${CHORE_POINTS} Points`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyPenaltyChild'
|
||||
const PEN_NAME = 'ModifyTestPenalty'
|
||||
const PEN_HELPER_NAME = 'ModifyPenaltyHelper'
|
||||
const PEN_POINTS = 5
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
|
||||
return tasks?.find((t) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function penaltySection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Penalties' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Penalty edit points', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let penId = ''
|
||||
let penHelperId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
|
||||
penHelperId = await createTask(request, PEN_HELPER_NAME, 'penalty', PEN_POINTS)
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [penId, penHelperId], type: 'penalty' },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (penId) await request.delete(`/api/task/${penId}`)
|
||||
if (penHelperId) await request.delete(`/api/task/${penHelperId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a penalty card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button disappears when clicking a different penalty card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = penaltySection(page)
|
||||
const card = section.locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
const helperCard = section.locator('.item-card').filter({ hasText: PEN_HELPER_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await helperCard.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
|
||||
await helperCard.click()
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button opens modal with penalty name and "Assign new points" subtitle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(PEN_NAME)
|
||||
await expect(page.locator('.modal-subtitle')).toContainText('new points')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Input is pre-populated with the raw point value (positive, not negated)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
// The input holds the positive magnitude; the negative display is a UI convention
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(PEN_POINTS))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not change the display', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('99')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
// Penalty displays points negated: -5 Points
|
||||
await expect(card.locator('.item-points')).toContainText(`-${PEN_POINTS} Points`)
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a new value updates card to show negated override and ⭐ badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('12')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.locator('.item-points')).toContainText('-12 Points')
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('-1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §10
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'PendingResetChild'
|
||||
const CHORE_NAME = 'PendingResetChore'
|
||||
const REWARD_NAME = 'PendingResetReward'
|
||||
const CHORE_POINTS = 10
|
||||
const REWARD_COST = 20
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', {
|
||||
data: { name, description: 'E2E pending-reset test', cost },
|
||||
})
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Pending status resets on task/reward modification', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
|
||||
// Assign chore + reward
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [choreId], type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
|
||||
// Create interval schedule for chore (no deadline)
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('10.1 Editing chore points clears pending status', async ({ page, request }) => {
|
||||
// Create pending via child confirm-chore API
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Verify PENDING stamp is visible
|
||||
const stamp = card.locator('.chore-stamp')
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Edit the parent task's points via API (triggers SSE → clears pending)
|
||||
await request.put(`/api/task/${choreId}/edit`, { data: { points: CHORE_POINTS + 1 } })
|
||||
|
||||
// Wait for SSE to propagate — PENDING should disappear
|
||||
await expect(stamp).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.2 Changing chore schedule clears pending status', async ({ page, request }) => {
|
||||
// Create pending again (10.1 cleared the previous one)
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const stamp = card.locator('.chore-stamp')
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Change the schedule via API (triggers SSE → clears pending)
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 2,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(stamp).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('10.5 Editing task name only does NOT clear pending', async ({ page, request }) => {
|
||||
// Create pending again
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
const stamp = card.locator('.chore-stamp')
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Edit name only (no points/type change) — should NOT clear pending
|
||||
await request.put(`/api/task/${choreId}/edit`, { data: { name: CHORE_NAME } })
|
||||
|
||||
// Wait briefly for SSE to propagate, then verify PENDING is still there
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(stamp).toHaveText('PENDING')
|
||||
|
||||
// Clean up: cancel the pending confirmation for subsequent tests
|
||||
await request.post(`/api/child/${childId}/cancel-confirm-chore`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
})
|
||||
|
||||
test('10.4 Completed chores remain completed after point edit', async ({ page, request }) => {
|
||||
// Directly trigger-task (parent approval) → creates approved confirmation for scheduled chore
|
||||
await request.post(`/api/child/${childId}/trigger-task`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Verify COMPLETED is visible
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
|
||||
// Edit points — only status='pending' is cleared, not approved
|
||||
await request.put(`/api/task/${choreId}/edit`, { data: { points: CHORE_POINTS + 2 } })
|
||||
|
||||
// Wait for SSE, then verify COMPLETED stamp is still visible
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(card.getByText('COMPLETED')).toBeVisible()
|
||||
})
|
||||
|
||||
test('10.3 Editing reward cost clears pending reward request', async ({ page, request }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
// Create pending reward request via API
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: rewardId },
|
||||
})
|
||||
|
||||
// Wait for SSE → PENDING on reward (use .pending class to avoid matching reward name)
|
||||
const pendingBadge = card.locator('.pending')
|
||||
await expect(pendingBadge).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Edit reward cost via API → clears pending
|
||||
await request.put(`/api/reward/${rewardId}/edit`, { data: { cost: REWARD_COST + 5 } })
|
||||
|
||||
// Wait for SSE — PENDING should disappear
|
||||
await expect(pendingBadge).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
// spec: e2e/plans/task-modified.plan copy.md
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ModifyRewardChild'
|
||||
const REWARD_NAME = 'ModifyTestReward'
|
||||
const REWARD_COST = 100
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
|
||||
return children?.find((c) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
const { rewards } = (await list.json()) as { rewards: { name: string; id: string }[] }
|
||||
return rewards?.find((r) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function rewardSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function openEditModal(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Reward edit cost', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let rewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
})
|
||||
|
||||
test('Edit button appears on first click of a reward card', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await expect(card.getByTitle('Edit custom value')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit button opens modal with reward name and "Assign new cost" subtitle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
await expect(page.locator('.modal-title')).toHaveText(REWARD_NAME)
|
||||
await expect(page.locator('.modal-subtitle')).toContainText('new cost')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Input is pre-populated with the current cost', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await expect(page.locator('#custom-value')).toHaveValue(String(REWARD_COST))
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Cancel does not change the points needed display', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('999')
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
// Child has 0 points; reward cost 100 → "100 more points"
|
||||
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
|
||||
await expect(card.locator('.override-badge')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Saving a new cost updates the points needed and shows ⭐ badge', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('50')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
// Child still has 0 points; override cost 50 → "50 more points"
|
||||
await expect(card.getByText('50 more points')).toBeVisible()
|
||||
await expect(card.locator('.override-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Setting cost to 0 makes the reward immediately redeemable', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('0')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Negative value disables the Save button', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('-1')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
|
||||
await openEditModal(page, card)
|
||||
await page.locator('#custom-value').fill('10001')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.locator('#custom-value').fill('10000')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
|
||||
test('Editing a pending reward shows a warning dialog — Cancel aborts the edit', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Give child enough points to satisfy the original reward cost, then create a pending request
|
||||
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
||||
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('PENDING')).toBeVisible()
|
||||
|
||||
// First click → edit button visible
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
// PendingRewardDialog should appear warning about the pending state
|
||||
await expect(page.locator('.modal-message')).toContainText('pending')
|
||||
|
||||
// Clicking "No" closes the dialog without opening the override modal
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
await expect(page.locator('.modal-message')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Editing a pending reward — confirming the warning opens the override modal', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Pending state was established in the previous test; navigate fresh
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('PENDING')).toBeVisible()
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByTitle('Edit custom value').click()
|
||||
|
||||
// PendingRewardDialog visible
|
||||
await expect(page.locator('.modal-message')).toContainText('pending')
|
||||
|
||||
// Confirming cancels the pending request and opens the override modal
|
||||
await page.getByRole('button', { name: 'Yes' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 5000 })
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §6
|
||||
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ScrollEditChild'
|
||||
const CHORE_COUNT = 12
|
||||
const REWARD_COUNT = 6
|
||||
const CHORE_PREFIX = 'ScrollEditChore'
|
||||
const REWARD_PREFIX = 'ScrollEditReward'
|
||||
const NARROW_VIEWPORT = { width: 480, height: 800 }
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'scroll test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function isInViewport(locator: Locator): Promise<boolean> {
|
||||
return locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.left < (window.innerWidth || document.documentElement.clientWidth)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Scroll to edited task after save', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
const choreIds: string[] = []
|
||||
const rewardIds: string[] = []
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
|
||||
for (let i = 1; i <= CHORE_COUNT; i++) {
|
||||
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5 + i))
|
||||
}
|
||||
for (let i = 1; i <= REWARD_COUNT; i++) {
|
||||
rewardIds.push(await createReward(request, `${REWARD_PREFIX}${i}`, 10 + i))
|
||||
}
|
||||
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: rewardIds },
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of choreIds) await request.delete(`/api/task/${id}`)
|
||||
for (const id of rewardIds) await request.delete(`/api/reward/${id}`)
|
||||
})
|
||||
|
||||
test('Edited chore card scrolls into viewport after override save', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
// Target the last chore — it should be scrolled off-screen in a narrow viewport
|
||||
const targetName = `${CHORE_PREFIX}${CHORE_COUNT}`
|
||||
const targetCard = choreSection(page).locator('.item-card').filter({ hasText: targetName })
|
||||
await choreSection(page).locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
// Click the target chore to bring up the kebab menu (first click centers + shows menu)
|
||||
await targetCard.click()
|
||||
await expect(targetCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
|
||||
// Open the kebab menu → "Edit Points"
|
||||
await targetCard.getByRole('button', { name: 'Options' }).click()
|
||||
await page.getByText('Edit Points').click()
|
||||
|
||||
// Override modal should be visible with input
|
||||
const input = page.locator('#custom-value')
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
// Set a new value and save
|
||||
await input.fill('99')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// After SSE override event → list refreshes → card scrolls into view
|
||||
// Use expect.poll to retry, allowing scroll animation to complete
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await expect.poll(() => isInViewport(targetCard), { timeout: 3000 }).toBe(true)
|
||||
})
|
||||
|
||||
test('Edited reward card scrolls into viewport after override save', async ({ page }) => {
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
|
||||
// Target the last reward
|
||||
const targetName = `${REWARD_PREFIX}${REWARD_COUNT}`
|
||||
const targetCard = rewardSection(page).locator('.item-card').filter({ hasText: targetName })
|
||||
await rewardSection(page).locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
// Click the target reward to center it and show the edit button
|
||||
await targetCard.click()
|
||||
await expect(targetCard).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
|
||||
// Click the Edit button
|
||||
await targetCard.locator('.edit-button').click()
|
||||
|
||||
// Override modal should be visible
|
||||
const input = page.locator('#custom-value')
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
// Set a new value and save
|
||||
await input.fill('77')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// After SSE override event → list refreshes → card scrolls into view
|
||||
// Use expect.poll to retry, allowing scroll animation to complete
|
||||
await expect(targetCard).toBeVisible({ timeout: 5000 })
|
||||
await expect.poll(() => isInViewport(targetCard), { timeout: 3000 }).toBe(true)
|
||||
})
|
||||
})
|
||||
240
frontend/e2e/mode_parent/task-sorting/child-sort-order.spec.ts
Normal file
240
frontend/e2e/mode_parent/task-sorting/child-sort-order.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §8
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ChildSortChild'
|
||||
|
||||
// Scheduled chores — two with different deadline times
|
||||
const SCHED_EARLY = 'CSort1SchedEarly'
|
||||
const SCHED_LATE = 'CSort2SchedLate'
|
||||
// General chore (no schedule)
|
||||
const GENERAL_CHORE = 'CSort3General'
|
||||
// Pending chore (child confirmed)
|
||||
const PENDING_CHORE = 'CSort4Pending'
|
||||
// Non-today chore (should be hidden in child view)
|
||||
const NON_TODAY_CHORE = 'CSortHiddenNonToday'
|
||||
|
||||
// Rewards with different costs
|
||||
const PENDING_REWARD = 'CSortRewardPending'
|
||||
const CHEAP_REWARD = 'CSortRewardCheap'
|
||||
const EXPENSIVE_REWARD = 'CSortRewardExpensive'
|
||||
|
||||
const POINTS = 5
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
|
||||
function todayDayIndex(): number {
|
||||
return new Date().getDay()
|
||||
}
|
||||
|
||||
function nonTodayDayIndex(): number {
|
||||
return (new Date().getDay() + 1) % 7
|
||||
}
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: POINTS, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'sort test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Child mode sort order', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let schedEarlyId = ''
|
||||
let schedLateId = ''
|
||||
let generalChoreId = ''
|
||||
let pendingChoreId = ''
|
||||
let nonTodayChoreId = ''
|
||||
let pendingRewardId = ''
|
||||
let cheapRewardId = ''
|
||||
let expensiveRewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
|
||||
schedEarlyId = await createTask(request, SCHED_EARLY)
|
||||
schedLateId = await createTask(request, SCHED_LATE)
|
||||
generalChoreId = await createTask(request, GENERAL_CHORE)
|
||||
pendingChoreId = await createTask(request, PENDING_CHORE)
|
||||
nonTodayChoreId = await createTask(request, NON_TODAY_CHORE)
|
||||
|
||||
// Rewards: different costs so points_needed differs
|
||||
pendingRewardId = await createReward(request, PENDING_REWARD, 10)
|
||||
cheapRewardId = await createReward(request, CHEAP_REWARD, 15)
|
||||
expensiveRewardId = await createReward(request, EXPENSIVE_REWARD, 100)
|
||||
|
||||
const allChoreIds = [schedEarlyId, schedLateId, generalChoreId, pendingChoreId, nonTodayChoreId]
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: allChoreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [pendingRewardId, cheapRewardId, expensiveRewardId] },
|
||||
})
|
||||
|
||||
// Scheduled early: today with deadline at 23:00 (future deadline, low sort key)
|
||||
await request.put(`/api/child/${childId}/task/${schedEarlyId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: todayDayIndex(), hour: 23, minute: 0 }],
|
||||
default_hour: 23,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Scheduled late: today with deadline at 23:59 (future deadline, higher sort key)
|
||||
await request.put(`/api/child/${childId}/task/${schedLateId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: todayDayIndex(), hour: 23, minute: 59 }],
|
||||
default_hour: 23,
|
||||
default_minute: 59,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Non-today chore: scheduled for a different day only
|
||||
await request.put(`/api/child/${childId}/task/${nonTodayChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Pending chore: child confirms
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: pendingChoreId },
|
||||
})
|
||||
|
||||
// Pending reward: request it
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
|
||||
// General chore: no schedule needed
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [schedEarlyId, schedLateId, generalChoreId, pendingChoreId, nonTodayChoreId]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
for (const id of [pendingRewardId, cheapRewardId, expensiveRewardId]) {
|
||||
if (id) await request.delete(`/api/reward/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Child chore sort: scheduled today (earliest deadline) → general → pending', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Clear parent auth so the router allows /child routes
|
||||
await page.addInitScript(() => localStorage.removeItem('parentAuth'))
|
||||
await page.goto(`/child/${childId}`)
|
||||
const section = choreSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
// Scheduled for today (priority 0) sorted by earliest deadline → earliest first
|
||||
const earlyIdx = names.indexOf(SCHED_EARLY)
|
||||
const lateIdx = names.indexOf(SCHED_LATE)
|
||||
// General (priority 1)
|
||||
const generalIdx = names.indexOf(GENERAL_CHORE)
|
||||
// Pending (priority 2)
|
||||
const pendingIdx = names.indexOf(PENDING_CHORE)
|
||||
|
||||
expect(earlyIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(lateIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(generalIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Scheduled → earliest first
|
||||
expect(earlyIdx).toBeLessThan(lateIdx)
|
||||
// Scheduled before general
|
||||
expect(lateIdx).toBeLessThan(generalIdx)
|
||||
// General before pending
|
||||
expect(generalIdx).toBeLessThan(pendingIdx)
|
||||
|
||||
// Non-today chore should be hidden
|
||||
expect(names).not.toContain(NON_TODAY_CHORE)
|
||||
})
|
||||
|
||||
test('Child reward sort: pending first, then ascending by points needed', async ({ page }) => {
|
||||
// Clear parent auth so the router allows /child routes
|
||||
await page.addInitScript(() => localStorage.removeItem('parentAuth'))
|
||||
await page.goto(`/child/${childId}`)
|
||||
const section = rewardSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
const pendingIdx = names.indexOf(PENDING_REWARD)
|
||||
const cheapIdx = names.indexOf(CHEAP_REWARD)
|
||||
const expensiveIdx = names.indexOf(EXPENSIVE_REWARD)
|
||||
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(cheapIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(expensiveIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Pending first
|
||||
expect(pendingIdx).toBeLessThan(cheapIdx)
|
||||
expect(pendingIdx).toBeLessThan(expensiveIdx)
|
||||
|
||||
// Then ascending by points_needed (cheap reward costs 15, expensive 100; child has 50 points)
|
||||
// Points needed: cheap = max(0, 15-50) = 0; expensive = max(0, 100-50) = 50
|
||||
// So cheap should come before expensive
|
||||
expect(cheapIdx).toBeLessThan(expensiveIdx)
|
||||
})
|
||||
})
|
||||
224
frontend/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts
Normal file
224
frontend/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §7
|
||||
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'ParentSortChild'
|
||||
const PENDING_CHORE = 'Sort1Pending'
|
||||
const GENERAL_CHORE = 'Sort2General'
|
||||
const EXPIRED_CHORE = 'Sort3Expired'
|
||||
const COMPLETED_CHORE = 'Sort4Completed'
|
||||
const NON_TODAY_CHORE = 'Sort5NonToday'
|
||||
|
||||
const PENDING_REWARD = 'SortRewardPending'
|
||||
const NORMAL_REWARD = 'SortRewardNormal'
|
||||
|
||||
const POINTS = 5
|
||||
const REWARD_COST = 10
|
||||
const INITIAL_CHILD_POINTS = 50
|
||||
|
||||
function todayDayIndex(): number {
|
||||
return new Date().getDay()
|
||||
}
|
||||
|
||||
function nonTodayDayIndex(): number {
|
||||
return (new Date().getDay() + 1) % 7
|
||||
}
|
||||
|
||||
async function createChild(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
|
||||
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
|
||||
return id
|
||||
}
|
||||
|
||||
async function createTask(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: POINTS, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
async function createReward(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
cost: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'sort test', cost } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
||||
}
|
||||
|
||||
function choreSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
function rewardSection(page: Page) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Rewards' }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Parent mode sort order', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let pendingChoreId = ''
|
||||
let generalChoreId = ''
|
||||
let expiredChoreId = ''
|
||||
let completedChoreId = ''
|
||||
let nonTodayChoreId = ''
|
||||
let pendingRewardId = ''
|
||||
let normalRewardId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
|
||||
|
||||
pendingChoreId = await createTask(request, PENDING_CHORE)
|
||||
generalChoreId = await createTask(request, GENERAL_CHORE)
|
||||
expiredChoreId = await createTask(request, EXPIRED_CHORE)
|
||||
completedChoreId = await createTask(request, COMPLETED_CHORE)
|
||||
nonTodayChoreId = await createTask(request, NON_TODAY_CHORE)
|
||||
pendingRewardId = await createReward(request, PENDING_REWARD, REWARD_COST)
|
||||
normalRewardId = await createReward(request, NORMAL_REWARD, REWARD_COST)
|
||||
|
||||
const allChoreIds = [
|
||||
pendingChoreId,
|
||||
generalChoreId,
|
||||
expiredChoreId,
|
||||
completedChoreId,
|
||||
nonTodayChoreId,
|
||||
]
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: allChoreIds, type: 'chore' },
|
||||
})
|
||||
await request.put(`/api/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [pendingRewardId, normalRewardId] },
|
||||
})
|
||||
|
||||
// Expired chore: scheduled today with deadline already passed (00:01)
|
||||
await request.put(`/api/child/${childId}/task/${expiredChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: todayDayIndex(), hour: 0, minute: 1 }],
|
||||
default_hour: 0,
|
||||
default_minute: 1,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Completed chore: scheduled today (interval, no deadline), then trigger to approve
|
||||
await request.put(`/api/child/${childId}/task/${completedChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toLocaleDateString('en-CA'),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
await request.post(`/api/child/${childId}/trigger-task`, {
|
||||
data: { task_id: completedChoreId },
|
||||
})
|
||||
|
||||
// Non-today chore: scheduled for a different day only
|
||||
await request.put(`/api/child/${childId}/task/${nonTodayChoreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||
default_hour: 8,
|
||||
default_minute: 0,
|
||||
default_has_deadline: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Pending chore: child confirms → creates pending status
|
||||
await request.post(`/api/child/${childId}/confirm-chore`, {
|
||||
data: { task_id: pendingChoreId },
|
||||
})
|
||||
|
||||
// Pending reward: request it
|
||||
await request.post(`/api/child/${childId}/request-reward`, {
|
||||
data: { reward_id: pendingRewardId },
|
||||
})
|
||||
|
||||
// General chore: no schedule, no changes needed
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
for (const id of [
|
||||
pendingChoreId,
|
||||
generalChoreId,
|
||||
expiredChoreId,
|
||||
completedChoreId,
|
||||
nonTodayChoreId,
|
||||
]) {
|
||||
if (id) await request.delete(`/api/task/${id}`)
|
||||
}
|
||||
for (const id of [pendingRewardId, normalRewardId]) {
|
||||
if (id) await request.delete(`/api/reward/${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Parent chore sort: pending → general → expired → completed → unscheduled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = choreSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
// Expected order: pending(0) → general(1) → expired(2) → completed(3) → non-today(4)
|
||||
const pendingIdx = names.indexOf(PENDING_CHORE)
|
||||
const generalIdx = names.indexOf(GENERAL_CHORE)
|
||||
const expiredIdx = names.indexOf(EXPIRED_CHORE)
|
||||
const completedIdx = names.indexOf(COMPLETED_CHORE)
|
||||
const nonTodayIdx = names.indexOf(NON_TODAY_CHORE)
|
||||
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(generalIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(expiredIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(completedIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(nonTodayIdx).toBeGreaterThanOrEqual(0)
|
||||
|
||||
expect(pendingIdx).toBeLessThan(generalIdx)
|
||||
expect(generalIdx).toBeLessThan(expiredIdx)
|
||||
expect(expiredIdx).toBeLessThan(completedIdx)
|
||||
expect(completedIdx).toBeLessThan(nonTodayIdx)
|
||||
})
|
||||
|
||||
test('Parent reward sort: pending requests appear first', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
const section = rewardSection(page)
|
||||
await section.locator('.item-card').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await section.locator('.item-card .item-name').allTextContents()
|
||||
|
||||
const pendingIdx = names.indexOf(PENDING_REWARD)
|
||||
const normalIdx = names.indexOf(NORMAL_REWARD)
|
||||
|
||||
expect(pendingIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(normalIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(pendingIdx).toBeLessThan(normalIdx)
|
||||
})
|
||||
})
|
||||
75
frontend/e2e/mode_parent/tasks/chore-cancel.spec.ts
Normal file
75
frontend/e2e/mode_parent/tasks/chore-cancel.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Chore creation/edit cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Cancel chore creation', async ({ page }) => {
|
||||
// 1. From /parent/tasks/chores, start creating a chore
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
// expect: Create Chore form appears
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// 2. Fill in 'Test' name and '5' points then cancel
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Test')
|
||||
setVal('#points', '5')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
// expect: User is returned to the chores list (exact match to avoid custom copy)
|
||||
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
|
||||
// expect: No chore named 'Test' exists
|
||||
await expect(page.locator('text=Test')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel chore edit', async ({ page }) => {
|
||||
// 1. Locate a chore and open its edit form
|
||||
await page.goto('/parent/tasks/chores')
|
||||
// ensure item exists
|
||||
if (!(await page.getByText('Wash dishes', { exact: true }).count())) {
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Wash dishes')
|
||||
setVal('#points', '10')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
await page.getByText('Wash dishes', { exact: true }).click()
|
||||
|
||||
// 2. Modify the name then cancel
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name')
|
||||
if (el) {
|
||||
el.value = 'Should not save'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
// expect: Navigation returns to list (exact match)
|
||||
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
|
||||
// expect: Original values remain unchanged
|
||||
await expect(page.getByText('Wash dishes', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
43
frontend/e2e/mode_parent/tasks/chore-convert-default.spec.ts
Normal file
43
frontend/e2e/mode_parent/tasks/chore-convert-default.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Convert a default chore to a user item by editing', async ({ page }) => {
|
||||
// 1. Locate a default chore such as 'Clean your mess' in the chores list
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await expect(page.locator('text=Clean your mess')).toBeVisible()
|
||||
// expect: Item is visible and has no delete button
|
||||
await expect(
|
||||
page.locator('text=Clean your mess >> .. >> button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click the default chore itself to edit
|
||||
await page.click('text=Clean your mess')
|
||||
// expect: Edit form opens with the default values
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Clean your mess')
|
||||
|
||||
// 3. Change some properties (name and points) so Save becomes enabled
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Clean your mess (custom)')
|
||||
setVal('#points', '20')
|
||||
})
|
||||
// expect: Save button is enabled because form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
|
||||
// 4. Save the update
|
||||
await page.click('button:has-text("Save")')
|
||||
// expect: The chore now shows as editable and includes a delete option
|
||||
await expect(
|
||||
page.locator('text=Clean your mess >> .. >> button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: Item behaves like a custom chore
|
||||
})
|
||||
141
frontend/e2e/mode_parent/tasks/chore-create-edit.spec.ts
Normal file
141
frontend/e2e/mode_parent/tasks/chore-create-edit.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Chore management', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test('Create a new chore (parent mode)', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Wash dishes ${suffix}`
|
||||
// 1. Navigate to /parent/tasks/chores
|
||||
await page.goto('/parent/tasks/chores')
|
||||
// expect: The parent dashboard loads and shows the chores list
|
||||
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
|
||||
|
||||
// 2. Click the 'Create Chore' FAB
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
// expect: The Create Chore form is displayed with fields for name and points
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// 3. Enter the chore name and points using DOM events
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '10')
|
||||
}, name)
|
||||
// expect: Submit/Create button becomes enabled
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
|
||||
// 5. Click the Create button
|
||||
await page.click('button:has-text("Create")')
|
||||
// expect: No validation errors are shown
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
// expect: Navigation returns to /parent/chores (or dashboard)
|
||||
await expect(page).toHaveURL(/\/parent/)
|
||||
// expect: The new chore appears in the chores list
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing chore', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `Wash dishes ${suffix}`
|
||||
const updated = `Wash car ${suffix}`
|
||||
// 1. Ensure there is at least one chore in the list (create one if necessary)
|
||||
await page.goto('/parent/tasks/chores')
|
||||
if (!(await page.locator(`text=${original}`).count())) {
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '10')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
// expect: The chore appears in the list
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
|
||||
// 2. Click the chore row to edit
|
||||
await page.click(`text=${original}`)
|
||||
// expect: Edit Chore form appears (loaded with current values)
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// 3. Change the Name to 'Wash car' and Points to '15' via DOM events
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '15')
|
||||
}, updated)
|
||||
// expect: Fields are updated with the new values
|
||||
|
||||
// 4. Click Save/Update
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
// expect: No errors are displayed
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
// expect: Navigation returns to chores list
|
||||
await expect(page).toHaveURL(/\/parent/)
|
||||
// expect: The chore now reads updated name with 15 points
|
||||
await expect(page.locator(`text=${updated}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete a chore', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Wash car ${suffix}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '15')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
|
||||
// delete using row-local button then confirm via modal
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.first()
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
// modal appears with a warning message
|
||||
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
|
||||
// click the red danger button inside the modal (labelled Delete)
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
92
frontend/e2e/mode_parent/tasks/chore-delete-default.spec.ts
Normal file
92
frontend/e2e/mode_parent/tasks/chore-delete-default.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Edit default chore "Take out trash" and verify system chore restoration on delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
|
||||
// Cleanup: if a previous run left a modified 'Take out trash' (with delete icon), remove it first
|
||||
while (
|
||||
(await page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.count()) > 0
|
||||
) {
|
||||
await page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
}
|
||||
|
||||
// 1. Verify 'Take out trash' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Take out trash', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click 'Take out trash' to open the edit form and change points
|
||||
await page.getByText('Take out trash', { exact: true }).click()
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Take out trash')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#points', '5')
|
||||
})
|
||||
|
||||
// expect: Save button becomes enabled because the form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// 3. Verify exactly one 'Take out trash' is in the list and it now has a delete icon
|
||||
await expect(page.getByText('Take out trash', { exact: true })).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: points display reflects the updated value
|
||||
await expect(
|
||||
page.getByText('Take out trash', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('5 pts')
|
||||
|
||||
// 4. Delete the modified 'Take out trash' and verify the system default is restored
|
||||
await page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// expect: 'Take out trash' is still on the list (system default restored)
|
||||
await expect(page.getByText('Take out trash', { exact: true })).toBeVisible()
|
||||
// expect: no delete icon (it's a system default again)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Take out trash', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
// expect: original points (20 pts) are restored
|
||||
await expect(
|
||||
page.getByText('Take out trash', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('20 pts')
|
||||
})
|
||||
63
frontend/e2e/mode_parent/tasks/kindness-cancel.spec.ts
Normal file
63
frontend/e2e/mode_parent/tasks/kindness-cancel.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Kindness act cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Cancel kindness act creation', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Test')
|
||||
setVal('#points', '5')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.locator('text=Should not save')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel kindness act edit', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
if (!(await page.getByText('Share toys', { exact: true }).count())) {
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Share toys')
|
||||
setVal('#points', '5')
|
||||
})
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
}
|
||||
// open edit by clicking row
|
||||
await page.getByText('Share toys', { exact: true }).click()
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name')
|
||||
if (el) {
|
||||
el.value = 'Never saved'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.getByText('Share toys', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
136
frontend/e2e/mode_parent/tasks/kindness-create-edit.spec.ts
Normal file
136
frontend/e2e/mode_parent/tasks/kindness-create-edit.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Kindness act management', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
// avoid parallel execution within this file; shared backend state and SSE events
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test('Create a new kindness act (parent mode)', async ({ page }) => {
|
||||
// use a unique name so repeated runs don't collide
|
||||
const suffix = Date.now()
|
||||
const name = `Share toys ${suffix}`
|
||||
|
||||
// 1. navigate once and switch to kindness tab
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
await expect(page.locator('text=Kindness Acts')).toBeVisible()
|
||||
|
||||
// 2. open form
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await expect(page.locator('text=Name')).toBeVisible()
|
||||
|
||||
// 3‑4. fill using evaluate helper
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '5')
|
||||
}, name)
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
|
||||
// 5. submit and assert the new row is visible
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator('.error')).toHaveCount(0)
|
||||
await expect(page.locator(`text=${name}`).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing kindness act', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `Share toys ${suffix}`
|
||||
const updated = `Help with homework ${suffix}`
|
||||
|
||||
// navigate and create fresh item
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '5')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${original}`).first()).toBeVisible()
|
||||
|
||||
// 2. open edit by clicking first matching row
|
||||
await page.locator(`text=${original}`).first().click()
|
||||
// wait for edit form to appear
|
||||
await expect(page.locator('text=Name')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
|
||||
// 3. update values once form is ready
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '8')
|
||||
}, updated)
|
||||
|
||||
// 4. save and verify (wait until button is enabled)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator(`text=${updated}`).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete a kindness act', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const name = `Help with homework ${suffix}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
// create fresh item
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.evaluate((name) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', name)
|
||||
setVal('#points', '8')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`).first()).toBeVisible()
|
||||
|
||||
// click the delete button on the first matching row
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.first()
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
// confirmation modal should appear
|
||||
await expect(
|
||||
page.locator('text=Are you sure you want to delete this kindness act?'),
|
||||
).toBeVisible()
|
||||
// click the red danger button inside the modal
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
134
frontend/e2e/mode_parent/tasks/kindness-default.spec.ts
Normal file
134
frontend/e2e/mode_parent/tasks/kindness-default.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// Merged from kindness-convert-default.spec.ts and kindness-delete-default.spec.ts.
|
||||
// Both tests touch "Be good for the day" so they MUST run serially.
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
async function cleanupBeGoodTasks(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.get(`${BACKEND}/task/list`)
|
||||
if (!res.ok()) return
|
||||
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
|
||||
await res.json()
|
||||
for (const task of tasks) {
|
||||
if (
|
||||
task.user_id !== null &&
|
||||
(task.name === 'Be good today (edited)' || task.name === 'Be good for the day')
|
||||
) {
|
||||
await request.delete(`${BACKEND}/task/${task.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Default kindness act management', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await cleanupBeGoodTasks(request)
|
||||
})
|
||||
|
||||
test('Convert a default kindness act to a user item by editing', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Kindness Acts')
|
||||
// find a default act
|
||||
await expect(page.locator('text=Be good for the day')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('text=Be good for the day >> .. >> button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// edit it — rename so there is no name collision with the delete-default test
|
||||
await page.click('text=Be good for the day')
|
||||
await page.locator('#name').fill('Be good today (edited)')
|
||||
await page.locator('#points').fill('7')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// renamed item should now be deletable
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good today (edited)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
|
||||
// clean up: delete the user item so the next test sees "Be good for the day" as a system default
|
||||
await page
|
||||
.getByText('Be good today (edited)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
})
|
||||
|
||||
test('Edit default kindness act "Be good for the day" and verify system act restoration on delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByText('Kindness Acts').click()
|
||||
|
||||
// 1. Verify 'Be good for the day' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click 'Be good for the day' to open the edit form and change points
|
||||
await page.getByText('Be good for the day', { exact: true }).click()
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Be good for the day')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#points', '3')
|
||||
})
|
||||
|
||||
// expect: Save button becomes enabled because the form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// 3. Verify exactly one 'Be good for the day' is in the list and it now has a delete icon
|
||||
await expect(page.getByText('Be good for the day', { exact: true })).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: points display reflects the updated value
|
||||
await expect(
|
||||
page.getByText('Be good for the day', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('3 pts')
|
||||
|
||||
// 4. Delete the modified 'Be good for the day' and verify the system default is restored
|
||||
await page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// expect: 'Be good for the day' is still on the list (system default restored)
|
||||
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
|
||||
// expect: no delete icon (it's a system default again)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Be good for the day', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
// expect: original points (15 pts) are restored
|
||||
await expect(
|
||||
page.getByText('Be good for the day', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('15 pts')
|
||||
})
|
||||
})
|
||||
55
frontend/e2e/mode_parent/tasks/penalty-cancel.spec.ts
Normal file
55
frontend/e2e/mode_parent/tasks/penalty-cancel.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Penalty creation/edit cancellation', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Cancel penalty creation', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Should not save')
|
||||
setVal('#points', '10')
|
||||
})
|
||||
// cancel before submitting the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.locator('text=Should not save')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Cancel penalty edit', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
if (!(await page.getByText('No screen time', { exact: true }).count())) {
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.locator('#name').fill('No screen time')
|
||||
await page.locator('#points').fill('10')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.getByText('No screen time', { exact: true })).toBeVisible()
|
||||
}
|
||||
// open edit by clicking row
|
||||
await page.getByText('No screen time', { exact: true }).click()
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#name')
|
||||
if (el) {
|
||||
el.value = 'Never saved'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(page.getByText('No screen time', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
114
frontend/e2e/mode_parent/tasks/penalty-create-edit.spec.ts
Normal file
114
frontend/e2e/mode_parent/tasks/penalty-create-edit.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Penalty management', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||
})
|
||||
|
||||
test('Create a new penalty (parent mode)', async ({ page }) => {
|
||||
const name = `No screen time ${Date.now()}`
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
await expect(page.locator('text=Penalties')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '30')
|
||||
}, name)
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit an existing penalty', async ({ page }) => {
|
||||
const suffix = Date.now()
|
||||
const original = `No screen time ${suffix}`
|
||||
const updated = `No dessert ${suffix}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
|
||||
// create the item to edit
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '30')
|
||||
}, original)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${original}`)).toBeVisible()
|
||||
|
||||
// open edit by clicking the row itself
|
||||
await page.click(`text=${original}`)
|
||||
// wait for the edit form to finish loading data from the API
|
||||
await expect(page.locator('#name')).toHaveValue(original)
|
||||
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '20')
|
||||
}, updated)
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.locator(`text=${updated}`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete a penalty', async ({ page }) => {
|
||||
const name = `No dessert ${Date.now()}`
|
||||
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.evaluate((n) => {
|
||||
const setVal = (sel, val) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', n)
|
||||
setVal('#points', '20')
|
||||
}, name)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.locator(`text=${name}`)).toBeVisible()
|
||||
|
||||
await page
|
||||
.locator(`text=${name}`)
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Delete item' })
|
||||
.click()
|
||||
await page.locator('button.btn-danger:has-text("Delete")').click()
|
||||
await expect(page.locator(`text=${name}`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
136
frontend/e2e/mode_parent/tasks/penalty-default.spec.ts
Normal file
136
frontend/e2e/mode_parent/tasks/penalty-default.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
||||
// Merged from penalty-convert-default.spec.ts and penalty-delete-default.spec.ts.
|
||||
// Both tests touch "Fighting" so they MUST run serially.
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
async function cleanupFightingTasks(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.get(`${BACKEND}/task/list`)
|
||||
if (!res.ok()) return
|
||||
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
|
||||
await res.json()
|
||||
for (const task of tasks) {
|
||||
if (task.user_id !== null && (task.name === 'Fighting (custom)' || task.name === 'Fighting')) {
|
||||
await request.delete(`${BACKEND}/task/${task.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Default penalty management', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await cleanupFightingTasks(request)
|
||||
})
|
||||
|
||||
test('Convert a default penalty to a user item by editing', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.click('text=Penalties')
|
||||
|
||||
// locate default penalty
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// edit it (click the item itself)
|
||||
await page.getByText('Fighting', { exact: true }).click()
|
||||
await page.locator('#name').fill('Fighting (custom)')
|
||||
await page.locator('#points').fill('15')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// now should have delete option
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
|
||||
// clean up: delete so the next test sees 'Fighting' as a system default again
|
||||
await page
|
||||
.getByText('Fighting (custom)', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
})
|
||||
|
||||
test('Edit default penalty "Fighting" and verify system penalty restoration on delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await page.getByText('Penalties').click()
|
||||
|
||||
// 1. Verify 'Fighting' is the system default (visible, no delete icon)
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// 2. Click 'Fighting' to open the edit form and change points
|
||||
await page.getByText('Fighting', { exact: true }).click()
|
||||
await expect(page.locator('input#name').inputValue()).resolves.toBe('Fighting')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#points', '3')
|
||||
})
|
||||
|
||||
// expect: Save button becomes enabled because the form is dirty
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// 3. Verify exactly one 'Fighting' is in the list and it now has a delete icon
|
||||
await expect(page.getByText('Fighting', { exact: true })).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toBeVisible()
|
||||
// expect: points display reflects the updated value
|
||||
await expect(
|
||||
page.getByText('Fighting', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('3 pts')
|
||||
|
||||
// 4. Delete the modified 'Fighting' and verify the system default is restored
|
||||
await page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]')
|
||||
.click()
|
||||
await expect(page.locator('text=Are you sure')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// expect: 'Fighting' is still on the list (system default restored)
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
// expect: no delete icon (it's a system default again)
|
||||
await expect(
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
// expect: original points (10 pts) are restored
|
||||
await expect(
|
||||
page.getByText('Fighting', { exact: true }).locator('..').locator('.value'),
|
||||
).toHaveText('10 pts')
|
||||
})
|
||||
})
|
||||
51
frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts
Normal file
51
frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Simple chore creation test', async ({ page }) => {
|
||||
// Navigate to chores
|
||||
await page.goto('/parent/tasks/chores')
|
||||
|
||||
// Click the Create Chore FAB
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
|
||||
// Wait for form to load
|
||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
||||
|
||||
// Fill form using custom evaluation
|
||||
await page.evaluate(() => {
|
||||
// Fill name
|
||||
const nameInput = document.querySelector('input[type="text"], input:not([type])')
|
||||
if (nameInput) {
|
||||
nameInput.value = 'Simple Chore Test'
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
nameInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Fill points
|
||||
const pointsInput = document.querySelector('input[type="number"]')
|
||||
if (pointsInput) {
|
||||
pointsInput.value = '5'
|
||||
pointsInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
pointsInput.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Click first image to select it
|
||||
const firstImage = document.querySelector('img[alt*="Image"]')
|
||||
if (firstImage) {
|
||||
firstImage.click()
|
||||
}
|
||||
})
|
||||
|
||||
// Wait a moment for validation to trigger
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Click Create button
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
// Verify we're back on the list page and item was created
|
||||
// locate the name element, then move up to the row container
|
||||
const choreName = page.locator('text=Simple Chore Test').first()
|
||||
const choreRow = choreName.locator('..')
|
||||
await expect(choreRow).toBeVisible()
|
||||
// the row container should display the correct points value
|
||||
await expect(choreRow.locator('text=5 pts')).toBeVisible()
|
||||
})
|
||||
181
frontend/e2e/mode_parent/user-profile/change-pin.spec.ts
Normal file
181
frontend/e2e/mode_parent/user-profile/change-pin.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
const NEW_PIN = '5678'
|
||||
|
||||
async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> {
|
||||
// Request a new code, retrieve it via test endpoint, verify it, then set the pin
|
||||
await request.post(`${BACKEND}/user/request-pin-setup`)
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await request.post(`${BACKEND}/user/verify-pin-setup`, { data: { code } })
|
||||
await request.post(`${BACKEND}/user/set-pin`, { data: { pin } })
|
||||
}
|
||||
|
||||
test.describe('User Profile – Change Parent PIN', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Restore original PIN so subsequent test runs start fresh
|
||||
await setPinDirectly(request, E2E_PIN)
|
||||
})
|
||||
|
||||
test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
await expect(page.getByRole('heading', { name: 'Set up your Parent PIN' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back from PIN setup page returns to profile', async ({ page }) => {
|
||||
// Navigate from the profile page so browser history exists
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Send Verification Code advances to step 2', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeInput = page.getByPlaceholder('6-digit code')
|
||||
await expect(codeInput).toBeVisible()
|
||||
|
||||
// Verify Code is disabled while input is empty
|
||||
await expect(page.getByRole('button', { name: 'Verify Code' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Resend Code button appears after delay', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
// According to the component, resend appears after 10 seconds
|
||||
await expect(page.getByRole('button', { name: 'Resend Code' })).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('Invalid code shows error', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('6-digit code').fill('ZZZZZZ')
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
|
||||
await expect(page.getByText(/invalid code/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Valid code advances to step 3 (Create PIN)', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
// Retrieve the code via the test-only endpoint
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
await expect(page.getByPlaceholder('New PIN')).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Confirm PIN')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN disabled when PINs do not match', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill('1111')
|
||||
await page.getByPlaceholder('Confirm PIN').fill('2222')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN disabled for fewer than 4 digits', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill('12')
|
||||
await page.getByPlaceholder('Confirm PIN').fill('12')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN succeeds with matching 4-digit PIN – shows success step', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill(NEW_PIN)
|
||||
await page.getByPlaceholder('Confirm PIN').fill(NEW_PIN)
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Set PIN' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Parent PIN Set!' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back from success step exits parent mode and goes to child selector', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill(NEW_PIN)
|
||||
await page.getByPlaceholder('Confirm PIN').fill(NEW_PIN)
|
||||
await page.getByRole('button', { name: 'Set PIN' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Parent PIN Set!' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/child(\/|$)/)
|
||||
// Parent mode exited – "Parent login" button visible instead of parent menu
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
109
frontend/e2e/mode_parent/user-profile/delete-account.spec.ts
Normal file
109
frontend/e2e/mode_parent/user-profile/delete-account.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
test.describe('User Profile – Delete Account', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
await expect(page.locator('#confirmEmail')).toBeVisible()
|
||||
await expect(page.locator('#confirmEmail')).toHaveValue('')
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Cancel' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete button stays disabled for incomplete email', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill('e2e@')
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button enables when matching email is entered', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill(E2E_DELETE_EMAIL)
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Cancel closes the dialog without deleting the account', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Dialog is gone, still on profile page
|
||||
await expect(
|
||||
page.locator('.modal-title', { hasText: 'Delete Your Account?' }),
|
||||
).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Backdrop click does NOT close the delete dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
// Click the backdrop (outside the modal-dialog box)
|
||||
await page.locator('.modal-backdrop').click({ position: { x: 10, y: 10 }, force: true })
|
||||
|
||||
// Dialog must still be visible – the delete warning has no backdrop-click listener
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful deletion: confirmation modal appears then redirects to landing page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill(E2E_DELETE_EMAIL)
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// Confirmation modal appears
|
||||
await expect(page.locator('.modal-title', { hasText: 'Account Deleted' })).toBeVisible()
|
||||
await expect(page.getByText(/marked for deletion/i)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Redirect to landing page
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.getByRole('button', { name: 'Sign In' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Login after deletion is blocked with an appropriate error', async ({ page }) => {
|
||||
await page.goto('/auth/login')
|
||||
|
||||
await page.getByLabel('Email address').fill(E2E_DELETE_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_DELETE_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
// Login must fail with a deletion-related error message
|
||||
await expect(page.getByText(/marked for deletion/i)).toBeVisible({ timeout: 8000 })
|
||||
// URL stays on the login page
|
||||
await expect(page).toHaveURL(/\/auth\/login/)
|
||||
})
|
||||
})
|
||||
241
frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts
Normal file
241
frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { E2E_EMAIL, E2E_FIRST_NAME } from '../../e2e-constants'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
interface ProfileData {
|
||||
first_name: string
|
||||
last_name: string
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
async function getProfile(request: APIRequestContext): Promise<ProfileData> {
|
||||
const res = await request.get(`${BACKEND}/user/profile`)
|
||||
const data = await res.json()
|
||||
return { first_name: data.first_name, last_name: data.last_name, image_id: data.image_id ?? null }
|
||||
}
|
||||
|
||||
async function restoreProfile(request: APIRequestContext, profile: ProfileData): Promise<void> {
|
||||
await request.put(`${BACKEND}/user/profile`, {
|
||||
data: {
|
||||
first_name: profile.first_name,
|
||||
last_name: profile.last_name,
|
||||
image_id: profile.image_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('User Profile – editing', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let originalProfile: ProfileData
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
originalProfile = await getProfile(request)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreProfile(request, originalProfile)
|
||||
})
|
||||
|
||||
test('Profile page loads with correct data', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('Tester')
|
||||
await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL)
|
||||
await expect(page.getByLabel('Email Address')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Back button navigates back', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Navigate in-app via the profile dropdown so Vue Router has a real history entry.
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
await expect(page).toHaveURL('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/parent')
|
||||
})
|
||||
|
||||
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('First Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save enables when a name is changed', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
await page.getByLabel('Last Name').fill('UpdatedTester')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog.locator('.modal-title', { hasText: 'Profile Updated' })).toBeVisible()
|
||||
await expect(dialog.getByText('Your profile was updated successfully.')).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// OK navigates back; go back to profile to verify persistence
|
||||
await page.goto('/parent/profile')
|
||||
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
||||
})
|
||||
|
||||
test('Cancel discards unsaved changes', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('Discarded')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Navigate back to verify no changes were saved
|
||||
await page.goto('/parent/profile')
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
})
|
||||
|
||||
test('Email field is read-only', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
const emailInput = page.locator('#email')
|
||||
await expect(emailInput).toBeDisabled()
|
||||
await expect(emailInput).toHaveValue(E2E_EMAIL)
|
||||
})
|
||||
|
||||
test('Change profile image (built-in)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
// Wait for images to load
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
// Pick the second image in the list (index 1), regardless of which is selected
|
||||
const images = page.locator('.selectable-image')
|
||||
const count = await images.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
// Find first image that is NOT currently selected
|
||||
let targetIndex = -1
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cls = await images.nth(i).getAttribute('class')
|
||||
if (!cls?.includes('selected')) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(targetIndex).toBeGreaterThanOrEqual(0)
|
||||
|
||||
await images.nth(targetIndex).click()
|
||||
|
||||
// Confirm it is now selected
|
||||
await expect(images.nth(targetIndex)).toHaveClass(/selected/)
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Re-visit and confirm selection persists
|
||||
await page.goto('/parent/profile')
|
||||
await page.waitForSelector('.selectable-image')
|
||||
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
||||
expect(selectedSrc).toBeTruthy()
|
||||
// The URL will differ after a new load (Object URL vs cached), so verify via API
|
||||
const profile = await getProfile(page.request)
|
||||
expect(profile.image_id).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Upload a custom profile image', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('button', { name: 'Add from device' }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(TEST_IMAGE)
|
||||
|
||||
// The uploaded image appears first in the list and is selected
|
||||
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
})
|
||||
|
||||
test('Change Password shows email-sent modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Change Password' }).click()
|
||||
|
||||
// Modal appears (title can transition quickly from loading to success)
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.locator('.modal-title')).toContainText(
|
||||
/Change Password|Password Change Email Sent|Password Change Failed/,
|
||||
)
|
||||
|
||||
// Eventually becomes the confirmed state
|
||||
await expect(
|
||||
dialog.locator('.modal-title', { hasText: 'Password Change Email Sent' }),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(dialog.locator('.modal-message')).toContainText(/password change link/i)
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Modal dismissed, back on the profile page
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
281
frontend/e2e/plans/bugs-1.0.6-set01.plan.md
Normal file
281
frontend/e2e/plans/bugs-1.0.6-set01.plan.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# E2E Test Plan — Bug Fixes 1.0.6 Set 1
|
||||
|
||||
Covers the bug fixes from `.github/specs/bugs-1.0.6-01/bugs-1.0.6-set01.md`. Each section proposes new E2E specs or additions to existing specs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Assign View Shows Child Name
|
||||
|
||||
**File:** `e2e/mode_parent/task-assignment/assign-title-shows-child-name.spec.ts`
|
||||
**Project:** `chromium-task-assignment`
|
||||
|
||||
**Seed:** Create an isolated child with a known name via API. Create at least one chore, reward, kindness, and penalty task.
|
||||
|
||||
#### 1.1. "Assign Chores" heading contains child name
|
||||
|
||||
- Navigate to `/parent/{childId}/assign-chores?name={childName}`
|
||||
- Verify the heading text includes the child's name
|
||||
|
||||
#### 1.2. "Assign Rewards" heading contains child name
|
||||
|
||||
- Navigate to `/parent/{childId}/assign-rewards?name={childName}`
|
||||
- Verify heading
|
||||
|
||||
#### 1.3. "Assign Kindnesses" heading contains child name
|
||||
|
||||
- Navigate to `/parent/{childId}/assign-kindness?name={childName}`
|
||||
- Verify heading
|
||||
|
||||
#### 1.4. "Assign Penalties" heading contains child name
|
||||
|
||||
- Navigate to `/parent/{childId}/assign-penalties?name={childName}`
|
||||
- Verify heading
|
||||
|
||||
---
|
||||
|
||||
## 2. General Chore Approval — No COMPLETED Stamp
|
||||
|
||||
**File:** `e2e/mode_parent/task-activation/general-chore-no-completed.spec.ts`
|
||||
**Project:** `chromium-task-activation`
|
||||
|
||||
**Seed:** Create an isolated child, one general (unscheduled) chore, and one scheduled chore. Assign both to the child.
|
||||
|
||||
#### 2.1. Approving a general chore awards points
|
||||
|
||||
- Click general chore → confirm in dialog → approve
|
||||
- Verify child's points increase by chore value
|
||||
|
||||
#### 2.2. General chore does NOT show COMPLETED stamp after approval
|
||||
|
||||
- After approval, verify the chore card does not contain "COMPLETED" text
|
||||
|
||||
#### 2.3. General chore can be triggered again immediately after approval
|
||||
|
||||
- Click the same general chore again
|
||||
- Verify the confirmation dialog re-appears (chore is re-triggerable)
|
||||
|
||||
#### 2.4. Scheduled chore DOES show COMPLETED after approval (contrast)
|
||||
|
||||
- Trigger and approve the scheduled chore
|
||||
- Verify "COMPLETED" stamp is visible on that card
|
||||
|
||||
---
|
||||
|
||||
## 3. Extend Time Removes TOO LATE Banner
|
||||
|
||||
**Existing file:** `e2e/mode_parent/chore-scheduler/extend-time.spec.ts`
|
||||
|
||||
The existing spec already has tests for "Extend Time removes TOO LATE badge" and "gray styling removed." The bug fix ensures `refresh()` is called immediately, so the existing tests should pass without page reload. If not already asserted:
|
||||
|
||||
#### 3.1. TOO LATE disappears without page reload (addition to existing spec)
|
||||
|
||||
- After clicking "Extend Time," assert TOO LATE text is hidden within 3 seconds
|
||||
- Do NOT call `page.reload()` between extension and assertion
|
||||
|
||||
---
|
||||
|
||||
## 4. Notification Card Overflow
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/notification-card-overflow.spec.ts`
|
||||
**Project:** `chromium-task-activation` or new `chromium-notifications`
|
||||
|
||||
**Seed:** Create child + a chore with a very long name (50+ characters). Assign and trigger it so a notification is created.
|
||||
|
||||
#### 4.1. Notification icon stays within card boundaries
|
||||
|
||||
- Navigate to notifications view
|
||||
- Get the bounding box of the notification card and its icon
|
||||
- Assert the icon's bounding box is fully contained within the card's bounding box
|
||||
|
||||
#### 4.2. Long notification text does not visually overflow
|
||||
|
||||
- Assert the card element has appropriate overflow CSS (`overflow: hidden` or similar)
|
||||
- Optionally use `toHaveScreenshot()` for visual regression
|
||||
|
||||
**Note:** This test benefits from visual regression testing (`expect(page).toHaveScreenshot()`) more than functional assertions, since overflow is inherently visual.
|
||||
|
||||
---
|
||||
|
||||
## 5. Override SSE Updates Child View
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/override-sse-child-update.spec.ts`
|
||||
**Project:** `chromium-task-modification`
|
||||
|
||||
**Seed:** Create child + one chore, one kindness, one penalty. Assign all to the child.
|
||||
|
||||
#### 5.1. Changing chore override points updates child view in real time
|
||||
|
||||
- Open child view in the browser (`goToChildView`)
|
||||
- Via API, set a point override on the chore: `PUT /api/child/{id}/task/{taskId}/override`
|
||||
- Without page reload, verify the chore card displays the new point value
|
||||
|
||||
#### 5.2. Changing kindness override points updates child view
|
||||
|
||||
- Same pattern for kindness task — set override via API, assert child view updates
|
||||
|
||||
#### 5.3. Changing penalty override points updates child view
|
||||
|
||||
- Same pattern for penalty task
|
||||
|
||||
#### 5.4. Deleting an override reverts child view to original points
|
||||
|
||||
- Remove the override via API
|
||||
- Verify child view reverts to original point value without reload
|
||||
|
||||
---
|
||||
|
||||
## 6. Scroll to Edited Task After Save
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/scroll-to-edited-task.spec.ts`
|
||||
**Project:** `chromium-task-modification`
|
||||
|
||||
**Seed:** Create child + 10–15 chores and 5+ rewards (enough to require both vertical and horizontal scrolling). Assign all to the child.
|
||||
|
||||
#### 6.1. Edited chore card scrolls into viewport after override save
|
||||
|
||||
- Navigate to parent child view
|
||||
- Open override modal on a chore that requires scrolling to reach
|
||||
- Save a new point value
|
||||
- Assert the edited card `toBeInViewport()` after the list refreshes
|
||||
|
||||
#### 6.2. Edited reward card scrolls into viewport after override save
|
||||
|
||||
- Open override modal on a distant reward
|
||||
- Save, assert card `toBeInViewport()`
|
||||
|
||||
---
|
||||
|
||||
## 7. Task Sorting — Parent Mode
|
||||
|
||||
**File:** `e2e/mode_parent/task-sorting/parent-sort-order.spec.ts`
|
||||
**Project:** New `chromium-task-sorting` or `chromium-chore-scheduler`
|
||||
|
||||
**Seed:** Create child + 5 chores in different states:
|
||||
|
||||
1. A pending chore (child triggered, not yet approved)
|
||||
2. A general (unscheduled) chore
|
||||
3. An expired scheduled chore (today, deadline in the past → TOO LATE)
|
||||
4. A completed scheduled chore (triggered + approved)
|
||||
5. A chore scheduled on a non-today day (unscheduled for today)
|
||||
|
||||
Also create 2+ rewards: one with a pending request, one without.
|
||||
|
||||
#### 7.1. Parent chore sort: pending → general → expired → completed → unscheduled
|
||||
|
||||
- Navigate to parent child view
|
||||
- Read all chore card names in DOM order
|
||||
- Assert the order matches: pending chore first, then general, then expired, then completed, then unscheduled-today
|
||||
|
||||
#### 7.2. Parent reward sort: pending requests appear first
|
||||
|
||||
- Read all reward card names in DOM order
|
||||
- Assert the pending-request reward appears before other rewards
|
||||
|
||||
---
|
||||
|
||||
## 8. Task Sorting — Child Mode
|
||||
|
||||
**File:** `e2e/mode_parent/task-sorting/child-sort-order.spec.ts`
|
||||
**Project:** Same as §7
|
||||
|
||||
**Seed:** Same child + tasks as §7, plus rewards with varying costs.
|
||||
|
||||
#### 8.1. Child chore sort: scheduled today (earliest deadline first) → general → pending
|
||||
|
||||
- Switch to child view (`goToChildView`)
|
||||
- Read all visible chore card names in DOM order
|
||||
- Assert: scheduled-today chores first (sorted by deadline), then general, then pending
|
||||
- Non-today chores should be hidden in child mode
|
||||
|
||||
#### 8.2. Child reward sort: pending first, then ascending by points needed
|
||||
|
||||
- Read all reward card names in DOM order
|
||||
- Assert: pending reward first, remaining sorted by ascending cost/points-needed
|
||||
|
||||
---
|
||||
|
||||
## 9. Notification Click Navigates and Scrolls to Task
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/notification-click-scroll.spec.ts`
|
||||
**Project:** `chromium-task-activation` or new `chromium-notifications`
|
||||
|
||||
**Seed:** Create child + many chores (10+). Trigger one chore to create a pending notification. The triggered chore should be far enough in the list to require scrolling.
|
||||
|
||||
#### 9.1. Clicking a chore notification navigates to parent child view
|
||||
|
||||
- Navigate to notifications page
|
||||
- Click the pending chore notification
|
||||
- Verify URL matches `/parent/{childId}` with `scrollTo` and `entityType` query params
|
||||
|
||||
#### 9.2. Target chore card is scrolled into viewport after navigation
|
||||
|
||||
- After navigation from notification click, assert the target chore card `toBeInViewport()`
|
||||
|
||||
#### 9.3. Works for reward notifications too
|
||||
|
||||
- Request a reward → navigate to notifications → click → verify reward card is in viewport
|
||||
|
||||
---
|
||||
|
||||
## 10. Pending Status Resets on Task/Reward Modification
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/pending-reset-on-edit.spec.ts`
|
||||
**Project:** `chromium-task-modification`
|
||||
|
||||
**Seed:** Create child + one chore + one reward. Assign both. Create a chore schedule.
|
||||
|
||||
#### 10.1. Editing chore points clears pending status
|
||||
|
||||
- Trigger the chore (creates pending confirmation)
|
||||
- Verify chore card shows pending state
|
||||
- Edit the parent task's points via API: `POST /api/task/{taskId}/edit` with new points
|
||||
- Refresh or wait for SSE — verify the chore card no longer shows pending state
|
||||
|
||||
#### 10.2. Changing chore schedule clears pending status
|
||||
|
||||
- Trigger the chore again
|
||||
- Set a new schedule via API: `PUT /api/child/{childId}/task/{taskId}/schedule`
|
||||
- Verify pending is cleared
|
||||
|
||||
#### 10.3. Editing reward cost clears pending reward request
|
||||
|
||||
- Request the reward (creates pending request)
|
||||
- Edit the reward's cost via API
|
||||
- Verify the reward card no longer shows pending state
|
||||
|
||||
#### 10.4. Completed chores remain completed after point edit
|
||||
|
||||
- Complete and approve a scheduled chore so it shows COMPLETED
|
||||
- Edit the parent task's points
|
||||
- Verify the chore still shows COMPLETED stamp
|
||||
|
||||
#### 10.5. Editing task name only (no point change) does NOT clear pending
|
||||
|
||||
- Trigger the chore
|
||||
- Edit the task name only (points unchanged) via API
|
||||
- Verify the chore still shows pending state
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Area | Spec File | Tests | Priority |
|
||||
| --- | -------------------------- | ------------------------------------------------------- | ----- | -------- |
|
||||
| 1 | Assign view child name | `task-assignment/assign-title-shows-child-name.spec.ts` | 4 | Medium |
|
||||
| 2 | General chore no COMPLETED | `task-activation/general-chore-no-completed.spec.ts` | 4 | **High** |
|
||||
| 3 | Extend time TOO LATE | Existing `chore-scheduler/extend-time.spec.ts` | 0–1 | Low |
|
||||
| 4 | Notification overflow | `notifications/notification-card-overflow.spec.ts` | 2 | Low |
|
||||
| 5 | Override SSE child update | `task-modification/override-sse-child-update.spec.ts` | 4 | **High** |
|
||||
| 6 | Scroll to edited task | `task-modification/scroll-to-edited-task.spec.ts` | 2 | Medium |
|
||||
| 7 | Parent sort order | `task-sorting/parent-sort-order.spec.ts` | 2 | **High** |
|
||||
| 8 | Child sort order | `task-sorting/child-sort-order.spec.ts` | 2 | **High** |
|
||||
| 9 | Notification click scroll | `notifications/notification-click-scroll.spec.ts` | 3 | Medium |
|
||||
| 10 | Pending reset on edit | `task-modification/pending-reset-on-edit.spec.ts` | 5 | **High** |
|
||||
|
||||
**Recommended implementation order:** #2, #10, #7, #8, #5, #9, #6, #1, #3, #4
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Bug #1 (uppercase verification code) is omitted from this E2E plan because reaching the PIN setup step requires email delivery; this is better covered by a component-level test on `ParentPinSetup.vue`.
|
||||
- Bug #4 (extend time) is largely already covered by the existing `extend-time.spec.ts` — only a minor addition may be needed.
|
||||
- Bug #5 (notification overflow) is best validated through visual regression (`toHaveScreenshot()`) rather than functional assertions.
|
||||
96
frontend/e2e/plans/child-options.plan.md
Normal file
96
frontend/e2e/plans/child-options.plan.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Child Kebab Menu Options
|
||||
|
||||
## Application Overview
|
||||
|
||||
Tests for the per-child kebab (⋮) menu on the Parent dashboard (`/parent`). The menu is only visible when the parent is authenticated (PIN entered). Each option card exposes an "Options" button (`aria-label="Options"`) that opens a dropdown with three actions: **Edit Child**, **Delete Points**, and **Delete Child**.
|
||||
|
||||
All tests run under the `chromium-child-options` project which completes before `chromium-create-child` starts, preventing `deleteAllChildren()` in the create-child suite from interfering with children created by these tests.
|
||||
|
||||
Each test creates its own named child via the API in `beforeEach` and cleans up in `afterEach`, so the tests are fully isolated and can run in parallel with each other.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Edit Child ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/child-options/edit-child.spec.ts`
|
||||
|
||||
**Seed child:** `KebabEdit` (created via `PUT /api/child/add`, deleted in `afterEach`)
|
||||
|
||||
#### 1.1. Edit Child menu item navigates to the child editor ✅
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create child `KebabEdit` via API in `beforeEach`
|
||||
|
||||
2. Navigate to `/parent`
|
||||
- expect: A card with heading `KebabEdit` is visible
|
||||
|
||||
3. Click the "Options" button on the `KebabEdit` card
|
||||
- expect: The kebab dropdown opens (`aria-expanded="true"` on the Options button)
|
||||
|
||||
4. Click "Edit Child" in the dropdown
|
||||
- expect: URL matches `/parent/<id>/edit`
|
||||
- expect: Page heading "Edit Child" is visible
|
||||
|
||||
---
|
||||
|
||||
### 2. Delete Points ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/child-options/delete-points.spec.ts`
|
||||
|
||||
**Seed child:** `KebabPoints` (created via `PUT /api/child/add`, seeded to 50 points via `PUT /api/child/:id/edit`, deleted in `afterEach`)
|
||||
|
||||
#### 2.1. Delete Points resets child points to 0 ✅
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create child `KebabPoints` with 50 points via API in `beforeEach`
|
||||
|
||||
2. Navigate to `/parent`
|
||||
|
||||
3. Click the "Options" button on the `KebabPoints` card
|
||||
- expect: The kebab dropdown opens (`aria-expanded="true"` on the Options button)
|
||||
|
||||
4. Click "Delete Points" in the dropdown
|
||||
- expect: The card shows `Points: 0`
|
||||
|
||||
---
|
||||
|
||||
### 3. Delete Child ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/child-options/delete-child.spec.ts`
|
||||
|
||||
**Seed child:** `KebabDelete` (created per-test, cleaned up in `afterEach`)
|
||||
|
||||
**Note:** The two tests in this file share the same child name and use `test.describe.configure({ mode: 'serial' })` to guarantee they never run in parallel with each other.
|
||||
|
||||
#### 3.1. Confirm deletes the child from the list ✅
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create child `KebabDelete` via API
|
||||
|
||||
2. Navigate to `/parent`
|
||||
- expect: Card with heading `KebabDelete` is visible
|
||||
|
||||
3. Click "Options" → "Delete Child"
|
||||
- expect: Confirmation dialog "Are you sure you want to delete this child?" appears
|
||||
|
||||
4. Click the "Delete" button in the dialog
|
||||
- expect: The `KebabDelete` card is no longer visible in the list
|
||||
|
||||
#### 3.2. Cancel does not delete the child ✅
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create child `KebabDelete` via API
|
||||
|
||||
2. Navigate to `/parent`
|
||||
- expect: Card with heading `KebabDelete` is visible
|
||||
|
||||
3. Click "Options" → "Delete Child"
|
||||
- expect: Confirmation dialog "Are you sure you want to delete this child?" appears
|
||||
|
||||
4. Click the "Cancel" button in the dialog
|
||||
- expect: The confirmation dialog is dismissed
|
||||
- expect: The `KebabDelete` card is still visible in the list
|
||||
558
frontend/e2e/plans/chore-scheduler.plan.md
Normal file
558
frontend/e2e/plans/chore-scheduler.plan.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# Chore Scheduler E2E Tests
|
||||
|
||||
## Application Overview
|
||||
|
||||
The chore scheduler allows parents to configure when chores appear for their children and set deadline times. Schedules operate in two modes: **Specific Days** (select weekdays + per-day deadlines with optional exceptions) and **Every X Days** (interval-based with anchor date). Schedules can be **paused** (enabled/disabled toggle) — a paused schedule makes the chore appear every day with no deadline, as if unscheduled. All schedule logic runs client-side; the backend only stores and returns raw schedule data.
|
||||
|
||||
**Key behavioral difference between parent and child mode:**
|
||||
|
||||
- **Parent mode** (`/parent/:id`): All assigned chores are always visible regardless of schedule. Off-day and expired chores are grayed out (`.chore-inactive`). A kebab menu provides Schedule, Edit Points, Extend Time (when expired), and Reset (when completed) options.
|
||||
- **Child mode** (`/child/:id`): Chores not scheduled for today are **hidden entirely** (filtered out client-side). Expired chores show a `TOO LATE` badge and are grayed out. No kebab menu or scheduling controls exist in child mode.
|
||||
|
||||
**Specs covered:**
|
||||
|
||||
- `feat-calendar-chore.md` — base schedule feature
|
||||
- `feat-calendar-schedule-refactor-01.md` — Specific Days UI refactor (day chips, default deadline, exceptions, TimePickerPopover)
|
||||
- `feat-calendar-schedule-refactor-02.md` — Every X Days UI refactor (stepper, DateInputField, anchor date, next occurrence preview, Anytime toggle)
|
||||
- `feat-schedule-enable-disable.md` — enable/disable (pause) toggle
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Tests live in `e2e/mode_parent/chore-scheduler/`. Each spec creates its own isolated child and chore tasks via the API so files can run in parallel. All created resources are deleted in `afterAll`.
|
||||
|
||||
## Playwright Projects
|
||||
|
||||
| Project | testMatch | Notes |
|
||||
| -------------------------- | -------------------------------------------- | ----------------------- |
|
||||
| `chromium-chore-scheduler` | `/mode_parent/chore-scheduler/.+\.spec\.ts/` | Depends on `setup` only |
|
||||
|
||||
**Config:** Add project to `playwright.config.ts` with `dependencies: ['setup']`. Exclude from the catch-all project via `testIgnore`.
|
||||
|
||||
---
|
||||
|
||||
## Seed Strategy
|
||||
|
||||
Each spec file creates an isolated child and one or more chore tasks via `beforeAll` API calls. Schedule configuration is done via the `PUT /api/child/:childId/task/:taskId/schedule` API. Cleanup (`delete child + tasks`) runs in `afterAll`.
|
||||
|
||||
For tests that need to verify child-mode behavior, the test navigates to `/child/:id` (no parent auth required for child view). For parent-mode behavior, navigate to `/parent/:id`.
|
||||
|
||||
**Time-sensitive tests:** Tests that depend on "Due by" labels or "TOO LATE" badges must set a schedule deadline far in the future (e.g., 23:59) to avoid the chore expiring mid-test, or set a deadline in the past (e.g., 00:01) to guarantee expiry. Use `page.clock` or carefully chosen deadline times to control time-dependent state.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Schedule Modal — Opening & Structure
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ScheduleModalChild` and chore `ScheduleModalChore` via API. Assigns chore to child. Deletes all in `afterAll`.
|
||||
|
||||
#### 1.1. Schedule option appears in kebab menu
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- Click the chore card, then click the kebab "Options" button
|
||||
- expect: "Schedule" button is visible in the menu
|
||||
|
||||
#### 1.2. Schedule modal opens with correct title and subtitle
|
||||
|
||||
- Open kebab menu → click "Schedule"
|
||||
- expect: Modal heading shows "Schedule Chore"
|
||||
- expect: Modal subtitle shows the chore's name
|
||||
|
||||
#### 1.3. Default state for new schedule — days mode selected, no days checked
|
||||
|
||||
- Open the Schedule modal for a chore with no existing schedule
|
||||
- expect: "Specific Days" mode button is active
|
||||
- expect: No day chips are in the active state
|
||||
- expect: Enable/disable toggle shows "Enabled" label and is ON
|
||||
|
||||
#### 1.4. Mode toggle switches between Specific Days and Every X Days
|
||||
|
||||
- Open the Schedule modal
|
||||
- Click "Every X Days" button
|
||||
- expect: Interval form is visible (stepper, "Starting on" date field, deadline row)
|
||||
- Click "Specific Days" button
|
||||
- expect: Day chips row is visible
|
||||
|
||||
#### 1.5. Cancel closes the modal without saving
|
||||
|
||||
- Open the Schedule modal, select Monday, click Cancel
|
||||
- Reopen the modal
|
||||
- expect: No days are selected (change was not persisted)
|
||||
|
||||
#### 1.6. Save is disabled when form is not dirty
|
||||
|
||||
- Open the Schedule modal (no existing schedule)
|
||||
- expect: Save button is disabled
|
||||
|
||||
---
|
||||
|
||||
### 2. Schedule Modal — Specific Days Mode
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/schedule-days-mode.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ScheduleDaysChild` and chore `ScheduleDaysChore` via API. Assigns chore. Deletes all in `afterAll`.
|
||||
|
||||
#### 2.1. Clicking day chips toggles them active/inactive
|
||||
|
||||
- Open Schedule modal
|
||||
- Click "Mo" chip
|
||||
- expect: "Mo" chip has active styling
|
||||
- Click "Mo" chip again
|
||||
- expect: "Mo" chip is no longer active
|
||||
|
||||
#### 2.2. Default deadline row appears when at least one day is selected
|
||||
|
||||
- Open Schedule modal, no days selected
|
||||
- expect: No deadline row visible
|
||||
- Click "Tu" chip
|
||||
- expect: Deadline row with "Deadline" label and a time picker appears
|
||||
|
||||
#### 2.3. Default deadline "Anytime" toggle hides the time picker
|
||||
|
||||
- Select a day so the deadline row appears
|
||||
- Click "Clear (Anytime)" link
|
||||
- expect: Time picker disappears, "Anytime" label is shown
|
||||
- Click "Set deadline" link
|
||||
- expect: Time picker reappears
|
||||
|
||||
#### 2.4. Exception time — "Set different time" creates per-day override
|
||||
|
||||
- Select Monday and Wednesday
|
||||
- In the day list, click "Set different time" on Wednesday
|
||||
- expect: A separate time picker appears for Wednesday
|
||||
- expect: Monday still shows the default time label
|
||||
|
||||
#### 2.5. Exception time — "Reset to default" removes the override
|
||||
|
||||
- With Wednesday exception set, click "Reset to default" on Wednesday
|
||||
- expect: Wednesday row shows the default time label again
|
||||
|
||||
#### 2.6. Save with specific days — schedule persists on reopen
|
||||
|
||||
- Select Monday and Friday, set default deadline to 03:00 PM, Save
|
||||
- Reopen the Schedule modal
|
||||
- expect: Monday and Friday chips are active
|
||||
- expect: Default deadline shows 03:00 PM
|
||||
|
||||
#### 2.7. Save with zero days selected deletes the schedule
|
||||
|
||||
- Open a schedule that has days selected, uncheck all days, Save
|
||||
- Reopen the modal
|
||||
- expect: No days selected (schedule was removed), form appears as new
|
||||
|
||||
#### 2.8. Dirty detection — changing days enables Save
|
||||
|
||||
- Open modal with an existing schedule
|
||||
- Toggle a different day
|
||||
- expect: Save becomes enabled
|
||||
|
||||
---
|
||||
|
||||
### 3. Schedule Modal — Every X Days Mode
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/schedule-interval-mode.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ScheduleIntervalChild` and chore `ScheduleIntervalChore` via API. Assigns chore. Deletes all in `afterAll`.
|
||||
|
||||
#### 3.1. Interval stepper increments and decrements within 1–7
|
||||
|
||||
- Switch to "Every X Days" mode
|
||||
- expect: Stepper shows "1" by default
|
||||
- expect: Decrement (−) button is disabled at 1
|
||||
- Click increment (+) 6 times
|
||||
- expect: Value shows "7"
|
||||
- expect: Increment (+) button is disabled at 7
|
||||
|
||||
#### 3.2. Anchor date field shows today's date by default
|
||||
|
||||
- Switch to "Every X Days" mode
|
||||
- expect: "Starting on" date field contains today's ISO date
|
||||
|
||||
#### 3.3. Next occurrence preview label updates correctly
|
||||
|
||||
- Set interval to 2, set anchor date to today
|
||||
- expect: "Next occurrence" label shows a date 2 days from today
|
||||
|
||||
#### 3.4. Interval deadline "Anytime" toggle works
|
||||
|
||||
- Switch to interval mode; deadline time picker is visible by default
|
||||
- Click "Clear (Anytime)"
|
||||
- expect: Time picker disappears, "Anytime" label shown
|
||||
- Click "Set deadline"
|
||||
- expect: Time picker reappears
|
||||
|
||||
#### 3.5. Save interval schedule — persists on reopen
|
||||
|
||||
- Set interval to 3, pick a specific anchor date, set deadline to 04:30 PM, Save
|
||||
- Reopen Schedule modal
|
||||
- expect: "Every X Days" mode is active
|
||||
- expect: Interval shows 3, anchor date matches, deadline shows 04:30 PM
|
||||
|
||||
#### 3.6. Dirty detection — changing interval enables Save
|
||||
|
||||
- Open modal with an existing interval schedule
|
||||
- Change interval from 3 to 5
|
||||
- expect: Save becomes enabled
|
||||
|
||||
---
|
||||
|
||||
### 4. Schedule Enable/Disable Toggle
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/schedule-enable-disable.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ScheduleToggleChild` and chore `ScheduleToggleChore` via API. Assigns chore. Creates a "Specific Days" schedule for a non-today weekday via API. Deletes all in `afterAll`.
|
||||
|
||||
#### 4.1. Toggle row is visible in the schedule modal
|
||||
|
||||
- Open Schedule modal
|
||||
- expect: Toggle row with "Enabled" label and a switch is visible above the mode buttons
|
||||
|
||||
#### 4.2. Toggling OFF shows "Paused" label and dims the form body
|
||||
|
||||
- Click the toggle switch to OFF
|
||||
- expect: Label changes to "Paused"
|
||||
- expect: Mode toggle and form fields have reduced opacity (dimmed)
|
||||
- expect: Cancel and Save buttons remain fully interactive
|
||||
|
||||
#### 4.3. Toggling ON shows "Enabled" label and restores the form body
|
||||
|
||||
- Toggle OFF, then toggle back ON
|
||||
- expect: Label returns to "Enabled"
|
||||
- expect: Form body opacity is restored
|
||||
|
||||
#### 4.4. Save paused state — persists on reopen
|
||||
|
||||
- Toggle OFF, Save
|
||||
- Reopen Schedule modal
|
||||
- expect: Toggle is OFF, label shows "Paused", form body is dimmed
|
||||
|
||||
#### 4.5. Dirty detection — toggling enabled state enables Save
|
||||
|
||||
- Open an existing schedule (enabled), toggle OFF
|
||||
- expect: Save becomes enabled
|
||||
- Toggle back ON
|
||||
- expect: Save is disabled again (reverted to original)
|
||||
|
||||
#### 4.6. Paused schedule in parent mode — chore is visible (not grayed out)
|
||||
|
||||
- Set up a schedule for a non-today weekday, then pause it via API
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Chore card is visible and NOT grayed out (paused = show always like unscheduled)
|
||||
|
||||
#### 4.7. Paused schedule in child mode — chore is visible (not hidden)
|
||||
|
||||
- Same paused schedule as 4.6
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore card IS visible (paused = scheduled for every day)
|
||||
|
||||
#### 4.8. Paused schedule shows no "Due by" label
|
||||
|
||||
- Pause a schedule that has a deadline set
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: No "Due by" sub-text on the chore card (paused = no deadline)
|
||||
|
||||
---
|
||||
|
||||
### 5. Parent Mode — Chore Card Schedule States
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/parent-card-states.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ParentCardChild` with multiple chores via API. Assigns chores. Creates various schedules via API. Deletes all in `afterAll`.
|
||||
|
||||
**Chores:**
|
||||
|
||||
- `AlwaysActiveChore` — no schedule (always visible, normal appearance)
|
||||
- `ScheduledTodayChore` — schedule includes today's weekday, deadline at 23:59
|
||||
- `NotScheduledTodayChore` — schedule for a weekday that is NOT today
|
||||
- `ExpiredChore` — schedule for today's weekday, deadline at 00:01 (guaranteed past)
|
||||
- `AnytimeChore` — schedule for today, deadline set to "Anytime" (no expiry)
|
||||
|
||||
#### 5.1. Chore with no schedule — normal appearance, no annotations
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: `AlwaysActiveChore` card is visible with normal styling (no grayout, no badge)
|
||||
|
||||
#### 5.2. Chore scheduled for today — shows "Due by" label
|
||||
|
||||
- expect: `ScheduledTodayChore` card is visible
|
||||
- expect: Card shows "Due by 11:59 PM" sub-text
|
||||
- expect: Card is NOT grayed out
|
||||
|
||||
#### 5.3. Chore not scheduled for today — grayed out in parent mode
|
||||
|
||||
- expect: `NotScheduledTodayChore` card IS visible (parent mode shows all chores)
|
||||
- expect: Card has grayed-out styling (`.chore-inactive` class — reduced opacity)
|
||||
|
||||
#### 5.4. Chore with expired deadline — grayed out with TOO LATE badge
|
||||
|
||||
- expect: `ExpiredChore` card is visible
|
||||
- expect: Card has grayed-out styling
|
||||
- expect: "TOO LATE" badge is visible on the card
|
||||
|
||||
#### 5.5. Chore with "Anytime" deadline — no "Due by" label, no TOO LATE
|
||||
|
||||
- expect: `AnytimeChore` card is visible with normal styling
|
||||
- expect: No "Due by" sub-text
|
||||
- expect: No "TOO LATE" badge
|
||||
|
||||
#### 5.6. Kebab menu always accessible on grayed-out chores
|
||||
|
||||
- Click the `NotScheduledTodayChore` card (grayed out)
|
||||
- expect: Kebab "Options" button is visible
|
||||
- Click "Options" → "Schedule" is available
|
||||
- expect: Schedule modal opens normally
|
||||
|
||||
#### 5.7. Chore can still be triggered by parent regardless of schedule state
|
||||
|
||||
- Click the `ExpiredChore` (which has TOO LATE badge)
|
||||
- Click again to activate
|
||||
- expect: Confirmation dialog appears (parent can still trigger expired chores)
|
||||
|
||||
---
|
||||
|
||||
### 6. Child Mode — Chore Visibility and Schedule States
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/child-card-states.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ChildCardChild` with multiple chores. Assigns chores. Creates schedules via API. Deletes all in `afterAll`. Uses same chore set as test 5 where applicable.
|
||||
|
||||
**Chores:**
|
||||
|
||||
- `ChildAlwaysChore` — no schedule
|
||||
- `ChildTodayChore` — scheduled for today, deadline at 23:59
|
||||
- `ChildNotTodayChore` — scheduled for a non-today weekday
|
||||
- `ChildExpiredChore` — scheduled for today, deadline at 00:01 (past)
|
||||
- `ChildAnytimeChore` — scheduled for today, deadline "Anytime"
|
||||
|
||||
#### 6.1. Chore with no schedule — visible normally in child mode
|
||||
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: `ChildAlwaysChore` card is visible with normal styling
|
||||
|
||||
#### 6.2. Chore scheduled for today — visible with "Due by" label
|
||||
|
||||
- expect: `ChildTodayChore` card is visible
|
||||
- expect: "Due by 11:59 PM" sub-text shown on card
|
||||
- expect: Card is NOT grayed out
|
||||
|
||||
#### 6.3. Chore not scheduled for today — HIDDEN in child mode
|
||||
|
||||
- expect: `ChildNotTodayChore` card is NOT visible (fully hidden, not just grayed out)
|
||||
|
||||
#### 6.4. Chore with expired deadline — grayed out with TOO LATE badge
|
||||
|
||||
- expect: `ChildExpiredChore` card IS visible (still shown, but expired)
|
||||
- expect: Card has grayed-out styling
|
||||
- expect: "TOO LATE" badge visible
|
||||
|
||||
#### 6.5. Chore with "Anytime" — visible, no badge, no "Due by"
|
||||
|
||||
- expect: `ChildAnytimeChore` card visible with normal styling
|
||||
- expect: No "Due by" label, no "TOO LATE" badge
|
||||
|
||||
#### 6.6. No kebab menu or schedule controls in child mode
|
||||
|
||||
- Click on `ChildTodayChore` card
|
||||
- expect: No "Options" button or kebab menu appears on the card
|
||||
|
||||
---
|
||||
|
||||
### 7. Parent vs Child Mode Comparison
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts`
|
||||
|
||||
**Seed:** Creates child `CompareChild` and two chores: `VisibleBothChore` (scheduled for today) and `ParentOnlyChore` (scheduled for a non-today weekday). Assigns both. Deletes all in `afterAll`.
|
||||
|
||||
#### 7.1. Parent mode shows all chores including off-day ones
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Both `VisibleBothChore` and `ParentOnlyChore` are visible
|
||||
- expect: `ParentOnlyChore` is grayed out (`chore-inactive`)
|
||||
- expect: `VisibleBothChore` is NOT grayed out
|
||||
|
||||
#### 7.2. Child mode hides off-day chores
|
||||
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: `VisibleBothChore` IS visible
|
||||
- expect: `ParentOnlyChore` is NOT visible (hidden entirely)
|
||||
|
||||
#### 7.3. Adding a schedule via parent mode hides chore in child mode on wrong day
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- Open Schedule for `VisibleBothChore`, set it to a non-today weekday only, Save
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: `VisibleBothChore` is no longer visible in child mode
|
||||
|
||||
#### 7.4. Removing a schedule via parent mode shows chore in child mode again
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- Open Schedule for the chore, uncheck all days (removes schedule), Save
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore is visible again in child mode (no schedule = always shown)
|
||||
|
||||
#### 7.5. Paused schedule — chore visible in both modes
|
||||
|
||||
- Set a non-today schedule and pause it via API
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Chore visible, NOT grayed out (paused = acts unscheduled)
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore IS visible (paused = shows every day)
|
||||
|
||||
---
|
||||
|
||||
### 8. Extend Time
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/extend-time.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ExtendTimeChild` and chore `ExtendTimeChore` with a schedule for today and a deadline at 00:01 (guaranteed expired). Assigns chore. Deletes all in `afterAll`.
|
||||
|
||||
#### 8.1. "Extend Time" appears in kebab menu only when chore is expired
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: `ExtendTimeChore` shows "TOO LATE" badge (deadline is 00:01, already past)
|
||||
- Click chore card, then "Options"
|
||||
- expect: "Extend Time" menu item is visible
|
||||
|
||||
#### 8.2. "Extend Time" is absent when chore is not expired
|
||||
|
||||
- Set up a second chore with deadline at 23:59 (not yet passed)
|
||||
- Click chore card, then "Options"
|
||||
- expect: "Extend Time" menu item is NOT visible
|
||||
|
||||
#### 8.3. Clicking "Extend Time" removes the TOO LATE badge
|
||||
|
||||
- Click "Extend Time" on the expired chore
|
||||
- expect: "TOO LATE" badge disappears from the card
|
||||
- expect: Card is no longer grayed out
|
||||
|
||||
#### 8.4. Extended chore no longer shows "Due by" label
|
||||
|
||||
- After extending, the chore card
|
||||
- expect: No "Due by" sub-text (extended = no deadline for remainder of day)
|
||||
|
||||
#### 8.5. Extending the same chore twice returns 409
|
||||
|
||||
- After the first extension, try opening kebab again
|
||||
- expect: "Extend Time" is no longer in the menu (chore is no longer expired)
|
||||
|
||||
#### 8.6. Extension effect is visible in child mode
|
||||
|
||||
- After extending the chore in parent mode
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore is visible and NOT showing "TOO LATE" badge
|
||||
|
||||
---
|
||||
|
||||
### 9. Schedule with Interval Mode — Anchor Date & Next Occurrence
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts`
|
||||
|
||||
**Seed:** Creates child `IntervalAnchorChild` and chore `IntervalAnchorChore`. Assigns chore. Deletes all in `afterAll`.
|
||||
|
||||
#### 9.1. Interval schedule set to every 1 day from today — chore active today
|
||||
|
||||
- Create interval schedule: `interval_days=1`, `anchor_date=today`, deadline 23:59
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Chore is NOT grayed out (hits today)
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore IS visible
|
||||
|
||||
#### 9.2. Interval schedule set to every 2 days from yesterday — chore NOT active today
|
||||
|
||||
- Create interval schedule: `interval_days=2`, `anchor_date=yesterday`
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Chore IS grayed out (yesterday + 2 = tomorrow, not today)
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore is NOT visible
|
||||
|
||||
#### 9.3. Interval schedule set to every 2 days starting today — active today
|
||||
|
||||
- Create interval schedule: `interval_days=2`, `anchor_date=today`
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Chore is NOT grayed out
|
||||
- Navigate to `/child/:childId`
|
||||
- expect: Chore IS visible
|
||||
|
||||
#### 9.4. Interval "Anytime" deadline — no "Due by" label
|
||||
|
||||
- Create interval schedule with `interval_has_deadline=false`
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: No "Due by" sub-text on the chore card
|
||||
|
||||
---
|
||||
|
||||
### 10. SSE Real-Time Updates
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/schedule-sse.spec.ts`
|
||||
|
||||
**Seed:** Creates child `SSEScheduleChild` and chore `SSEScheduleChore`. Assigns chore. Deletes all in `afterAll`.
|
||||
|
||||
#### 10.1. Setting a schedule via API reflects in an already-open parent view
|
||||
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: Chore is visible, no schedule annotations
|
||||
- Via API call (`request.put`), create a schedule for a non-today weekday
|
||||
- expect: Within a few seconds, the chore card becomes grayed out (SSE `chore_schedule_modified` fires, list refreshes)
|
||||
|
||||
#### 10.2. Deleting a schedule via API un-grays the chore in parent view
|
||||
|
||||
- (Continuing from 10.1) Delete the schedule via API
|
||||
- expect: Within a few seconds, the chore card returns to normal styling
|
||||
|
||||
#### 10.3. Extending time via API reflects in an already-open parent view
|
||||
|
||||
- Set up a schedule with a past deadline (00:01) via API so chore is expired
|
||||
- Navigate to `/parent/:childId`
|
||||
- expect: "TOO LATE" badge visible
|
||||
- Extend time via API call
|
||||
- expect: "TOO LATE" badge disappears (SSE `chore_time_extended` fires, list refreshes)
|
||||
|
||||
---
|
||||
|
||||
### 11. Schedule Loading — Backward Compatibility
|
||||
|
||||
**File:** `e2e/mode_parent/chore-scheduler/schedule-load.spec.ts`
|
||||
|
||||
**Seed:** Creates child and chore. Uses direct API calls to create schedules with specific field combinations. Deletes all in `afterAll`.
|
||||
|
||||
#### 11.1. Schedule with `enabled` field missing defaults to enabled
|
||||
|
||||
- Create schedule via API without `enabled` field
|
||||
- Open Schedule modal
|
||||
- expect: Toggle shows "Enabled" (defaults to true)
|
||||
|
||||
#### 11.2. Schedule with `interval_has_deadline=false` shows "Anytime"
|
||||
|
||||
- Create interval schedule with `interval_has_deadline=false`
|
||||
- Open Schedule modal
|
||||
- expect: "Anytime" label is shown instead of time picker
|
||||
|
||||
#### 11.3. Existing days schedule with mixed times loads correctly
|
||||
|
||||
- Create days schedule with 3 days: Mon (08:00), Wed (08:00), Fri (10:30) — Fri is an exception
|
||||
- Open Schedule modal
|
||||
- expect: Mo, We, Fr chips are active
|
||||
- expect: Default deadline shows 08:00 AM
|
||||
- expect: Friday row shows its own time picker with 10:30 AM
|
||||
|
||||
#### 11.4. Interval schedule loads anchor date and interval correctly
|
||||
|
||||
- Create interval schedule via API with `anchor_date="2026-03-15"`, `interval_days=3`
|
||||
- Open Schedule modal
|
||||
- expect: "Every X Days" mode is active
|
||||
- expect: Stepper shows 3
|
||||
- expect: Date field shows 2026-03-15
|
||||
|
||||
---
|
||||
|
||||
## CSS Spec References
|
||||
|
||||
- `.chore-inactive`: `opacity: 0.45; filter: grayscale(60%)` — applied in both parent and child views
|
||||
- `.chore-stamp`: absolute-positioned badge overlay for "TOO LATE", "COMPLETED", "PENDING"
|
||||
- `.due-label`: "Due by X:XX PM" sub-text below point value
|
||||
- `.schedule-body.disabled`: `opacity: 0.45; pointer-events: none` — dim wrapper when schedule is paused
|
||||
- `.toggle-track.on`: `background: var(--btn-primary)` — toggle ON state
|
||||
286
frontend/e2e/plans/create-child.plan.md
Normal file
286
frontend/e2e/plans/create-child.plan.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Create Child
|
||||
|
||||
## Application Overview
|
||||
|
||||
Tests for creating a new child from the Parent dashboard. The user is authenticated and in parent mode via stored auth state (tests/.auth/user.json). All tests start at /parent/children (Children List view).
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Happy Path ✅ IMPLEMENTED
|
||||
|
||||
**Seed:** `frontend/vue-app/seed.ts`
|
||||
|
||||
#### 1.1. Create a child with name and age only ✅
|
||||
|
||||
**File:** `tests/create-child/happy-path.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent (router auto-redirects from /)
|
||||
|
||||
|
||||
- expect: The Children List page is displayed
|
||||
|
||||
2. Click the 'Add Child' floating action button (FAB) in the bottom-right corner
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed with Name, Age, and Image fields
|
||||
|
||||
3. Enter 'Alice' in the Name field
|
||||
|
||||
|
||||
- expect: The Name field shows 'Alice'
|
||||
- expect: The Create button remains disabled until all required fields are valid
|
||||
|
||||
4. Enter '8' in the Age field
|
||||
|
||||
|
||||
- expect: The Age field shows '8'
|
||||
- expect: The Create button becomes enabled
|
||||
|
||||
5. Leave the Image field as the default pre-selected value and click the Create button
|
||||
|
||||
|
||||
- expect: No error messages are displayed
|
||||
- expect: Navigation returns to /parent
|
||||
- expect: The child 'Alice' appears in the children list
|
||||
|
||||
#### 1.2. Create a child via the inline 'Create' button in empty state ✅
|
||||
|
||||
**File:** `tests/create-child/happy-path.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent with no children in the account
|
||||
|
||||
|
||||
- expect: An empty state message "No children" is shown with an inline 'Create' button
|
||||
|
||||
2. Click the inline 'Create' button (with class "round-btn")
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed
|
||||
|
||||
3. Enter 'Bob' in the Name field and '10' in the Age field, then click Create
|
||||
|
||||
|
||||
- expect: Create button is initially disabled, becomes enabled after both fields are filled
|
||||
- expect: No errors are displayed
|
||||
- expect: Navigation returns to /parent
|
||||
- expect: 'Bob' appears in the children list
|
||||
|
||||
#### 1.3. Create a child with a custom uploaded image ✅
|
||||
|
||||
**File:** `tests/create-child/happy-path.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed
|
||||
|
||||
2. Enter 'Grace' in the Name field and '6' in the Age field
|
||||
|
||||
|
||||
- expect: Name and Age fields are populated
|
||||
|
||||
3. Click 'Add from device' button and choose a valid PNG or JPEG file
|
||||
|
||||
|
||||
- expect: The image file is selected and uploaded
|
||||
|
||||
4. Click the Create button
|
||||
|
||||
|
||||
- expect: Create button becomes enabled after async form initialization
|
||||
- expect: No error messages are displayed
|
||||
- expect: Navigation returns to /parent
|
||||
- expect: 'Grace' appears in the children list with the uploaded image
|
||||
|
||||
### 2. Validation - Required Fields ✅ IMPLEMENTED
|
||||
|
||||
**Seed:** `frontend/vue-app/seed.ts`
|
||||
|
||||
**Note:** Form validation is client-side. Invalid inputs disable the submit button rather than showing error messages.
|
||||
|
||||
#### 2.1. Reject submission when Name is empty ✅
|
||||
|
||||
**File:** `tests/create-child/validation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed at /parent/children/create
|
||||
|
||||
2. Leave the Name field empty, enter '7' in the Age field
|
||||
|
||||
|
||||
- expect: The Create button remains disabled
|
||||
- expect: Form stays on /parent/children/create (no navigation)
|
||||
|
||||
#### 2.2. Reject submission when Name is whitespace only ✅
|
||||
|
||||
**File:** `tests/create-child/validation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed at /parent/children/create
|
||||
|
||||
2. Enter only spaces ' ' in the Name field, enter '7' in the Age field
|
||||
|
||||
|
||||
- expect: The Create button remains disabled (whitespace is treated as empty)
|
||||
- expect: Form stays on /parent/children/create
|
||||
|
||||
#### 2.3. Reject submission when Age is empty ✅
|
||||
|
||||
**File:** `tests/create-child/validation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed at /parent/children/create
|
||||
|
||||
2. Enter 'Charlie' in the Name field, clear the Age field
|
||||
|
||||
|
||||
- expect: The Create button remains disabled
|
||||
- expect: Form stays on /parent/children/create
|
||||
|
||||
### 3. Validation - Boundary Conditions ✅ IMPLEMENTED
|
||||
|
||||
**Seed:** `frontend/vue-app/seed.ts`
|
||||
|
||||
#### 3.1. Reject negative age ✅
|
||||
|
||||
**File:** `tests/create-child/validation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed at /parent/children/create
|
||||
|
||||
2. Enter 'Dave' in the Name field, enter '-1' in the Age field
|
||||
|
||||
|
||||
- expect: The Create button remains disabled (negative age is invalid)
|
||||
- expect: Form stays on /parent/children/create
|
||||
|
||||
#### 3.2. Enforce maximum Name length of 64 characters ✅
|
||||
|
||||
**File:** `tests/create-child/validation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed at /parent/children/create
|
||||
|
||||
2. Attempt to type a name with 65 characters in the Name field
|
||||
|
||||
|
||||
- expect: The input is capped at 64 characters by HTML maxlength attribute
|
||||
|
||||
3. Enter '5' in the Age field and click Create
|
||||
|
||||
|
||||
- expect: The Create button becomes enabled
|
||||
- expect: The form submits successfully with the 64-character name
|
||||
- expect: Navigation returns to /parent
|
||||
|
||||
#### 3.3. Reject age greater than 120 ✅
|
||||
|
||||
**File:** `tests/create-child/validation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed at /parent/children/create
|
||||
|
||||
2. Enter 'Eve' in the Name field, enter '121' in the Age field
|
||||
|
||||
|
||||
- expect: The Create button remains disabled (age > 120 is invalid)
|
||||
- expect: Form stays on /parent/children/create
|
||||
|
||||
### 4. Navigation ✅ IMPLEMENTED
|
||||
|
||||
**Seed:** `frontend/vue-app/seed.ts`
|
||||
|
||||
#### 4.1. Cancel navigates back without saving ✅
|
||||
|
||||
**File:** `tests/create-child/navigation.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent and click the 'Add Child' FAB
|
||||
|
||||
|
||||
- expect: The Create Child form is displayed
|
||||
|
||||
2. Enter 'Frank' in the Name field and '9' in the Age field, then click the Cancel button
|
||||
|
||||
|
||||
- expect: Navigation returns to /parent
|
||||
- expect: 'Frank' does NOT appear in the children list
|
||||
|
||||
### 5. Real-Time Updates via SSE ✅ IMPLEMENTED
|
||||
|
||||
**Seed:** `frontend/vue-app/seed.ts`
|
||||
|
||||
#### 5.1. New child appears in list without page reload ✅
|
||||
|
||||
**File:** `tests/create-child/sse.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Open two browser tabs both on /parent
|
||||
|
||||
|
||||
- expect: Both tabs show the Children List page
|
||||
|
||||
2. In Tab 1, click 'Add Child' FAB, fill in name 'Hannah' and age '4', then submit
|
||||
|
||||
|
||||
- expect: Tab 1 navigates to /parent and 'Hannah' is visible
|
||||
|
||||
3. Switch to Tab 2 which is still on /parent
|
||||
|
||||
|
||||
- expect: 'Hannah' appears in Tab 2 without a manual refresh (SSE real-time update)
|
||||
|
||||
### 6. Authorization ✅ IMPLEMENTED
|
||||
|
||||
**Seed:** `frontend/vue-app/seed.ts`
|
||||
|
||||
#### 6.1. Add Child FAB is hidden when parent auth is expired ✅
|
||||
|
||||
**File:** `tests/create-child/authorization.spec.ts`
|
||||
|
||||
**Note:** Uses `chromium-no-pin` project storage state to simulate a user without parent authentication.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to /parent using the no-pin storage state (parent auth not present)
|
||||
|
||||
|
||||
- expect: The 'Add Child' FAB is NOT visible on the page
|
||||
|
||||
## Key Changes from Original Plan
|
||||
|
||||
1. **Validation Strategy**: Changed from server-side error messages to client-side form validation that disables the submit button for invalid inputs
|
||||
2. **Async Form Handling**: Tests include logic to handle async form initialization races with default image loading
|
||||
3. **Authorization test**: Uses `STORAGE_STATE_NO_PIN` (chromium-no-pin project) rather than manually clearing localStorage
|
||||
481
frontend/e2e/plans/parent-item-management.plan.md
Normal file
481
frontend/e2e/plans/parent-item-management.plan.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# Parent create and edit chores, kindness acts, and penalties
|
||||
|
||||
## Application Overview
|
||||
|
||||
Focus on parent-mode flows: a parent logs in via PIN, then creates, edits, and deletes three types of items (chore, kindness act, penalty) separately. Each scenario assumes the parent is already authenticated (PIN entered) and navigates to `/parent/tasks/chores`, then switches tabs as needed.
|
||||
|
||||
Tests are executed in parent mode; children should not be able to perform these operations. The intent is to verify that creation forms work, that existing items can be updated correctly, and that proper deletion and default-item restoration logic functions.
|
||||
|
||||
All item names use a `${Date.now()}` suffix to avoid collisions between parallel test runs.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Chore management
|
||||
|
||||
**Seed:** `e2e/seed.spec.ts`
|
||||
|
||||
#### 1.1. Create a new chore (parent mode)
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`
|
||||
|
||||
|
||||
- expect: The chores list loads and the default chore 'Clean your mess' is visible
|
||||
|
||||
2. Click the 'Create Chore' button
|
||||
|
||||
|
||||
- expect: The Create Chore form is displayed with a 'Chore Name' field and a Points field
|
||||
|
||||
3. Enter a unique name (e.g. `Wash dishes ${suffix}`) and '10' in Points using DOM events
|
||||
|
||||
|
||||
- expect: Submit/Create button becomes enabled
|
||||
|
||||
4. Click the Create button
|
||||
|
||||
|
||||
- expect: No `.error` elements are shown
|
||||
- expect: The new chore appears in the chores list
|
||||
|
||||
#### 1.2. Cancel chore creation
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores` and click 'Create Chore'
|
||||
|
||||
|
||||
- expect: Create Chore form appears with 'Chore Name' label visible
|
||||
|
||||
2. Fill in 'Test' for name and '5' for points, then click 'Cancel'
|
||||
|
||||
|
||||
- expect: User is returned to the chores list (default chore 'Clean your mess' is visible with exact match)
|
||||
- expect: No element with text 'Test' exists in the list
|
||||
|
||||
#### 1.3. Edit an existing chore
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`; create `Wash dishes ${suffix}` if it doesn't already exist
|
||||
|
||||
|
||||
- expect: The chore appears in the list
|
||||
|
||||
2. Click the chore row to open the edit form
|
||||
|
||||
|
||||
- expect: Edit form appears with 'Chore Name' label visible
|
||||
|
||||
3. Change the name to `Wash car ${suffix}` and points using DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: No errors are displayed
|
||||
- expect: The updated chore name `Wash car ${suffix}` appears in the list
|
||||
|
||||
#### 1.4. Cancel chore edit
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`; create 'Wash dishes' if it doesn't exist; click the row
|
||||
|
||||
|
||||
- expect: Edit form is displayed
|
||||
|
||||
2. Change the name to 'Should not save' via DOM events, then click 'Cancel'
|
||||
|
||||
|
||||
- expect: The list is visible again (default chore 'Clean your mess' shown with exact match)
|
||||
- expect: 'Wash dishes' (exact) is still present; 'Should not save' does not appear
|
||||
|
||||
#### 1.5. Convert a default chore to a user item by editing
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-convert-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores` and locate the default chore 'Clean your mess'
|
||||
|
||||
|
||||
- expect: Item is visible and has no delete button (`button[aria-label="Delete item"]` count is 0)
|
||||
|
||||
2. Click the row to open the edit form
|
||||
|
||||
|
||||
- expect: Edit form opens with `input#name` value of 'Clean your mess'
|
||||
|
||||
3. Change the name to 'Clean your mess (custom)' and points to '20' via DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: The chore row for 'Clean your mess' now has a visible delete button
|
||||
|
||||
#### 1.6. Delete a chore
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`; create a uniquely-named chore if necessary
|
||||
|
||||
|
||||
- expect: The chore appears in the list with a delete control
|
||||
|
||||
2. Click the delete icon (`button[aria-label="Delete item"]`) and confirm the dialog
|
||||
|
||||
|
||||
- expect: The item is removed from the list
|
||||
|
||||
#### 1.7. Edit default chore points and verify system restoration on delete
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/chore-delete-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`; clean up any leftover user-copy of 'Take out trash' (with delete icon) from previous runs
|
||||
2. Verify the default 'Take out trash' is visible with no delete icon
|
||||
3. Click 'Take out trash' to open the edit form; confirm `input#name` reads 'Take out trash'; change points to '5' via DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: Exactly one 'Take out trash' row is in the list
|
||||
- expect: A delete icon is now visible on the 'Take out trash' row
|
||||
- expect: The points display reads '5 pts'
|
||||
|
||||
5. Click the delete icon and confirm the dialog
|
||||
|
||||
|
||||
- expect: 'Take out trash' is still visible in the list (system default restored)
|
||||
- expect: No delete icon on the 'Take out trash' row
|
||||
- expect: Points display is restored to '20 pts'
|
||||
|
||||
### 2. Kindness act management
|
||||
|
||||
**Seed:** `e2e/seed.spec.ts`
|
||||
|
||||
#### 2.1. Create a new kindness act (parent mode)
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores` and click the 'Kindness Acts' tab
|
||||
|
||||
|
||||
- expect: Kindness acts list is visible
|
||||
|
||||
2. Click 'Create Kindness Act'
|
||||
|
||||
|
||||
- expect: Create form appears with a 'Name' field
|
||||
|
||||
3. Enter a unique name (e.g. `Share toys ${suffix}`) and '5' in Points via DOM events
|
||||
|
||||
|
||||
- expect: Create button becomes enabled
|
||||
|
||||
4. Click Create
|
||||
|
||||
|
||||
- expect: No `.error` elements shown
|
||||
- expect: The new kindness act name appears in the list
|
||||
|
||||
#### 2.2. Cancel kindness act creation
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts', then click 'Create Kindness Act'
|
||||
|
||||
|
||||
- expect: Create form is visible
|
||||
|
||||
2. Fill in 'Test' for name and '5' for points, then click 'Cancel'
|
||||
|
||||
|
||||
- expect: Returned to the list
|
||||
- expect: 'Should not save' does not appear in the list
|
||||
|
||||
#### 2.3. Edit an existing kindness act
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; create `Share toys ${suffix}` fresh
|
||||
|
||||
|
||||
- expect: Item is displayed in the list
|
||||
|
||||
2. Click the row to open the edit form
|
||||
|
||||
|
||||
- expect: Edit form appears with 'Name' label and Save button visible
|
||||
|
||||
3. Change name to `Help with homework ${suffix}` and points to '8' via DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: The updated name `Help with homework ${suffix}` appears in the list
|
||||
|
||||
#### 2.4. Cancel kindness act edit
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; ensure 'Share toys' (exact) exists; click its row
|
||||
|
||||
|
||||
- expect: Edit form is displayed
|
||||
|
||||
2. Change name to 'Never saved' via DOM events, then click 'Cancel'
|
||||
|
||||
|
||||
- expect: 'Share toys' (exact) is still visible in the list
|
||||
|
||||
#### 2.5. Convert a default kindness act to a user item by editing
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-convert-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; locate the default 'Be good for the day'
|
||||
|
||||
|
||||
- expect: Item is visible with no delete icon
|
||||
|
||||
2. Click the row; rename to 'Be good today (edited)' and change points to '7' via `#name`/`#points` inputs
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
3. Click Save
|
||||
|
||||
|
||||
- expect: 'Be good today (edited)' row has a visible delete icon
|
||||
|
||||
4. _(Cleanup)_ Click the delete icon and confirm — restores the default so other tests see a clean state
|
||||
|
||||
#### 2.6. Delete a kindness act
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; create a uniquely-named act
|
||||
|
||||
|
||||
- expect: The item appears in the list with a delete control
|
||||
|
||||
2. Click the delete icon and confirm
|
||||
|
||||
|
||||
- expect: The act is removed from the list
|
||||
|
||||
#### 2.7. Edit default kindness act points and verify system restoration on delete
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/kindness-delete-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Kindness Acts'; clean up any leftover user-copy of 'Be good for the day' from previous runs
|
||||
2. Verify the default 'Be good for the day' is visible with no delete icon
|
||||
3. Click the row; confirm `input#name` reads 'Be good for the day'; change points to '3' via DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: Exactly one 'Be good for the day' row is in the list
|
||||
- expect: A delete icon is now visible on the row
|
||||
- expect: Points display reads '3 pts'
|
||||
|
||||
5. Click the delete icon and confirm the dialog
|
||||
|
||||
|
||||
- expect: 'Be good for the day' is still visible (system default restored)
|
||||
- expect: No delete icon on the row
|
||||
- expect: Points display is restored to '15 pts'
|
||||
|
||||
### 3. Penalty management
|
||||
|
||||
**Seed:** `e2e/seed.spec.ts`
|
||||
|
||||
#### 3.1. Create a new penalty (parent mode)
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores` and click the 'Penalties' tab
|
||||
|
||||
|
||||
- expect: Penalties list is shown
|
||||
|
||||
2. Click 'Create Penalty'
|
||||
|
||||
|
||||
- expect: Create Penalty form appears
|
||||
|
||||
3. Enter a unique name (e.g. `No screen time ${suffix}`) and '30' in Points via DOM events
|
||||
|
||||
|
||||
- expect: Create button becomes enabled
|
||||
|
||||
4. Click Create
|
||||
|
||||
|
||||
- expect: No validation errors
|
||||
- expect: The new penalty name appears in the list
|
||||
|
||||
#### 3.2. Cancel penalty creation
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Penalties', then click 'Create Penalty'
|
||||
|
||||
|
||||
- expect: Create penalty form is visible
|
||||
|
||||
2. Fill 'Should not save' and '10' points via DOM events, then click 'Cancel'
|
||||
|
||||
|
||||
- expect: 'Should not save' does not appear in the list
|
||||
|
||||
#### 3.3. Edit an existing penalty
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; create `No screen time ${suffix}` fresh
|
||||
|
||||
|
||||
- expect: Item appears in list
|
||||
|
||||
2. Click the row; wait for `#name` to have the original value
|
||||
|
||||
|
||||
- expect: Edit form opens with existing name pre-filled
|
||||
|
||||
3. Change name to `No dessert ${suffix}` and points to '20' via DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: `No dessert ${suffix}` appears in the list
|
||||
|
||||
#### 3.4. Cancel penalty edit
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; ensure 'No screen time' (exact) exists; click the row
|
||||
|
||||
|
||||
- expect: Edit form is displayed
|
||||
|
||||
2. Change name to 'Never saved' via DOM events, then click 'Cancel'
|
||||
|
||||
|
||||
- expect: 'No screen time' (exact) is still visible
|
||||
|
||||
#### 3.5. Convert a default penalty to a user item by editing
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-convert-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; locate the default 'Fighting'
|
||||
|
||||
|
||||
- expect: Item is visible with no delete icon
|
||||
|
||||
2. Click the row; rename to 'Fighting (custom)' and change points to '15' via `#name`/`#points` inputs
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
3. Click Save
|
||||
|
||||
|
||||
- expect: 'Fighting' row now has a visible delete icon
|
||||
|
||||
#### 3.6. Delete a penalty
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; create a uniquely-named penalty
|
||||
|
||||
|
||||
- expect: Delete control (`button[aria-label="Delete item"]`) is visible on the row
|
||||
|
||||
2. Click the delete icon and confirm via `button.btn-danger`
|
||||
|
||||
|
||||
- expect: Item is removed from the list
|
||||
|
||||
#### 3.7. Edit default penalty points and verify system restoration on delete
|
||||
|
||||
**File:** `e2e/mode_parent/tasks/penalty-delete-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Navigate to `/parent/tasks/chores`, click 'Penalties'; clean up any leftover user-copy of 'Fighting' from previous runs
|
||||
2. Verify the default 'Fighting' is visible with no delete icon
|
||||
3. Click the row; confirm `input#name` reads 'Fighting'; change points to '3' via DOM events
|
||||
|
||||
|
||||
- expect: Save button becomes enabled
|
||||
|
||||
4. Click Save
|
||||
|
||||
|
||||
- expect: Exactly one 'Fighting' row is in the list
|
||||
- expect: A delete icon is now visible on the row
|
||||
- expect: Points display reads '3 pts'
|
||||
|
||||
5. Click the delete icon and confirm the dialog
|
||||
|
||||
|
||||
- expect: 'Fighting' is still visible (system default restored)
|
||||
- expect: No delete icon on the row
|
||||
- expect: Points display is restored to '10 pts'
|
||||
602
frontend/e2e/plans/parent-notifications.plan.md
Normal file
602
frontend/e2e/plans/parent-notifications.plan.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# Parent Notifications E2E Tests
|
||||
|
||||
## Application Overview
|
||||
|
||||
The parent notifications feature adds two channels for alerting parents when a child performs an action requiring their attention:
|
||||
|
||||
1. **Web Push Notifications** — When a child confirms a chore is complete, or requests an affordable reward, the parent receives a native browser push notification via a Service Worker. The payload contains `child_id`, `entity_id`, and `entity_type`. Supporting browsers (Chrome desktop) show "Approve" and "Deny" action buttons; clicking an action button calls the matching backend API (`approve-chore`, `reject-chore`, `trigger-reward`, or `deny-reward-request`) without opening the app. Tapping the notification body opens or focuses the app and navigates to `/parent/:id?scrollTo=<entityId>&entityType=<chore|reward>`.
|
||||
|
||||
2. **Daily Email Digest** — At 9 pm in the parent's local timezone, a HTML digest email is sent for any still-pending items (only if at least one exists). Each item has three links: **View** (ParentView deep link), **Approve** (`GET /api/digest-action/<token>`), and **Deny** (`GET /api/digest-action/<token>`). Tokens are HMAC-SHA256 signed, valid for 24 hours, and single-use. On success the endpoint executes the action and 302-redirects to the ParentView deep link; on error (expired, already-used, tampered) it returns a 400 HTML page with no redirect.
|
||||
|
||||
**In-app notification view** — `/parent/notifications` lists all pending confirmations via `GET /api/pending-confirmations`. Clicking an item navigates to `/parent/:id?scrollTo=<entityId>&entityType=<type>`. Approval and rejection happen via **ParentView**:
|
||||
|
||||
- **Pending chores**: card shows a `.chore-stamp.pending-stamp` "PENDING" badge; clicking the card opens `ChoreApproveDialog` which has Approve and Reject buttons.
|
||||
- **Pending rewards**: card shows a `.pending` "PENDING" badge when `item.redeeming === true`; clicking opens a reward-confirm dialog that calls `POST trigger-reward`. A **Deny** button (added by this feature) calls `POST deny-reward-request`.
|
||||
|
||||
The notification view refreshes reactively via SSE events: `child_chore_confirmation` (operations: `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`) and `child_reward_request` (operations: `CREATED`, `CANCELLED`, `GRANTED`).
|
||||
|
||||
**Spec covered:** `feat-parent-chore-notifications.md`
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Tests live in `e2e/mode_parent/notifications/`. Each spec creates its own isolated child and entities via API so files can run in parallel within the bucket. All created resources are deleted in `afterAll`.
|
||||
|
||||
## Playwright Projects
|
||||
|
||||
| Project | testMatch | Notes |
|
||||
| ------------------------------- | -------------------------------------------- | ----------------------- |
|
||||
| `chromium-parent-notifications` | `/mode_parent\/notifications\/.+\.spec\.ts/` | Depends on `setup` only |
|
||||
|
||||
**Config:** Add project to `playwright.config.ts` with `dependencies: ['setup']` and `storageState: STORAGE_STATE`. Add `/mode_parent\/notifications\//` to the `testIgnore` list of any existing catch-all project so notification specs do not run in two buckets simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Seed Strategy
|
||||
|
||||
Each spec file uses `beforeAll` to create isolated children, tasks, and rewards via the authenticated `request` fixture, and deletes them all in `afterAll`. The standard pattern is: pre-delete by name, create via `PUT`, re-fetch to get the ID.
|
||||
|
||||
**Push subscription tests:** Service Worker registration and `PushManager.subscribe()` require a VAPID public key configured in the dev server environment. Tests 1.1 and 1.2 (intercepting the subscription POST to verify endpoint/timezone) depend on VAPID being configured and `PushManager` being available — both absent in headless Chromium. These tests have been **removed** from the spec. The remaining tests (1.3–1.5) cover the permission-denied path and unauthenticated API rejection, which do not require VAPID.
|
||||
|
||||
**Digest action token prerequisite:** The email digest scheduler is disabled in `DB_ENV=e2e` (to avoid sending real email). To exercise `GET /api/digest-action/<token>` in E2E tests, the backend must expose a test-only helper endpoint active **only** when `DB_ENV=e2e`:
|
||||
|
||||
```
|
||||
POST /api/admin/test/digest-token
|
||||
Auth: required (uses the E2E parent session)
|
||||
Body: { child_id, entity_id, entity_type, action, expires_in_hours? }
|
||||
Response 200: { token: string }
|
||||
```
|
||||
|
||||
This endpoint creates a valid `DigestActionToken` (with correct HMAC-SHA256 signature) in TinyDB and returns the token string. All digest-action tests call this helper in `beforeAll` to obtain tokens for their scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Push Subscription Registration
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/push-subscription.spec.ts`
|
||||
|
||||
**Seed:** No child/task setup required. Tests use `page.context().grantPermissions(['notifications'])` as needed. No `afterAll` cleanup.
|
||||
|
||||
> **Note on trigger**: Push subscription is now initiated by the Push Notifications toggle in `/parent/profile` (which calls `subscribeToPushWithResult()` directly). For these tests, triggering is done via the profile toggle rather than navigating to `/parent/tasks/chores`. PIN entry also triggers subscription in production but pre-authenticated E2E sessions bypass the PIN flow.
|
||||
|
||||
#### 1.3. No subscription POST is sent when notification permission is not granted
|
||||
|
||||
- Do NOT grant notification permission (leave default)
|
||||
- Track calls to `POST /api/push-subscription` via `page.route()`
|
||||
- Navigate to `/parent/profile` and attempt to click the Push Notifications toggle
|
||||
- expect: No POST to `/api/push-subscription` was captured
|
||||
|
||||
#### 1.4. Backend rejects unauthenticated subscription POST (401)
|
||||
|
||||
- Create a fresh `APIRequestContext` without storageState (no auth cookies) via `playwright.request.newContext()`
|
||||
- POST to `/api/push-subscription` with a minimal payload
|
||||
- expect: Response status is 401
|
||||
|
||||
#### 1.5. Backend rejects unauthenticated subscription DELETE (401)
|
||||
|
||||
- Using the same unauthenticated context as 1.4, send `DELETE /api/push-subscription`
|
||||
- expect: Response status is 401
|
||||
|
||||
---
|
||||
|
||||
### 2. Chore Notification — Appearance and In-App Approval
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/chore-notification-approve.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ChoreNotifApproveChild` and chore `ChoreNotifApproveChore` (5 points, type `chore`) via API. Assigns chore to child. Sets a daily interval schedule (`mode: 'interval', interval_days: 1, interval_has_deadline: false`) so the chore persists post-approval. In `beforeAll`, calls `POST /child/<childId>/confirm-chore` with `{ task_id: choreId }`. Deletes child and task in `afterAll`.
|
||||
|
||||
#### 2.1. Confirmed chore appears in the notification view
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- expect: A list item is visible containing the text `ChoreNotifApproveChore`
|
||||
- expect: The item contains the word "completed" (not "requested")
|
||||
|
||||
#### 2.2. Notification item displays the child's name
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- expect: The notification item for `ChoreNotifApproveChore` also shows `ChoreNotifApproveChild`
|
||||
|
||||
#### 2.3. Clicking notification navigates to ParentView with correct query params
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- Click the `ChoreNotifApproveChore` notification item
|
||||
- `await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=chore'), { timeout: 5000 })`
|
||||
- expect: URL contains `scrollTo=<choreId>` and `entityType=chore`
|
||||
- expect: The "Chores" section heading is visible
|
||||
|
||||
#### 2.4. Pending chore card shows PENDING stamp in ParentView
|
||||
|
||||
- Navigate to `/parent/<childId>`
|
||||
- Locate the chore card for `ChoreNotifApproveChore` in the Chores section
|
||||
- expect: Card contains an element with text "PENDING"
|
||||
|
||||
#### 2.5. Clicking a pending chore card opens ChoreApproveDialog
|
||||
|
||||
- Navigate to `/parent/<childId>`
|
||||
- Click the `ChoreNotifApproveChore` card
|
||||
- expect: A dialog element is visible containing both an "Approve" button and a "Reject" button
|
||||
|
||||
#### 2.6. Approving via ChoreApproveDialog removes the PENDING stamp
|
||||
|
||||
- Click the "Approve" button in the dialog
|
||||
- expect: The dialog closes
|
||||
- expect: The "PENDING" stamp is no longer present on the `ChoreNotifApproveChore` card
|
||||
|
||||
#### 2.7. Approving a chore clears it from the notification view
|
||||
|
||||
- After approving (2.6), navigate to `/parent/notifications`
|
||||
- expect: No list item containing `ChoreNotifApproveChore` is present
|
||||
|
||||
#### 2.8. Approving a chore awards points to the child
|
||||
|
||||
- Note child's point balance before approval
|
||||
- Approve the chore via the dialog
|
||||
- expect: Child's point balance increases by the chore's point value (5)
|
||||
|
||||
---
|
||||
|
||||
### 3. Chore Notification — In-App Rejection
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/chore-notification-deny.spec.ts`
|
||||
|
||||
**Seed:** Creates child `ChoreNotifDenyChild` and chore `ChoreNotifDenyChore` (5 points) via API. Assigns chore. Sets daily interval schedule. In `beforeAll`, calls `POST /child/<childId>/confirm-chore`. Deletes all in `afterAll`.
|
||||
|
||||
#### 3.1. Confirmed chore appears in the notification view
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- expect: A list item is visible containing `ChoreNotifDenyChore`
|
||||
|
||||
#### 3.2. ChoreApproveDialog shows both Approve and Reject buttons
|
||||
|
||||
- Navigate to `/parent/<childId>`, click the `ChoreNotifDenyChore` card
|
||||
- expect: Dialog with "Approve" and "Reject" buttons is visible
|
||||
|
||||
#### 3.3. Rejecting via ChoreApproveDialog removes the PENDING stamp
|
||||
|
||||
- Click the "Reject" button
|
||||
- expect: Dialog closes
|
||||
- expect: "PENDING" stamp is no longer present on the `ChoreNotifDenyChore` card
|
||||
|
||||
#### 3.4. Rejecting a chore clears it from the notification view
|
||||
|
||||
- After rejecting (3.3), navigate to `/parent/notifications`
|
||||
- expect: No list item containing `ChoreNotifDenyChore` is present
|
||||
|
||||
#### 3.5. Rejecting a chore does not award points
|
||||
|
||||
- Note child's point balance before rejection
|
||||
- Reject the pending chore via ChoreApproveDialog
|
||||
- expect: Child's point balance is unchanged
|
||||
|
||||
---
|
||||
|
||||
### 4. Reward Notification — Appearance and In-App Grant
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/reward-notification-approve.spec.ts`
|
||||
|
||||
**Seed:** Creates child `RewardNotifApproveChild` with `points = 30` via API. Creates reward `RewardNotifApproveReward` with `cost = 10`. Assigns reward to child. In `beforeAll`, calls `POST /child/<childId>/request-reward` with `{ reward_id: rewardId }`. Deletes child and reward in `afterAll`.
|
||||
|
||||
#### 4.1. Requested reward appears in the notification view
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- expect: A list item is visible containing `RewardNotifApproveReward`
|
||||
- expect: The item contains the word "requested" (not "completed")
|
||||
|
||||
#### 4.2. Notification item displays the child's name
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- expect: The notification item for `RewardNotifApproveReward` also shows `RewardNotifApproveChild`
|
||||
|
||||
#### 4.3. Clicking reward notification navigates to ParentView with correct query params
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- Click the `RewardNotifApproveReward` notification item
|
||||
- `await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=reward'), { timeout: 5000 })`
|
||||
- expect: URL contains `scrollTo=<rewardId>` and `entityType=reward`
|
||||
|
||||
#### 4.4. Pending reward card shows PENDING badge in ParentView
|
||||
|
||||
- Navigate to `/parent/<childId>`
|
||||
- Locate the reward card for `RewardNotifApproveReward` in the Rewards section
|
||||
- expect: Card contains an element with text "PENDING"
|
||||
|
||||
#### 4.5. Clicking a pending reward card opens the grant confirmation dialog
|
||||
|
||||
- Navigate to `/parent/<childId>`, click the `RewardNotifApproveReward` card
|
||||
- expect: A reward confirmation dialog appears
|
||||
|
||||
#### 4.6. Confirming the grant removes the PENDING badge and updates child points
|
||||
|
||||
- In the grant confirmation dialog, click the confirm button
|
||||
- expect: Dialog closes
|
||||
- expect: The "PENDING" badge is no longer visible on the reward card
|
||||
- expect: Child's point balance decreases by the reward cost (30 → 20)
|
||||
|
||||
#### 4.7. Granting a reward clears it from the notification view
|
||||
|
||||
- After confirming the grant (4.6), navigate to `/parent/notifications`
|
||||
- expect: No list item containing `RewardNotifApproveReward` is present
|
||||
|
||||
---
|
||||
|
||||
### 5. Reward Notification — In-App Denial
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/reward-notification-deny.spec.ts`
|
||||
|
||||
**Seed:** Creates child `RewardNotifDenyChild` with `points = 30`. Creates reward `RewardNotifDenyReward` with `cost = 10`. Assigns reward to child. Uses `beforeEach` to call `POST /child/<childId>/request-reward` (creates a fresh pending request before each test) and `afterEach` to call `POST /child/<childId>/cancel-request-reward` to clean up any unconsumed requests. Deletes child and reward in `afterAll`.
|
||||
|
||||
#### 5.1. Requested reward appears in the notification view
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- expect: A list item is visible containing `RewardNotifDenyReward`
|
||||
|
||||
#### 5.2. Pending reward card shows PENDING badge in ParentView
|
||||
|
||||
- Navigate to `/parent/<childId>`
|
||||
- expect: The `RewardNotifDenyReward` card shows a "PENDING" badge
|
||||
|
||||
#### 5.3. Denying a reward request removes the PENDING badge
|
||||
|
||||
- Trigger the deny action for the pending reward (target the Deny button by its label or role — exact placement determined by implementation)
|
||||
- expect: The "PENDING" badge disappears from the reward card
|
||||
- expect: Child's point balance is unchanged
|
||||
|
||||
#### 5.4. Denying a reward request clears it from the notification view
|
||||
|
||||
- After denying (5.3), navigate to `/parent/notifications`
|
||||
- expect: No list item containing `RewardNotifDenyReward` is present
|
||||
|
||||
#### 5.5. `POST deny-reward-request` returns success when a pending request exists (API layer)
|
||||
|
||||
- Via the authenticated `request` fixture, call `POST /child/<childId>/deny-reward-request` with `{ reward_id: rewardId }`
|
||||
- expect: Response status is 200
|
||||
|
||||
#### 5.6. `POST deny-reward-request` returns 404 when no pending request exists (API layer)
|
||||
|
||||
- Cancel any existing pending request first via `POST /child/<childId>/cancel-request-reward`
|
||||
- Call `POST /child/<childId>/deny-reward-request` with `{ reward_id: rewardId }`
|
||||
- expect: Response status is 404
|
||||
|
||||
---
|
||||
|
||||
### 6. ParentView Deep-Link Navigation
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/deep-link-navigation.spec.ts`
|
||||
|
||||
**Seed:** Creates child `DeepLinkChild` with `points = 100`. Creates 10 chores `DeepLinkChore1` … `DeepLinkChore10` (each 5 points, type `chore`). Creates reward `DeepLinkReward` with `cost = 20`. Assigns all 10 chores and the reward to the child. Confirms `DeepLinkChore10` via `POST /child/<childId>/confirm-chore`. Requests the reward via `POST /child/<childId>/request-reward`. Uses narrow viewport `{ width: 480, height: 800 }` so cards near the bottom require actual scrolling. Deletes child, all tasks, and reward in `afterAll`.
|
||||
|
||||
#### 6.1. Deep link to chore — correct card is scrolled into viewport
|
||||
|
||||
- `await page.setViewportSize({ width: 480, height: 800 })`
|
||||
- Navigate to `/parent/<childId>?scrollTo=<choreId>&entityType=chore`
|
||||
- Wait for chore list to load and allow 800 ms for the scroll delay to complete
|
||||
- expect: The `DeepLinkChore10` card is within the visible viewport
|
||||
|
||||
#### 6.2. Deep link to reward — correct card is scrolled into viewport
|
||||
|
||||
- `await page.setViewportSize({ width: 480, height: 800 })`
|
||||
- Navigate to `/parent/<childId>?scrollTo=<rewardId>&entityType=reward`
|
||||
- Wait for reward list to load and scroll delay
|
||||
- expect: The `DeepLinkReward` card is within the visible viewport
|
||||
|
||||
#### 6.3. Deep link with unknown `entityType` — page loads without JS error
|
||||
|
||||
- Listen for `page.on('pageerror', ...)` before navigating
|
||||
- Navigate to `/parent/<childId>?scrollTo=<choreId>&entityType=unknown`
|
||||
- expect: No pageerror event fires
|
||||
- expect: Page loads and the "Chores" section heading is visible
|
||||
|
||||
#### 6.4. Deep link with missing `scrollTo` — page loads normally
|
||||
|
||||
- Navigate to `/parent/<childId>?entityType=chore` (no `scrollTo` query param)
|
||||
- expect: Page loads without error and "Chores" section is visible
|
||||
|
||||
#### 6.5. Deep link with no query params — page loads normally
|
||||
|
||||
- Navigate to `/parent/<childId>` (bare URL, no query params)
|
||||
- expect: Page loads and both "Chores" and "Rewards" section headings are visible
|
||||
|
||||
#### 6.6. Clicking a chore notification produces the correct deep link
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- Click the `DeepLinkChore10` notification item (confirmed chore)
|
||||
- expect: URL matches `/parent/<childId>?scrollTo=<choreId>&entityType=chore`
|
||||
- Wait 800 ms for scroll delay
|
||||
- expect: `DeepLinkChore10` card is in viewport
|
||||
|
||||
#### 6.7. Clicking a reward notification produces the correct deep link
|
||||
|
||||
- Navigate to `/parent/notifications`
|
||||
- Click the `DeepLinkReward` notification item
|
||||
- expect: URL matches `/parent/<childId>?scrollTo=<rewardId>&entityType=reward`
|
||||
- Wait 800 ms
|
||||
- expect: `DeepLinkReward` card is in viewport
|
||||
|
||||
---
|
||||
|
||||
### 7. Digest Action Token — Happy Paths
|
||||
|
||||
Each combination of `entity_type × action` gets its own spec file to keep seeds isolated and specs independently runnable.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `e2e/mode_parent/notifications/digest-action-approve-chore.spec.ts`
|
||||
- `e2e/mode_parent/notifications/digest-action-deny-chore.spec.ts`
|
||||
- `e2e/mode_parent/notifications/digest-action-approve-reward.spec.ts`
|
||||
- `e2e/mode_parent/notifications/digest-action-deny-reward.spec.ts`
|
||||
|
||||
**Seed (shared pattern for all four specs):** Creates an isolated child, task or reward, assigns it, creates a pending confirmation (via `confirm-chore` or `request-reward`), then calls the authenticated `POST /api/admin/test/digest-token` helper to obtain a valid signed token for the required action. All `GET /digest-action/<token>` calls use a **fresh unauthenticated `APIRequestContext`** (no cookies) created via `playwright.request.newContext({ ignoreHTTPSErrors: true })`. The verifying `GET /api/pending-confirmations` and `GET /api/child/<id>` calls use the authenticated `request` fixture. Deletes child and task/reward in `afterAll`.
|
||||
|
||||
#### 7.1. Approve-chore token — 302 redirect to correct ParentView URL
|
||||
|
||||
- Call `GET /api/digest-action/<approve_chore_token>` (unauthenticated, redirect following disabled)
|
||||
- expect: Response status is 302
|
||||
- expect: `response.headers()['location']` contains `/parent/<childId>`, `scrollTo=<choreId>`, and `entityType=chore`
|
||||
|
||||
#### 7.2. Approve-chore token — approve action is performed, points awarded
|
||||
|
||||
- After calling token (7.1), wait 200 ms for the action to complete
|
||||
- Via authenticated `request`: `GET /api/pending-confirmations`
|
||||
- expect: No pending confirmation record exists for this chore and child
|
||||
- Via authenticated `request`: `GET /api/child/<childId>`
|
||||
- expect: Points increased by the chore's point value
|
||||
|
||||
#### 7.3. Deny-chore token — 302 redirect to correct ParentView URL
|
||||
|
||||
- Call `GET /api/digest-action/<deny_chore_token>` (unauthenticated, redirect following disabled)
|
||||
- expect: Response status is 302
|
||||
- expect: Location header contains the correct ParentView deep link (`entityType=chore`)
|
||||
|
||||
#### 7.4. Deny-chore token — rejection action is performed, no points awarded
|
||||
|
||||
- After calling token (7.3), verify:
|
||||
- expect: Pending confirmation for this chore is removed from `GET /api/pending-confirmations`
|
||||
- expect: Child's point balance is unchanged
|
||||
|
||||
#### 7.5. Approve-reward token — 302 redirect to correct ParentView URL
|
||||
|
||||
- Call `GET /api/digest-action/<approve_reward_token>` (unauthenticated, redirect following disabled)
|
||||
- expect: Response status is 302
|
||||
- expect: Location header contains `/parent/<childId>`, `scrollTo=<rewardId>`, and `entityType=reward`
|
||||
|
||||
#### 7.6. Approve-reward token — reward is triggered and child points are deducted
|
||||
|
||||
- After calling token (7.5):
|
||||
- expect: Pending confirmation for this reward is gone from `GET /api/pending-confirmations`
|
||||
- expect: Child's point balance decreased by the reward cost
|
||||
|
||||
#### 7.7. Deny-reward token — 302 redirect to correct ParentView URL
|
||||
|
||||
- Call `GET /api/digest-action/<deny_reward_token>` (unauthenticated, redirect following disabled)
|
||||
- expect: Response status is 302
|
||||
- expect: Location header contains the correct ParentView deep link for this child and reward
|
||||
|
||||
#### 7.8. Deny-reward token — pending reward request is removed, points unchanged
|
||||
|
||||
- After calling token (7.7):
|
||||
- expect: Pending confirmation for this reward is gone
|
||||
- expect: Child's point balance is unchanged
|
||||
|
||||
---
|
||||
|
||||
### 8. Digest Action Token — Error Paths
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/digest-action-errors.spec.ts`
|
||||
|
||||
**Seed:** Creates child `DigestErrorChild` and chore `DigestErrorChore` via API. Assigns chore. Calls `POST /child/<childId>/confirm-chore` to create a pending confirmation. Uses `POST /api/admin/test/digest-token` to generate tokens with specific expiry configurations. All `GET /digest-action/<token>` calls use a fresh unauthenticated context. Deletes child and task in `afterAll`.
|
||||
|
||||
#### 8.1. Expired token returns 400
|
||||
|
||||
- Call `POST /api/admin/test/digest-token` with `expires_in_hours: -1` (already expired at creation)
|
||||
- Call `GET /api/digest-action/<expired_token>` (unauthenticated, redirect following disabled)
|
||||
- expect: Response status is 400
|
||||
- expect: No `Location` header is present (no redirect occurred)
|
||||
|
||||
#### 8.2. Already-used token returns 400 on second use
|
||||
|
||||
- Create a valid token via test helper
|
||||
- First call: `GET /api/digest-action/<token>` — expect: status 302 (token consumed, action executed)
|
||||
- Second call: `GET /api/digest-action/<token>` with the same token string
|
||||
- expect: Response status is 400
|
||||
|
||||
#### 8.3. Tampered token returns 400
|
||||
|
||||
- Create a valid token via test helper; store the raw token string
|
||||
- Modify one character of the token (e.g. replace the final character with a different letter)
|
||||
- Call `GET /api/digest-action/<tampered_token>` (unauthenticated)
|
||||
- expect: Response status is 400
|
||||
|
||||
#### 8.4. Completely fake token returns 400
|
||||
|
||||
- Call `GET /api/digest-action/this-token-does-not-exist`
|
||||
- expect: Response status is 400
|
||||
|
||||
#### 8.5. Error response is a plain HTML page with no redirect
|
||||
|
||||
- For any 400 response from 8.1–8.4:
|
||||
- expect: `content-type` header contains `text/html`
|
||||
- expect: The response body does not include a `Location` header
|
||||
|
||||
---
|
||||
|
||||
### 9. Service Worker Push Delivery — Manual Tests and Mock Strategy
|
||||
|
||||
> **Playwright limitation:** The following scenarios **cannot be automated** with standard Playwright:
|
||||
>
|
||||
> - Playwright has no API to dispatch a `push` event to a registered Service Worker.
|
||||
> - OS-level notification tray interactions ("Approve"/"Deny" action buttons) are outside the browser's JavaScript sandbox.
|
||||
> - Delivering a real push message requires a live Web Push server sending to the browser's push endpoint, which is impractical in a local E2E environment.
|
||||
>
|
||||
> These scenarios must be verified **manually** during feature QA, or via the **mock strategy** below.
|
||||
|
||||
#### Recommended Mock Strategy — Service Worker Test Hook
|
||||
|
||||
Add a test-only message handler to `public/sw.js`, guarded by `self.location.hostname === 'localhost'`:
|
||||
|
||||
```js
|
||||
// sw.js — localhost test hook only
|
||||
self.addEventListener('message', async (event) => {
|
||||
if (event.data?.type !== '__TEST_NOTIFICATION_CLICK__') return
|
||||
const { action, childId, entityId, entityType } = event.data
|
||||
|
||||
if (action === 'approve') {
|
||||
const url =
|
||||
entityType === 'chore'
|
||||
? `/api/child/${childId}/approve-chore`
|
||||
: `/api/child/${childId}/trigger-reward`
|
||||
const body =
|
||||
entityType === 'chore'
|
||||
? JSON.stringify({ task_id: entityId })
|
||||
: JSON.stringify({ reward_id: entityId })
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
} else if (action === 'deny') {
|
||||
const url =
|
||||
entityType === 'chore'
|
||||
? `/api/child/${childId}/reject-chore`
|
||||
: `/api/child/${childId}/deny-reward-request`
|
||||
const body =
|
||||
entityType === 'chore'
|
||||
? JSON.stringify({ task_id: entityId })
|
||||
: JSON.stringify({ reward_id: entityId })
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
} else {
|
||||
// body tap — focus or open the app at the deep link
|
||||
const deepLink = `/parent/${childId}?scrollTo=${entityId}&entityType=${entityType}`
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
if (clients[0]) {
|
||||
await clients[0].focus()
|
||||
await clients[0].navigate(deepLink)
|
||||
} else {
|
||||
await self.clients.openWindow(deepLink)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Invoke from a Playwright spec after the SW is registered and controlling the page:
|
||||
|
||||
```ts
|
||||
await page.evaluate(
|
||||
({ childId, entityId, entityType, action }) => {
|
||||
navigator.serviceWorker.controller?.postMessage({
|
||||
type: '__TEST_NOTIFICATION_CLICK__',
|
||||
action,
|
||||
childId,
|
||||
entityId,
|
||||
entityType,
|
||||
})
|
||||
},
|
||||
{ childId, entityId, entityType, action: 'approve' },
|
||||
)
|
||||
```
|
||||
|
||||
This allows a spec to simulate notification action clicks without OS interaction, enabling API verification and navigation assertions on top of the mock.
|
||||
|
||||
#### Manual QA Checklist (no spec file — document only)
|
||||
|
||||
- Parent tab active: SSE in-app toast appears; no OS push notification is sent
|
||||
- Parent tab in background or closed: OS push notification appears showing the child name and chore/reward name
|
||||
- Notification does NOT appear for a reward request when the child cannot afford the reward
|
||||
- Notification shows "Approve" and "Deny" action buttons on Chrome desktop; no action buttons on iOS Safari (fallback: body tap only)
|
||||
- Clicking "Approve" on a chore notification calls `POST approve-chore`, dismisses the notification tray entry, and does NOT open or focus the app
|
||||
- Clicking "Deny" on a chore notification calls `POST reject-chore`, dismisses, no app focus
|
||||
- Clicking "Approve" on a reward notification calls `POST trigger-reward`, dismisses, no app focus
|
||||
- Clicking "Deny" on a reward notification calls `POST deny-reward-request`, dismisses, no app focus
|
||||
- Tapping the chore notification body opens (or focuses) the app at `/parent/<childId>?scrollTo=<choreId>&entityType=chore` with the chore card scrolled into view
|
||||
- Tapping the reward notification body opens/focuses the app at `/parent/<childId>?scrollTo=<rewardId>&entityType=reward` with the reward card scrolled into view
|
||||
- If the app is already open in another tab, tapping the notification body focuses the existing tab and navigates it — a second tab is NOT opened
|
||||
- iOS Safari fall-back: notification body tap still navigates correctly to the deep link; no action buttons are rendered
|
||||
- After the app tab is fully closed and a push notification arrives and is tapped, the app correctly opens at the deep-link URL
|
||||
|
||||
---
|
||||
|
||||
### 10. User Profile Notification Settings
|
||||
|
||||
**File:** `e2e/mode_parent/notifications/user-profile-notification-settings.spec.ts`
|
||||
|
||||
**Seed:** No child/task setup required. Tests navigate directly to the User Profile page. No `afterAll` cleanup beyond any push subscription state left by the tests (handled internally by `beforeEach`/`afterEach` where needed).
|
||||
|
||||
#### 10.1. Email Digest toggle is visible on User Profile page
|
||||
|
||||
- Navigate to `/parent/profile` (or the route that renders `UserProfile.vue`)
|
||||
- expect: An element with the label text "Daily Digest" is visible
|
||||
|
||||
#### 10.2. Email Digest toggle initialises from the server value
|
||||
|
||||
- Via authenticated `request`, call `PUT /api/user/profile` with `{ email_digest_enabled: true }` to set a known state
|
||||
- Navigate to `/parent/profile`
|
||||
- expect: The Daily Digest toggle is checked/on
|
||||
- Via authenticated `request`, call `PUT /api/user/profile` with `{ email_digest_enabled: false }`
|
||||
- Reload the page
|
||||
- expect: The Daily Digest toggle is unchecked/off
|
||||
|
||||
#### 10.3. Toggling Email Digest off calls the correct API
|
||||
|
||||
- Via `request`, ensure `email_digest_enabled` is `true` before the test
|
||||
- Navigate to `/parent/profile`
|
||||
- Intercept `PUT /api/user/profile` with `page.route()`
|
||||
- Click the Daily Digest toggle to switch it off
|
||||
- expect: Intercepted PUT body contains `email_digest_enabled: false`
|
||||
- expect: Toggle visually reflects the off state
|
||||
|
||||
#### 10.4. Toggling Email Digest on calls the correct API
|
||||
|
||||
- Via `request`, ensure `email_digest_enabled` is `false` before the test
|
||||
- Navigate to `/parent/profile`
|
||||
- Intercept `PUT /api/user/profile` with `page.route()`
|
||||
- Click the Daily Digest toggle to switch it on
|
||||
- expect: Intercepted PUT body contains `email_digest_enabled: true`
|
||||
- expect: Toggle visually reflects the on state
|
||||
|
||||
#### 10.5. Push Notifications toggle is visible on User Profile page
|
||||
|
||||
- Navigate to `/parent/profile`
|
||||
- expect: An element with the label text "Push Notifications" is visible
|
||||
|
||||
#### 10.6. Push Notifications toggle initialises to off when no subscription exists
|
||||
|
||||
- Do NOT call `page.context().grantPermissions(['notifications'])` (leave default denied/prompt)
|
||||
- Navigate to `/parent/profile`
|
||||
- expect: The Push Notifications toggle is unchecked/off
|
||||
|
||||
#### 10.8. Push Notifications toggle is disabled when browser permission is denied
|
||||
|
||||
- Call `page.context().clearPermissions()` then navigate to `/parent/profile` with notifications permission absent (or set to `'denied'` if Playwright supports it)
|
||||
- expect: The Push Notifications toggle input is disabled (`aria-disabled` or `disabled` attribute set)
|
||||
|
||||
---
|
||||
|
||||
## Test Summary
|
||||
|
||||
| # | Group | Spec File(s) | Test Cases |
|
||||
| --- | --------------------------------- | -------------------------------------------- | ---------- |
|
||||
| 1 | Push subscription registration | `push-subscription.spec.ts` | 3 |
|
||||
| 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 |
|
||||
| 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 |
|
||||
| 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 |
|
||||
| 5 | Reward notification — deny flow | `reward-notification-deny.spec.ts` | 6 |
|
||||
| 6 | ParentView deep-link navigation | `deep-link-navigation.spec.ts` | 7 |
|
||||
| 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 |
|
||||
| 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 |
|
||||
| 9 | SW push — manual/mock | (no spec file) | checklist |
|
||||
| 10 | User Profile notification toggles | `user-profile-notification-settings.spec.ts` | 7 |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- A backend test-only endpoint `POST /api/admin/test/digest-token` (guarded by `DB_ENV=e2e`) is a **prerequisite** for scenario groups 7 and 8 to work.
|
||||
- The SW mock strategy in group 9 requires a `__TEST_NOTIFICATION_CLICK__` message handler added to `sw.js` (localhost-only, not present in production builds).
|
||||
- The `playwright.config.ts` catch-all project must get `e2e/mode_parent/notifications/` added to its `testIgnore` list to prevent double-running.
|
||||
- Tests 1.1, 1.2, 10.7, 10.9, and 10.10 were removed: all required `PushManager.subscribe()` which is unavailable in headless Chromium without a VAPID key. The features they covered work correctly in a real browser; these scenarios are verified via manual QA (see §9).
|
||||
65
frontend/e2e/plans/parent-profile-button.plan.md
Normal file
65
frontend/e2e/plans/parent-profile-button.plan.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Parent Profile Button
|
||||
|
||||
## Application Overview
|
||||
|
||||
In parent mode, the top right of the screen should display the parent's profile icon. When clicking this icon, a menu should appear giving options to select.
|
||||
This is a parent mode only test, and probably should be in it's own bucket of tests
|
||||
|
||||
**Profile Button Badge:**
|
||||
|
||||
- There are 2 modes for the parent profile button in parent mode: Temporary access and Permanent access.
|
||||
- In temporary mode, there is no badge overlaying the profile icon button. This mode will revert back to child mode after after 1 minute or a reload of the site (see feat-parent-mode-expire.md)
|
||||
- In permanent mode, there should be a lock emoji badge over the icon button. This lets the user know that parent mode is active for 2 days. Reloading the page still keeps permanent mode. (see feat-parent-mode-expire.md)
|
||||
|
||||
**Profile Button Menu Options:**
|
||||
When clicking the profile button, we should see the user's selected profile picture, name, and email. Verify those are present. Under this are options.
|
||||
|
||||
1. Profile
|
||||
- This option should take the user to the Edit Profile page. We just need to verify that page is there, testing the page will be for another plan.
|
||||
2. Child Mode
|
||||
- Clicking this will take us out of parent mode and back to child mode. This can be verified by no longer having the view-selector navigation tab at the top-middle of the screen.
|
||||
- In Child mode, clicking the profile button again should prompt us for the parent PIN.
|
||||
- Entering the correct PIN will take us back into temporary parent mode. It will take us back into permanent parent mode if the checkbox is selected during PIN entry.
|
||||
3. Sign Out
|
||||
- This will take us back to the landing page.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Constants
|
||||
|
||||
`E2E_FIRST_NAME` and `STORAGE_STATE_TEMP_PARENT` were added to `e2e/e2e-constants.ts`. `E2E_FIRST_NAME` holds the first name of the seeded test user and is used to verify the name appears in the profile menu.
|
||||
|
||||
### Auth Setup
|
||||
|
||||
`e2e/auth-temp-parent.setup.ts` was created to produce a temporary parent mode storage state (logs in and enters PIN without checking "Stay in parent mode"). It depends on `setup` so it runs after the database is seeded.
|
||||
|
||||
### Playwright Projects (`playwright.config.ts`)
|
||||
|
||||
Two new isolated projects were added:
|
||||
|
||||
- **`chromium-profile-button`** — uses the standard permanent parent mode storage state. Runs all spec files under `e2e/mode_parent/profile-button/`.
|
||||
- **`chromium-profile-button-temp`** — uses the temporary parent mode storage state. Runs the temporary-mode badge spec only.
|
||||
|
||||
Both projects are excluded from the catch-all `chromium-tasks-rewards` project.
|
||||
|
||||
### Test Files
|
||||
|
||||
**`e2e/mode_parent/profile-button/profile-button.spec.ts`** ✅ IMPLEMENTED
|
||||
|
||||
Covers all permanent parent mode scenarios (7 tests):
|
||||
|
||||
1. **Badge – 🔒 visible in permanent mode** ✅
|
||||
2. **Menu – shows user name and email** ✅
|
||||
3. **Menu → Profile navigates to User Profile page** ✅
|
||||
4. **Menu → Child Mode exits parent mode** ✅ (view-selector disappears, button label changes to "Parent login")
|
||||
5. **Menu → Child Mode re-entry without checkbox enters temporary mode** ✅ (no 🔒 badge after re-entry)
|
||||
6. **Menu → Child Mode re-entry with checkbox enters permanent mode** ✅ (🔒 badge visible after re-entry)
|
||||
7. **Menu → Sign Out navigates to landing page** ✅ ("Sign In" nav button visible)
|
||||
|
||||
**`e2e/mode_parent/profile-button/profile-button-temporary.spec.ts`** ✅ IMPLEMENTED
|
||||
|
||||
Covers the temporary parent mode badge scenario (1 test):
|
||||
|
||||
1. **Badge – no 🔒 badge in temporary mode** ✅ (enters temp mode by exiting to child mode then re-entering PIN without "Stay" checkbox)
|
||||
113
frontend/e2e/plans/parent-rewards-management.plan.md
Normal file
113
frontend/e2e/plans/parent-rewards-management.plan.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Parent Rewards Management
|
||||
|
||||
## Application Overview
|
||||
|
||||
Focus on parent-mode flows interacting with the Rewards section. A parent must be authenticated via PIN and can create, cancel, edit, delete, and manage default rewards. The scenarios mirror the chore/kindness/penalty plans but target `/parent/rewards` and the reward-specific fields (name, cost, description). All tests run under parent mode in the `mode_parent/rewards` directory. Unique names use a `${Date.now()}` suffix to avoid collisions.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Create and manage rewards
|
||||
|
||||
**Seed:** `e2e/seed.spec.ts`
|
||||
|
||||
#### 1.1. Create a new reward
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards` and verify the default reward list loads (some reward name visible)
|
||||
2. Click the 'Create Reward' FAB
|
||||
- expect: The create form appears with 'Reward Name' and 'Cost' fields
|
||||
- expect: Create button is disabled initially
|
||||
3. Enter a unique name (e.g. `Toy car ${suffix}`) and `20` in Cost via DOM events
|
||||
- expect: Create button becomes enabled
|
||||
4. Click the Create button
|
||||
- expect: No `.error` elements are shown
|
||||
- expect: The new reward appears in the list
|
||||
|
||||
#### 1.2. Cancel reward creation
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards` and click 'Create Reward'
|
||||
- expect: Create form is displayed with name label visible
|
||||
2. Fill 'Test' for name and '5' for cost then click 'Cancel'
|
||||
- expect: User is returned to the rewards list
|
||||
- expect: No element with text 'Test' exists
|
||||
|
||||
#### 1.3. Edit an existing reward
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards`; create `Toy car ${suffix}` if absent
|
||||
- expect: The reward appears in the list
|
||||
2. Click the reward row to open edit form
|
||||
- expect: Edit form appears with 'Reward Name' label visible
|
||||
3. Change the name to `Toy boat ${suffix}` and cost to '25' via DOM events
|
||||
- expect: Save button becomes enabled
|
||||
4. Click Save
|
||||
- expect: No errors are displayed
|
||||
- expect: Updated reward name appears in the list
|
||||
|
||||
#### 1.4. Cancel reward edit
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-cancel.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards`; ensure `Toy car` exists; click its row
|
||||
- expect: Edit form is displayed
|
||||
2. Change the name to 'Should not save' via DOM events then click 'Cancel'
|
||||
- expect: Rewards list visible again
|
||||
- expect: `Toy car` is still present
|
||||
- expect: `Should not save` does not appear
|
||||
|
||||
#### 1.5. Convert a default reward to a user item by editing
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-convert-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards` and locate a default reward such as 'Choose meal'
|
||||
- expect: Item is visible with no delete button
|
||||
2. Click the row to open edit form
|
||||
- expect: Edit form opens with `input#name` value matching the default
|
||||
3. Change the name to 'Choose meal (custom)' and cost to '35'
|
||||
- expect: Save button becomes enabled
|
||||
4. Click Save
|
||||
- expect: Row now shows a visible delete button
|
||||
|
||||
#### 1.6. Delete a reward
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards`; create a unique reward if necessary
|
||||
- expect: The reward appears in the list with a delete control
|
||||
2. Click the delete icon and confirm the dialog
|
||||
- expect: Item is removed from the list
|
||||
|
||||
#### 1.7. Edit default reward cost and verify system restoration on delete
|
||||
|
||||
**File:** `e2e/mode_parent/rewards/reward-delete-default.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. -
|
||||
- expect: Navigate to `/parent/rewards`; clean up any user-copy of 'Choose meal' from prior runs
|
||||
- expect: Verify default 'Choose meal' is present with no delete icon
|
||||
2. Click 'Choose meal' to open edit form; confirm input#name reads 'Choose meal'; change cost to '50' via DOM events
|
||||
- expect: Save button becomes enabled
|
||||
3. Click Save
|
||||
- expect: Exactly one 'Choose meal' row exists
|
||||
- expect: Delete icon now visible on that row
|
||||
- expect: Cost display reads '50 pts'
|
||||
4. Click delete icon and confirm
|
||||
- expect: 'Choose meal' remains in list (default restored)
|
||||
- expect: No delete icon present
|
||||
- expect: Cost display returns to original value
|
||||
72
frontend/e2e/plans/task-activated.plan.md
Normal file
72
frontend/e2e/plans/task-activated.plan.md
Normal 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)
|
||||
149
frontend/e2e/plans/task-assignment.plan.md
Normal file
149
frontend/e2e/plans/task-assignment.plan.md
Normal 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
|
||||
239
frontend/e2e/plans/task-modified.plan.md
Normal file
239
frontend/e2e/plans/task-modified.plan.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Parent Activate Modifies Task (changing points and resetting completed tasks)
|
||||
|
||||
## Application Overview
|
||||
|
||||
In parent mode, with a populated list of chores, kindness acts, penalties, and rewards, tests must be run to make sure a chore has a kebab menu that lets a parent make modifications to the chore. For kindness acts, penalties, and rewards, parents should be able to click on the edit button and change the amount of points or cost of the item.
|
||||
|
||||
This is a continuation of tests from task-assignment.plan.md
|
||||
|
||||
**Chores:**
|
||||
|
||||
- When the parent clicks on a chore's kebab menu, there should be an item that allows the parent to "Edit Points"
|
||||
- When the parent clicks on a chore's kebab menu with the chore already in the COMPLETED state, there should be a "Reset" option.
|
||||
- When a chore's "Edit Points" are clicked, a new dialog should appear with the chore's title, icon, and point value. These points should be editable. The value should not be negitive. If cancelled, nothing happens. If Save is clicked, the chore's point value should match what was entered. There should also be a star emoji that appears on the chore's card on the top right. The maximum value for the points should be 10000. Anything higher, the save button should disable.
|
||||
- Clicking outside the points dialog should not close it. It should be modal.
|
||||
- When the parent clicks "Reset" from a completed chore. That chore should become active again. The completed banner should be gone and the chore can be activated again.
|
||||
- Everytime a resetted chore is activated it should go back to the completed state until reset again.
|
||||
|
||||
**Kindness Acts, Penalties, Rewards**
|
||||
|
||||
- When the parent clicks on these item's edit button, a new dialog should appear with the item's title, icon, and point/cost value. These points/costs should be editable. The value should not be negitive. If cancelled, nothing happens. If Save is clicked, the item's point value should match what was entered. There should also be a star emoji that appears on the chore's card on the top right. The maximum value for the points should be 10000. Anything higher, the save button should disable.
|
||||
|
||||
**Other Tests:**
|
||||
When a point/cost value is changed, make sure the new value take affect when the parent activates the task. This testing is done in task-assignment.plan.md, but we want to verify the new point values work. There may be some test overlap.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Tests are in `e2e/mode_parent/task-modification/`. Each spec creates its own isolated child via the API so all 6 files run in parallel under the `chromium-task-modification` project.
|
||||
|
||||
## Playwright Project
|
||||
|
||||
**Project name:** `chromium-task-modification`
|
||||
**Config:** `playwright.config.ts`
|
||||
**testMatch:** `/mode_parent/task-modification/.+\.spec\.ts/`
|
||||
**Dependencies:** `setup`
|
||||
**Excluded from:** `chromium-tasks-rewards` (via `testIgnore`)
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Chore Edit Points ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/chore-edit-points.spec.ts`
|
||||
|
||||
**Seed:** Creates an isolated child and a chore task via API in `beforeAll`. Deletes all in `afterAll`.
|
||||
|
||||
#### 1.1. Kebab button visibility ✅
|
||||
|
||||
- Kebab button is hidden initially; becomes visible after the chore card is clicked; hides again when a different card is clicked
|
||||
|
||||
#### 1.2. Kebab menu contents ✅
|
||||
|
||||
- Menu contains "Edit Points" and "Schedule" options
|
||||
|
||||
#### 1.3. Override modal opens pre-populated ✅
|
||||
|
||||
- "Edit Points" opens the override modal with the current point value already filled in
|
||||
|
||||
#### 1.4. Modal is blocking ✅
|
||||
|
||||
- Clicking outside the modal does not close it
|
||||
|
||||
#### 1.5. Cancel does not save ✅
|
||||
|
||||
- Card still shows original points after Cancel; no ⭐ badge appears
|
||||
|
||||
#### 1.6. Save updates displayed points ✅
|
||||
|
||||
- Saving a valid new value updates the point display on the card
|
||||
|
||||
#### 1.7. ⭐ override badge appears after save ✅
|
||||
|
||||
- ⭐ badge appears on the card after saving a custom value
|
||||
|
||||
#### 1.8. ⭐ badge persists on revert ✅
|
||||
|
||||
- ⭐ badge remains even when the value is changed back to the original default
|
||||
|
||||
#### 1.9. Validation — negative and over-limit ✅
|
||||
|
||||
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
|
||||
|
||||
#### 1.10. Zero is valid ✅
|
||||
|
||||
- Card shows "0 Points" with ⭐ badge when zero is saved
|
||||
|
||||
---
|
||||
|
||||
### 2. Chore Reset ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/chore-reset.spec.ts`
|
||||
|
||||
**Seed:** Creates an isolated child and a chore task via API in `beforeAll`. Activates and confirms the chore as needed per test. Deletes all in `afterAll`.
|
||||
|
||||
#### 2.1. "Reset" absent before completion ✅
|
||||
|
||||
- "Reset" is not present in the kebab menu when the chore is not yet completed
|
||||
|
||||
#### 2.2. COMPLETED stamp after activation ✅
|
||||
|
||||
- Chore shows COMPLETED stamp after activation and parent confirmation
|
||||
|
||||
#### 2.3. "Reset" appears after completion ✅
|
||||
|
||||
- "Reset" appears in the kebab menu once the chore is in COMPLETED state
|
||||
|
||||
#### 2.4. Reset removes COMPLETED stamp ✅
|
||||
|
||||
- Clicking "Reset" removes the COMPLETED stamp and returns the chore to active
|
||||
|
||||
#### 2.5. Chore is re-activatable after reset ✅
|
||||
|
||||
- After reset, the chore can be activated again and returns to COMPLETED on confirmation
|
||||
|
||||
#### 2.6. Reset → activate cycle is repeatable ✅
|
||||
|
||||
- The reset and re-activate cycle can be repeated multiple times without error
|
||||
|
||||
---
|
||||
|
||||
### 3. Kindness Act Edit Points ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/kindness-edit-points.spec.ts`
|
||||
|
||||
**Seed:** Creates an isolated child and two kindness act tasks via API in `beforeAll`. Deletes all in `afterAll`.
|
||||
|
||||
#### 3.1. Edit button visibility ✅
|
||||
|
||||
- Edit button appears on first click of a kindness card; disappears when a different card is clicked
|
||||
|
||||
#### 3.2. Override modal opens pre-populated ✅
|
||||
|
||||
- Edit button opens modal with the kindness name, "Assign new points" subtitle, and current point value filled in
|
||||
|
||||
#### 3.3. Cancel does not save ✅
|
||||
|
||||
- Points remain unchanged and no ⭐ badge appears after Cancel
|
||||
|
||||
#### 3.4. Save updates displayed points and badge ✅
|
||||
|
||||
- Saving a new value updates the displayed points and shows ⭐ badge
|
||||
|
||||
#### 3.5. Validation — negative and over-limit ✅
|
||||
|
||||
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
|
||||
|
||||
---
|
||||
|
||||
### 4. Penalty Edit Points ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/penalty-edit-points.spec.ts`
|
||||
|
||||
**Seed:** Creates an isolated child and two penalty tasks via API in `beforeAll`. Deletes all in `afterAll`.
|
||||
|
||||
#### 4.1. Edit button visibility ✅
|
||||
|
||||
- Edit button appears on first click of a penalty card; disappears when a different card is clicked
|
||||
|
||||
#### 4.2. Override modal opens pre-populated ✅
|
||||
|
||||
- Edit button opens modal with the penalty name, "Assign new points" subtitle, and the raw (positive) point value filled in — not the negated display value
|
||||
|
||||
#### 4.3. Cancel does not save ✅
|
||||
|
||||
- Display remains unchanged after Cancel
|
||||
|
||||
#### 4.4. Save updates card and badge ✅
|
||||
|
||||
- Saving a new value updates the card to show the negated override value and ⭐ badge
|
||||
|
||||
#### 4.5. Validation — negative and over-limit ✅
|
||||
|
||||
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
|
||||
|
||||
---
|
||||
|
||||
### 5. Reward Edit Cost ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/reward-edit-cost.spec.ts`
|
||||
|
||||
**Seed:** Creates an isolated child and a reward (with a pending redemption for warning-dialog tests) via API in `beforeAll`. Deletes all in `afterAll`.
|
||||
|
||||
#### 5.1. Edit button visibility ✅
|
||||
|
||||
- Edit button appears on first click of a reward card
|
||||
|
||||
#### 5.2. Override modal opens pre-populated ✅
|
||||
|
||||
- Edit button opens modal with the reward name, "Assign new cost" subtitle, and the current cost filled in
|
||||
|
||||
#### 5.3. Cancel does not save ✅
|
||||
|
||||
- Points needed display remains unchanged after Cancel
|
||||
|
||||
#### 5.4. Save updates cost and badge ✅
|
||||
|
||||
- Saving a new cost updates the points needed display and shows ⭐ badge
|
||||
|
||||
#### 5.5. Zero cost makes reward immediately redeemable ✅
|
||||
|
||||
- Setting cost to 0 marks the reward as immediately redeemable
|
||||
|
||||
#### 5.6. Validation — negative and over-limit ✅
|
||||
|
||||
- Negative value disables the Save button; value above 10000 disables Save; exactly 10000 enables it
|
||||
|
||||
#### 5.7. Warning dialog for pending redemption ✅
|
||||
|
||||
- Editing a reward with an active pending redemption shows a warning dialog; Cancel aborts the edit
|
||||
|
||||
#### 5.8. Confirming warning opens override modal ✅
|
||||
|
||||
- Confirming the warning dialog proceeds to open the override modal normally
|
||||
|
||||
---
|
||||
|
||||
### 6. Modified Points Take Effect on Activation ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/task-modification/modified-points-activation.spec.ts`
|
||||
|
||||
**Seed:** Creates an isolated child, a chore, a kindness act, a penalty, and a reward via API in `beforeAll`. Sets overrides for each. Deletes all in `afterAll`.
|
||||
|
||||
#### 6.1. Modified chore points awarded on activation ✅
|
||||
|
||||
- Child receives the overridden point value (not the default) when the chore is activated and confirmed
|
||||
|
||||
#### 6.2. Modified kindness points awarded on activation ✅
|
||||
|
||||
- Child receives the overridden point value when the kindness act is activated and confirmed
|
||||
|
||||
#### 6.3. Modified penalty points deducted on activation ✅
|
||||
|
||||
- Child loses the overridden point value when the penalty is applied
|
||||
|
||||
#### 6.4. Modified reward cost reflected in display ✅
|
||||
|
||||
- Points needed shown on the reward card reflects the overridden cost value
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user