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

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

View File

@@ -1,8 +0,0 @@
node_modules
dist
.git
.gitignore
Dockerfile
docker-compose.yml
*.md
*.log

View File

@@ -1,8 +0,0 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

View File

@@ -1 +0,0 @@
* text=auto eol=lf

View File

@@ -1,34 +0,0 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# Customize this step as needed
- name: Build application
run: npx run build

View File

@@ -1,27 +0,0 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -1,45 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
*.old
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCh96Qoz16kpDde
puFkARSFp9PDFs8kUBGCJ0DcBDqJIUuLJj7RT5vxfUj3O7uUUjTtVAVCMJ84/eq/
IQnyhch1B2Fe5bas51kEgwybzxxEN1bcj3yokbd4aOCpxkpM382TKMPuGH1BXUO8
ZqqikbvKySJ9glboPImLRA4UvH1cx9Db0kQE9scBl98qqE28exlBqMBsIOjh+mJ0
rWt3JO+A50OjOSuxO7hdjINlJg5X+jmI1wF3GWE2i9GrCJXh9JVYixnjvsFMIKJo
Zu9wAZCmtqHvicvwdzDxdDAU8GfACOg4w4hlMOSfIU4pAONxW479Gf5pSFVTw7Zu
RNrTDsd9AgMBAAECggEAGICASL0wLdt61dqMg8anDl4Yaq2nafCj6WrbRL1uBoMv
LK59N8hhiKuBj4cthg9WmuWIQx5cY/CDo+TRXqsu60dRy1uYYjlATe6uSF7RQZ+W
iBi7zLt4hCJnhD9vS4hazs2OsFTrk+kSP2zPmPbPcCqzyUVftNO9ogAKWkg2dcO1
9KjDnR0pSn17rwj064mfVNoLYiN11nwQ21zyJFLYGM5mXYYW9b+EI9ZnWWyz6Lvo
+qrYhZh2hR8ul8rpURc0flqFvORaO4FhaQeX5+r5FZFQkRWQYbRfBbAJnIrFVa+N
8O002E0FLHWI0S7YPsbnBqE+eHRM9fzz1q0p4YrjEQKBgQDDz9n2BLwwQCreXglG
V2T1SBdi1+MDTMUCHEcak3Gk4ZRIU4r2ofoTdYagEaYCMBGL7/noYECpEfkzF4B5
u1nXUTtFODq0m1Uc18iL8WMJy8AkoT+LZEmF0y5WDI/fLzzxuQKXpTDyeUwTYf/W
9xHHi4gckc3wcf1tKtNSswCcMQKBgQDTwJxki8/2OEtIRcPlhhXNQRpSnp6cjgoB
xQ4J83idR3/HBLWMI9snx47SE1AxGFY4UHIQNvMN3icKt5C7QyfrMfZCpUcRryv6
1dBFVbvxm0Hvqj/pWsgtjbq+dbiOLtH52zc9nsPAF++FhlYtZz9fJmwsgxsnab1O
B9dUyXMpDQKBgDj3LRfPhNgcstwCS3x1TF+3W2ZcHCUHnoDgrSbkIjmvjq4D7/eU
Y+ZpWIMU31Dfnxsw82lRJz6IhhEBE1VW1eo4LaATnbCRSA+eDy/3R7K/3eRKLOxm
fqU6LM7H1Ms/OOGxyzlGy5ifBSzWY9GsCzYcN7roCBudbfbmcJgsj07hAoGAd2p1
CCLsub9PfUeSzTrLyr//Nz6q1kEoFY1qeGQszg3HWpYmSAzkh897lK89lyJRZVrA
qLJEabqxq9KPtXuO5I19gmIw7SErnT69QIyz+/IBwkXx2wjOQRpfiQ9ccBqpYc2l
noONgyQ8eMGkkeBbFa7WbFfXlWeFUZ8MaY1d+3UCgYBguVXJ9JTvMxeXx5cjnfMI
eVRhr2BbARTdCJXKUpo4rUUqLrCCSB6BrT4H+DYtRVKO/ZIaXARQIN1ACo03C78o
U+Rg+mGTyvGh8pVH63zZE9tsohtT+/bSraYm/dLKUmaK3Z9wG75wmOpNLlAEiUqW
mzkW/UN0WWzg5gyODVbQKQ==
-----END PRIVATE KEY-----

View File

@@ -1,26 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEZDCCAsygAwIBAgIQIM51sKaVetu2bhpqghM+2TANBgkqhkiG9w0BAQsFADCB
kzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTQwMgYDVQQLDCtyeWFu
QG1hY2Jvb2sudGFpbGExNDZiMy50cy5uZXQgKFJ5YW4gS2VnZWwpMTswOQYDVQQD
DDJta2NlcnQgcnlhbkBtYWNib29rLnRhaWxhMTQ2YjMudHMubmV0IChSeWFuIEtl
Z2VsKTAeFw0yNjA0MTYyMjEwMTNaFw0yODA3MTYyMjEwMTNaMFMxJzAlBgNVBAoT
Hm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEoMCYGA1UECwwfcnlhbkBy
eWFucy1haXIubGFuIChSeWFuIEtlZ2VsKTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAKH3pCjPXqSkN16m4WQBFIWn08MWzyRQEYInQNwEOokhS4smPtFP
m/F9SPc7u5RSNO1UBUIwnzj96r8hCfKFyHUHYV7ltqznWQSDDJvPHEQ3VtyPfKiR
t3ho4KnGSkzfzZMow+4YfUFdQ7xmqqKRu8rJIn2CVug8iYtEDhS8fVzH0NvSRAT2
xwGX3yqoTbx7GUGowGwg6OH6YnSta3ck74DnQ6M5K7E7uF2Mg2UmDlf6OYjXAXcZ
YTaL0asIleH0lViLGeO+wUwgomhm73ABkKa2oe+Jy/B3MPF0MBTwZ8AI6DjDiGUw
5J8hTikA43Fbjv0Z/mlIVVPDtm5E2tMOx30CAwEAAaNzMHEwDgYDVR0PAQH/BAQD
AgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFLM+akEIDt75nyHZ
rbkUs/4IQWesMCkGA1UdEQQiMCCCCWxvY2FsaG9zdIINZGV2c2VydmVyLmxhbocE
wKgBZjANBgkqhkiG9w0BAQsFAAOCAYEAi4pT3LNzoM5mmD3ujX+G+cXx8MOAljvR
XGrzorysNOujR79+zR5tPPyiLwFfEYASJhWlHarM8n6DG+IgARNt9PuDq0x8+oDp
V/t9grCS6eV/SQf6y7JBQrywdIBgfYGJbbx6xzVlj187M+7yNV73f3ldS3wPFKlS
Gp6p50Vt9gmfsiqzW4NDr0FIvdbcBesNfr2DwRccZNgNAcLsr9ys2yVUcUtMnqPF
IPyarDbREuBLLLpnsYZhY3BJQgI3gDS9QRoviFCMjcbWKNnv0D2W0rVdZkJf9WOj
ro9X1K2f2C/t61EnFsM6ncqAgMksbBmlORiVi4thAgB44FDydyHX6anf7Hbzfn1t
sjsiZ03rO9xvFzUb2T9guFMBkU6pBDEhCdN3twYPY3uKPXByHnSmj3q38NJVbULu
szC1q91VF+2U2UoeoXaNxrXVleKpLiWEUJ/u/iGrbApeuov5ZTktA/y/d/+TDB+h
Jlo+Ofg3zgZBHpN+fsGTxhRLlJyXSynI
-----END CERTIFICATE-----

View File

@@ -1,26 +0,0 @@
# Stage 1: Build the app
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Copy SSL certificate and key
COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt
COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key
EXPOSE 80
EXPOSE 443
# Copy nginx.conf
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Set default BACKEND_HOST (can be overridden at runtime)
ENV BACKEND_HOST=chore-app-backend
# Use sed to replace $BACKEND_HOST with the env value, then start Nginx
CMD ["/bin/sh", "-c", "sed 's/\\$BACKEND_HOST/'\"$BACKEND_HOST\"'/g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'"]

View File

@@ -1,54 +0,0 @@
# vue-app
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -1,39 +0,0 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "yQZhoeeYUidDfolHE467zNEWguWXzMIinFT1LNLU5yM",
"domain": "localhost",
"path": "/api/auth",
"expires": 1784509278.633774,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZmZkYTMyNC01MDE1LTRiYjktODY3Yi1jZjFlNTU1NjBjMzciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzY3MzQxNzh9.YvFGLV1bdoRpRb8z5YtO19dpD7vkC0p5XMhGJgSJol8",
"domain": "localhost",
"path": "/",
"expires": 1776734178.633726,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1776733278500}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1776906078798}"
}
]
}
]
}

View File

@@ -1,39 +0,0 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "j90QCKu8d9QvNG-k-m31G5PoxU0yBuz-wyx8NPYCOG8",
"domain": "localhost",
"path": "/api/auth",
"expires": 1784509278.541961,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNzA3ZGYwYmUtOWQxMy00MGVlLWI3M2QtYzYwZmFmNDg2NmU2IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc2NzM0MTc4fQ.cEKtxo4zVTd7kBZzfjjI_uxqez32gUxmZYuUa8-RYdc",
"domain": "localhost",
"path": "/",
"expires": 1776734178.541918,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1776733278395}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1776906078675}"
}
]
}
]
}

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "RETiHvb_RUs2-0xb-klx8BwvSwr2TMtz1D-NmB5825Y",
"domain": "localhost",
"path": "/api/auth",
"expires": 1784509276.929905,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNjZmYjdjNy04ZDI5LTRkNjAtYmVjNi0zMmEzODg1MGI1OGIiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzY3MzQxNzZ9.WSka-ZbN1ZKbD_Q4UpoP9qfN1i73G51nrx88F2TbbOE",
"domain": "localhost",
"path": "/",
"expires": 1776734176.929863,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1776733276784}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1776906077058}"
}
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
import { test, expect } from '@playwright/test'
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
await request.put('/api/child/add', { data: { name, age } })
const listRes = await request.get('/api/child/list')
const data = await listRes.json()
const child = (data.children ?? []).find((c: any) => c.name === name)
return child?.id ?? ''
}
test.describe('Child kebab menu Delete Child', () => {
test.describe.configure({ mode: 'serial' })
const CHILD_NAME = 'KebabDelete'
let childId = ''
test.afterEach(async ({ request }) => {
// Best-effort cleanup in case the test did not delete the child
if (childId) await request.delete(`/api/child/${childId}`)
childId = ''
})
test('Confirm deletes the child from the list', async ({ page, request }) => {
childId = await createTestChild(request, CHILD_NAME)
await page.goto('/parent')
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
await card.getByRole('button', { name: 'Options' }).click()
await card.getByRole('button', { name: 'Delete Child' }).click()
await expect(page.getByText('Are you sure you want to delete this child?')).toBeVisible()
await page.getByRole('button', { name: 'Delete' }).click()
await expect(page.getByRole('heading', { name: CHILD_NAME })).not.toBeVisible()
childId = '' // already deleted, skip afterEach cleanup
})
test('Cancel does not delete the child', async ({ page, request }) => {
childId = await createTestChild(request, CHILD_NAME)
await page.goto('/parent')
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
await card.getByRole('button', { name: 'Options' }).click()
await card.getByRole('button', { name: 'Delete Child' }).click()
await expect(page.getByText('Are you sure you want to delete this child?')).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByText('Are you sure you want to delete this child?')).not.toBeVisible()
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
})
})

View File

@@ -1,40 +0,0 @@
import { test, expect } from '@playwright/test'
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
await request.put('/api/child/add', { data: { name, age } })
const listRes = await request.get('/api/child/list')
const data = await listRes.json()
const child = (data.children ?? []).find((c: any) => c.name === name)
return child?.id ?? ''
}
test.describe('Child kebab menu Delete Points', () => {
const CHILD_NAME = 'KebabPoints'
let childId = ''
test.beforeEach(async ({ request }) => {
childId = await createTestChild(request, CHILD_NAME)
await request.put(`/api/child/${childId}/edit`, { data: { points: 50 } })
})
test.afterEach(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
childId = ''
})
test('Delete Points resets child points to 0', async ({ page }) => {
await page.goto('/parent')
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
await card.getByRole('button', { name: 'Options' }).click()
await expect(card.getByRole('button', { name: 'Options' })).toHaveAttribute(
'aria-expanded',
'true',
)
await card.getByRole('button', { name: 'Delete Points' }).click()
await expect(card.getByText('Points: 0')).toBeVisible()
})
})

View File

@@ -1,40 +0,0 @@
import { test, expect } from '@playwright/test'
async function createTestChild(request: any, name: string, age = 8): Promise<string> {
await request.put('/api/child/add', { data: { name, age } })
const listRes = await request.get('/api/child/list')
const data = await listRes.json()
const child = (data.children ?? []).find((c: any) => c.name === name)
return child?.id ?? ''
}
test.describe('Child kebab menu Edit Child', () => {
const CHILD_NAME = 'KebabEdit'
let childId = ''
test.beforeEach(async ({ request }) => {
childId = await createTestChild(request, CHILD_NAME)
})
test.afterEach(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
childId = ''
})
test('Edit Child menu item navigates to the child editor', async ({ page }) => {
await page.goto('/parent')
await expect(page.getByRole('heading', { name: CHILD_NAME })).toBeVisible()
const card = page.locator('.card').filter({ hasText: CHILD_NAME })
await card.getByRole('button', { name: 'Options' }).click()
await expect(card.getByRole('button', { name: 'Options' })).toHaveAttribute(
'aria-expanded',
'true',
)
await card.getByRole('button', { name: 'Edit Child' }).click()
await expect(page).toHaveURL(/\/parent\/[^/]+\/edit/)
await expect(page.getByRole('heading', { name: 'Edit Child' })).toBeVisible()
})
})

View File

@@ -1,128 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 6: Child Mode Chore Visibility & States
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
setDaysSchedule,
choreCard,
choreSection,
todayDayIndex,
nonTodayDayIndex,
goToChildView,
} from './helpers'
const CHILD_NAME = 'ChildCardChild'
const CHILD_ALWAYS = 'ChildAlwaysChore'
const CHILD_TODAY = 'ChildTodayChore'
const CHILD_NOT_TODAY = 'ChildNotTodayChore'
const CHILD_EXPIRED = 'ChildExpiredChore'
const CHILD_ANYTIME = 'ChildAnytimeChore'
test.describe('Child Mode — Chore Visibility and Schedule States', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let alwaysId = ''
let todayId = ''
let notTodayId = ''
let expiredId = ''
let anytimeId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
alwaysId = await createChoreTask(request, CHILD_ALWAYS)
todayId = await createChoreTask(request, CHILD_TODAY)
notTodayId = await createChoreTask(request, CHILD_NOT_TODAY)
expiredId = await createChoreTask(request, CHILD_EXPIRED)
anytimeId = await createChoreTask(request, CHILD_ANYTIME)
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
const today = todayDayIndex()
const nonToday = nonTodayDayIndex()
// ChildTodayChore: scheduled for today, deadline 23:59
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
// ChildNotTodayChore: scheduled for a non-today day
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
// ChildExpiredChore: scheduled for today, deadline 00:01 (guaranteed expired)
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
// ChildAnytimeChore: scheduled for today, no deadline
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
// ChildAlwaysChore: no schedule
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
if (id) await request.delete(`/api/task/${id}`)
}
})
test('6.1 Chore with no schedule — visible normally in child mode', async ({ page }) => {
await goToChildView(page, childId)
const card = choreCard(page, CHILD_ALWAYS)
await expect(card).toBeVisible()
await expect(card).not.toHaveClass(/chore-inactive/)
})
test('6.2 Chore scheduled for today — visible with "Due by" label', async ({ page }) => {
await goToChildView(page, childId)
const card = choreCard(page, CHILD_TODAY)
await expect(card).toBeVisible()
await expect(card).not.toHaveClass(/chore-inactive/)
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM')
})
test('6.3 Chore not scheduled for today — HIDDEN in child mode', async ({ page }) => {
await goToChildView(page, childId)
// Wait for the chores section to fully load (uses other cards as anchor)
const alwaysCard = choreCard(page, CHILD_ALWAYS)
await expect(alwaysCard).toBeVisible()
// Off-day chore must not appear at all
await expect(
choreSection(page).locator('.item-card').filter({ hasText: CHILD_NOT_TODAY }),
).toHaveCount(0)
})
test('6.4 Chore with expired deadline — grayed out with TOO LATE badge (not hidden)', async ({
page,
}) => {
await goToChildView(page, childId)
const card = choreCard(page, CHILD_EXPIRED)
// Expired chore is still visible (was scheduled for today)
await expect(card).toBeVisible()
await expect(card).toHaveClass(/chore-inactive/)
await expect(card.getByText('TOO LATE')).toBeVisible()
})
test('6.5 Chore with "Anytime" — visible, no badge, no "Due by"', async ({ page }) => {
await goToChildView(page, childId)
const card = choreCard(page, CHILD_ANYTIME)
await expect(card).toBeVisible()
await expect(card).not.toHaveClass(/chore-inactive/)
await expect(card.locator('.due-label')).not.toBeVisible()
await expect(card.getByText('TOO LATE')).not.toBeVisible()
})
test('6.6 No kebab menu or schedule controls in child mode', async ({ page }) => {
await goToChildView(page, childId)
const card = choreCard(page, CHILD_TODAY)
await expect(card).toBeVisible()
// Click the card — no Options button should appear
await card.click()
await page.waitForTimeout(500)
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Schedule' })).not.toBeVisible()
})
})

View File

@@ -1,121 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 8: Extend Time
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
setDaysSchedule,
choreCard,
openChoreOptions,
todayDayIndex,
todayISO,
goToChildView,
} from './helpers'
const CHILD_NAME = 'ExtendTimeChild'
const EXPIRED_NAME = 'ExtendTimeChore'
const FUTURE_NAME = 'FutureDeadlineChore'
test.describe('Extend Time', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let expiredId = ''
let futureId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
expiredId = await createChoreTask(request, EXPIRED_NAME)
futureId = await createChoreTask(request, FUTURE_NAME)
await assignChore(request, childId, [expiredId, futureId])
const today = todayDayIndex()
// ExpiredChore: scheduled for today, deadline at 00:01 (guaranteed past)
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
// FutureChore: scheduled for today, deadline at 23:59 (not expired)
await setDaysSchedule(request, childId, futureId, [today], { hour: 23, minute: 59 })
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (expiredId) await request.delete(`/api/task/${expiredId}`)
if (futureId) await request.delete(`/api/task/${futureId}`)
})
test('8.1 "Extend Time" appears in kebab menu only when chore is expired', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, EXPIRED_NAME)
await card.waitFor()
// Expired chore shows TOO LATE badge
await expect(card.getByText('TOO LATE')).toBeVisible()
await openChoreOptions(page, card)
await expect(page.getByRole('button', { name: 'Extend Time' })).toBeVisible()
})
test('8.2 "Extend Time" is absent when chore is not expired', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, FUTURE_NAME)
await card.waitFor()
await openChoreOptions(page, card)
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
})
test('8.3 Clicking "Extend Time" removes the TOO LATE badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, EXPIRED_NAME)
await card.waitFor()
await expect(card.getByText('TOO LATE')).toBeVisible()
await openChoreOptions(page, card)
await page.getByRole('button', { name: 'Extend Time' }).click()
// After extension the badge disappears and card is no longer grayed out
await expect(card.getByText('TOO LATE')).not.toBeVisible()
await expect(card).not.toHaveClass(/chore-inactive/)
})
test('8.4 Extended chore no longer shows "Due by" label', async ({ page }) => {
// Card was extended in 8.3 so this verifies the extension persists
await page.goto(`/parent/${childId}`)
const card = choreCard(page, EXPIRED_NAME)
await card.waitFor()
await expect(card.getByText('TOO LATE')).not.toBeVisible()
await expect(card.locator('.due-label')).not.toBeVisible()
})
test('8.5 "Extend Time" disappears from kebab after extension', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, EXPIRED_NAME)
await card.waitFor()
// Chore was extended in 8.3 — should no longer be expired
await openChoreOptions(page, card)
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
})
test('8.6 Extension effect visible in child mode', async ({ page }) => {
// After extending in parent mode (done in 8.3), verify child view
await goToChildView(page, childId)
const card = choreCard(page, EXPIRED_NAME)
// Chore is still visible (was scheduled for today + extended) and no TOO LATE badge
await expect(card).toBeVisible()
await expect(card.getByText('TOO LATE')).not.toBeVisible()
})
test('8.6b Extending same chore twice via API returns 409', async ({ request }) => {
// The chore was already extended in 8.3; a second API extension should fail
const res = await request.post(`/api/child/${childId}/task/${expiredId}/extend`, {
data: { date: todayISO() },
})
expect(res.status()).toBe(409)
})
})

View File

@@ -1,233 +0,0 @@
// shared helpers for chore-scheduler spec files
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
// ─── Date Helpers ─────────────────────────────────────────────────────────────
export function todayISO(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function yesterdayISO(): string {
const d = new Date()
d.setDate(d.getDate() - 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function futureDateISO(daysAhead = 7): string {
const d = new Date()
d.setDate(d.getDate() + daysAhead)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** Today's weekday (0 = Sunday, 6 = Saturday) — matches DayConfig.day */
export function todayDayIndex(): number {
return new Date().getDay()
}
/** A weekday guaranteed not to be today */
export function nonTodayDayIndex(): number {
return (new Date().getDay() + 1) % 7
}
// ─── API Helpers ──────────────────────────────────────────────────────────────
export async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
''
)
}
export async function createChoreTask(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points: 5, type: 'chore' } })
const list = await request.get('/api/task/list')
return (
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
)
}
export async function assignChore(
request: APIRequestContext,
childId: string,
choreIds: string[],
): Promise<void> {
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: choreIds, type: 'chore' },
})
}
export interface DaysScheduleOpts {
hour?: number
minute?: number
hasDeadline?: boolean
enabled?: boolean
}
export async function setDaysSchedule(
request: APIRequestContext,
childId: string,
taskId: string,
days: number[],
opts: DaysScheduleOpts = {},
): Promise<void> {
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
const day_configs = days.map((day) => ({ day, hour, minute }))
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
data: {
mode: 'days',
day_configs,
default_hour: hour,
default_minute: minute,
default_has_deadline: hasDeadline,
enabled,
},
})
}
export interface IntervalScheduleOpts {
hour?: number
minute?: number
hasDeadline?: boolean
enabled?: boolean
}
export async function setIntervalSchedule(
request: APIRequestContext,
childId: string,
taskId: string,
intervalDays: number,
anchorDate: string,
opts: IntervalScheduleOpts = {},
): Promise<void> {
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
data: {
mode: 'interval',
interval_days: intervalDays,
anchor_date: anchorDate,
interval_has_deadline: hasDeadline,
interval_hour: hour,
interval_minute: minute,
enabled,
},
})
}
export async function deleteSchedule(
request: APIRequestContext,
childId: string,
taskId: string,
): Promise<void> {
// 404 is acceptable (no schedule existed)
await request.delete(`/api/child/${childId}/task/${taskId}/schedule`)
}
// ─── UI Helpers ───────────────────────────────────────────────────────────────
/** Locator for the Chores section */
export function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
/** Locator for a named chore card in the Chores section */
export function choreCard(page: Page, name: string): Locator {
return choreSection(page)
.locator('.item-card')
.filter({
has: page.locator('.item-name').getByText(name, { exact: true }),
})
}
/** Click the chore card (select it), wait for ready state, then click Options */
export async function openChoreOptions(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByRole('button', { name: 'Options' }).click()
}
/** Open the schedule modal for a named chore card */
export async function openScheduleModal(page: Page, card: Locator): Promise<void> {
await openChoreOptions(page, card)
await page.getByRole('button', { name: 'Schedule' }).click()
await expect(page.getByText('Schedule Chore')).toBeVisible()
}
/**
* Set a TimePickerPopover to a specific time.
* @param timePicker - the `.time-picker-popover` locator wrapping the popover
*/
export async function setTimePicker(
page: Page,
timePicker: Locator,
hour12: number,
minute: number,
period: 'AM' | 'PM',
): Promise<void> {
await timePicker.locator('.time-display').click()
// Scope panel to this specific time picker to avoid strict-mode violations
const panel = timePicker.locator('.popover-panel')
await panel.waitFor()
// Use exact regex patterns — getByRole name uses substring matching by default,
// so "3" would also match "30" and "1" would match "10", "11", "12" etc.
await panel.getByRole('button', { name: new RegExp(`^${period}$`) }).click()
await panel.getByRole('button', { name: new RegExp(`^${hour12}$`) }).click()
await panel
.getByRole('button', { name: new RegExp(`^${String(minute).padStart(2, '0')}$`) })
.click()
// Close popover by clicking the modal title (outside popover bounds)
await page.locator('.modal-title').click()
await panel.waitFor({ state: 'hidden' })
}
/** Set the hidden date input and fire a change event */
export async function setDateInput(page: Page, isoDate: string): Promise<void> {
await page.locator('input.date-input-hidden').evaluate((el: HTMLInputElement, val: string) => {
el.value = val
el.dispatchEvent(new Event('change', { bubbles: true }))
}, isoDate)
}
/**
* Navigate to the child view for a given child.
* Parent-authenticated users are blocked from /child by the auth guard, so we
* must clear the parentAuth localStorage key before navigating there.
*/
export async function goToChildView(page: Page, childId: string): Promise<void> {
// Go to the app domain first so we can access its localStorage
await page.goto(`/parent/${childId}`)
// Clear the parent auth token — router will now allow /child/:id
await page.evaluate(() => localStorage.removeItem('parentAuth'))
// Navigate to child view and wait for the chore section to appear
await page.goto(`/child/${childId}`)
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
}
/**
* Navigate (back) to the parent view from any page.
* Restores the parentAuth localStorage key so the router allows /parent/:id.
*/
export async function goToParentView(page: Page, childId: string): Promise<void> {
// Restore parent auth so the router guard allows /parent/:id
await page.evaluate(() => {
localStorage.setItem(
'parentAuth',
JSON.stringify({ expiresAt: Date.now() + 2 * 24 * 60 * 60 * 1000 }),
)
})
await page.goto(`/parent/${childId}`)
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
}

View File

@@ -1,113 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 9: Interval Mode Anchor Date Logic
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
setIntervalSchedule,
choreCard,
choreSection,
todayISO,
yesterdayISO,
goToChildView,
} from './helpers'
const CHILD_NAME = 'IntervalAnchorChild'
const CHORE_NAME = 'IntervalAnchorChore'
test.describe('Schedule Interval Mode — Anchor Date Logic', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test('9.1 Interval every 1 day starting today — chore active today', async ({
page,
request,
}) => {
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
hour: 23,
minute: 59,
})
// Parent mode: not grayed out
await page.goto(`/parent/${childId}`)
const parentCard = choreCard(page, CHORE_NAME)
await parentCard.waitFor()
await expect(parentCard).not.toHaveClass(/chore-inactive/)
// Child mode: visible
await goToChildView(page, childId)
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
})
test('9.2 Interval every 2 days starting yesterday — chore NOT active today', async ({
page,
request,
}) => {
// anchor=yesterday, interval=2: yesterday+2=tomorrow → today is skipped
await setIntervalSchedule(request, childId, choreId, 2, yesterdayISO(), {
hour: 23,
minute: 59,
})
// Parent mode: grayed out (not hitting today)
await page.goto(`/parent/${childId}`)
const parentCard = choreCard(page, CHORE_NAME)
await parentCard.waitFor()
await expect(parentCard).toHaveClass(/chore-inactive/)
// Child mode: hidden (not scheduled for today)
await goToChildView(page, childId)
// Wait for list to finish loading — the chore should be filtered out
await expect(choreSection(page).locator('.empty, .scroll-wrapper')).toBeVisible({
timeout: 10000,
})
await expect(
choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME }),
).toHaveCount(0)
})
test('9.3 Interval every 2 days starting today — active today', async ({ page, request }) => {
// anchor=today, interval=2: diffDays=0, 0%2=0 → hits today
await setIntervalSchedule(request, childId, choreId, 2, todayISO(), {
hour: 23,
minute: 59,
})
// Parent mode: not grayed out
await page.goto(`/parent/${childId}`)
const parentCard = choreCard(page, CHORE_NAME)
await parentCard.waitFor()
await expect(parentCard).not.toHaveClass(/chore-inactive/)
// Child mode: visible
await goToChildView(page, childId)
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
})
test('9.4 Interval "Anytime" deadline — no "Due by" label', async ({ page, request }) => {
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
hasDeadline: false,
})
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
// Anytime = no due label
await expect(card.locator('.due-label')).not.toBeVisible()
})
})

View File

@@ -1,153 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 5: Parent Mode Chore Card Schedule States
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
setDaysSchedule,
choreCard,
todayDayIndex,
nonTodayDayIndex,
} from './helpers'
const CHILD_NAME = 'ParentCardChild'
const ALWAYS_ACTIVE = 'AlwaysActiveChore'
const SCHEDULED_TODAY = 'ScheduledTodayChore'
const NOT_SCHEDULED_TODAY = 'NotScheduledTodayChore'
const EXPIRED_CHORE = 'ExpiredChore'
const ANYTIME_CHORE = 'AnytimeChore'
test.describe('Parent Mode — Chore Card Schedule States', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let alwaysId = ''
let todayId = ''
let notTodayId = ''
let expiredId = ''
let anytimeId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
alwaysId = await createChoreTask(request, ALWAYS_ACTIVE)
todayId = await createChoreTask(request, SCHEDULED_TODAY)
notTodayId = await createChoreTask(request, NOT_SCHEDULED_TODAY)
expiredId = await createChoreTask(request, EXPIRED_CHORE)
anytimeId = await createChoreTask(request, ANYTIME_CHORE)
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
const today = todayDayIndex()
const nonToday = nonTodayDayIndex()
// ScheduledTodayChore: scheduled for today, deadline 23:59 (far future)
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
// NotScheduledTodayChore: scheduled for a non-today day
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
// ExpiredChore: scheduled for today, deadline 00:01 (guaranteed past)
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
// AnytimeChore: scheduled for today, no deadline
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
// AlwaysActiveChore: no schedule (already default)
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
if (id) await request.delete(`/api/task/${id}`)
}
})
test('5.1 Chore with no schedule — normal appearance, no annotations', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, ALWAYS_ACTIVE)
await card.waitFor()
await expect(card).not.toHaveClass(/chore-inactive/)
await expect(card.locator('.chore-stamp')).not.toBeVisible()
await expect(card.locator('.due-label')).not.toBeVisible()
})
test('5.2 Chore scheduled for today — shows "Due by" label, not grayed out', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, SCHEDULED_TODAY)
await card.waitFor()
await expect(card).not.toHaveClass(/chore-inactive/)
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM', { timeout: 10000 })
})
test('5.3 Chore not scheduled for today — grayed out in parent mode but visible', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, NOT_SCHEDULED_TODAY)
await card.waitFor()
// Always visible in parent mode
await expect(card).toBeVisible()
// Grayed out because off-day
await expect(card).toHaveClass(/chore-inactive/)
// No TOO LATE badge (not expired — just off-day)
await expect(card.getByText('TOO LATE')).not.toBeVisible()
})
test('5.4 Chore with expired deadline — grayed out with TOO LATE badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, EXPIRED_CHORE)
await card.waitFor()
await expect(card).toBeVisible()
await expect(card).toHaveClass(/chore-inactive/)
await expect(card.getByText('TOO LATE')).toBeVisible()
})
test('5.5 Chore with "Anytime" deadline — no "Due by" label, no TOO LATE badge', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, ANYTIME_CHORE)
await card.waitFor()
await expect(card).not.toHaveClass(/chore-inactive/)
await expect(card.locator('.due-label')).not.toBeVisible()
await expect(card.getByText('TOO LATE')).not.toBeVisible()
})
test('5.6 Kebab menu always accessible on grayed-out chores', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, NOT_SCHEDULED_TODAY)
await card.waitFor()
// Grayed out card still has Options button after clicking
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
await card.getByRole('button', { name: 'Options' }).click()
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
// Schedule modal opens normally
await page.getByRole('button', { name: 'Schedule' }).click()
await expect(page.getByText('Schedule Chore')).toBeVisible()
})
test('5.7 Expired chore cannot be triggered by tapping — no confirm dialog', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, EXPIRED_CHORE)
await card.waitFor()
// First click selects the card even when expired
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
// Second click attempts to trigger — but expired chores are blocked in triggerTask()
await card.click()
// Confirm dialog should NOT appear for expired chores
await expect(page.locator('.modal-dialog')).not.toBeVisible({ timeout: 1000 })
})
})

View File

@@ -1,130 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 7: Parent vs Child Mode Comparison
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
deleteSchedule,
setDaysSchedule,
choreCard,
choreSection,
todayDayIndex,
nonTodayDayIndex,
goToChildView,
} from './helpers'
const CHILD_NAME = 'CompareChild'
const VISIBLE_BOTH = 'VisibleBothChore'
const PARENT_ONLY = 'ParentOnlyChore'
test.describe('Parent vs Child Mode Comparison', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let visibleBothId = ''
let parentOnlyId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
visibleBothId = await createChoreTask(request, VISIBLE_BOTH)
parentOnlyId = await createChoreTask(request, PARENT_ONLY)
await assignChore(request, childId, [visibleBothId, parentOnlyId])
const today = todayDayIndex()
const nonToday = nonTodayDayIndex()
// VisibleBothChore: scheduled for today (visible everywhere)
await setDaysSchedule(request, childId, visibleBothId, [today], { hour: 23, minute: 59 })
// ParentOnlyChore: scheduled for a non-today day (grayed/hidden)
await setDaysSchedule(request, childId, parentOnlyId, [nonToday], { hour: 8, minute: 0 })
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (visibleBothId) await request.delete(`/api/task/${visibleBothId}`)
if (parentOnlyId) await request.delete(`/api/task/${parentOnlyId}`)
})
test('7.1 Parent mode shows all chores including off-day ones', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const visibleCard = choreCard(page, VISIBLE_BOTH)
const parentOnlyCard = choreCard(page, PARENT_ONLY)
await visibleCard.waitFor()
await parentOnlyCard.waitFor()
// Both visible in parent mode
await expect(visibleCard).toBeVisible()
await expect(parentOnlyCard).toBeVisible()
// parentOnly is grayed out (off-day), visible is not
await expect(parentOnlyCard).toHaveClass(/chore-inactive/)
await expect(visibleCard).not.toHaveClass(/chore-inactive/)
})
test('7.2 Child mode hides off-day chores', async ({ page }) => {
await goToChildView(page, childId)
const visibleCard = choreCard(page, VISIBLE_BOTH)
await expect(visibleCard).toBeVisible()
// Off-day chore is hidden entirely
await expect(
choreSection(page).locator('.item-card').filter({ hasText: PARENT_ONLY }),
).toHaveCount(0)
})
test('7.3 Adding a schedule via parent mode hides chore in child mode on wrong day', async ({
page,
request,
}) => {
const nonToday = nonTodayDayIndex()
// Remove today's schedule from VisibleBothChore and set it to non-today
await setDaysSchedule(request, childId, visibleBothId, [nonToday], { hour: 8, minute: 0 })
await goToChildView(page, childId)
// VisibleBothChore is now off-day — should be hidden in child mode
await expect(
choreSection(page).locator('.item-card').filter({ hasText: VISIBLE_BOTH }),
).toHaveCount(0)
})
test('7.4 Removing a schedule via parent mode shows chore in child mode again', async ({
page,
request,
}) => {
// Delete the schedule via API (acts as if saved with no days)
await deleteSchedule(request, childId, visibleBothId)
await goToChildView(page, childId)
// No schedule = always visible in child mode
const card = choreCard(page, VISIBLE_BOTH)
await expect(card).toBeVisible()
})
test('7.5 Paused schedule — chore visible in both modes', async ({ page, request }) => {
// Re-add a non-today schedule, but paused (enabled=false)
await setDaysSchedule(request, childId, parentOnlyId, [nonTodayDayIndex()], {
enabled: false,
})
// Parent mode: visible and NOT grayed out (paused = acts unscheduled)
await page.goto(`/parent/${childId}`)
const parentCard = choreCard(page, PARENT_ONLY)
await parentCard.waitFor()
await expect(parentCard).toBeVisible()
await expect(parentCard).not.toHaveClass(/chore-inactive/)
// Child mode: also visible (paused = shows every day)
await goToChildView(page, childId)
const childCard = choreCard(page, PARENT_ONLY)
await expect(childCard).toBeVisible()
})
})

View File

@@ -1,230 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 2: Schedule Modal Specific Days Mode
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
deleteSchedule,
setDaysSchedule,
choreCard,
openScheduleModal,
setTimePicker,
nonTodayDayIndex,
} from './helpers'
const CHILD_NAME = 'ScheduleDaysChild'
const CHORE_NAME = 'ScheduleDaysChore'
test.describe('Schedule Modal — Specific Days mode', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test.beforeEach(async ({ request }) => {
await deleteSchedule(request, childId, choreId)
})
test('2.1 Clicking day chips toggles them active/inactive', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
const moChip = page.locator('.day-chips').getByRole('button', { name: 'Mo' })
await moChip.click()
await expect(moChip).toHaveClass(/active/)
await moChip.click()
await expect(moChip).not.toHaveClass(/active/)
})
test('2.2 Default deadline row appears when at least one day is selected', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.locator('.default-deadline-row')).not.toBeVisible()
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
await expect(page.locator('.default-deadline-row')).toBeVisible()
await expect(page.locator('.default-deadline-row')).toContainText('Deadline')
})
test('2.3 Default deadline "Anytime" toggle hides and restores time picker', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Select a day to show the deadline row
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
await expect(page.locator('.default-deadline-row')).toBeVisible()
// Time picker is visible by default
await expect(
page.locator('.default-deadline-row').locator('.time-picker-popover'),
).toBeVisible()
// Click "Clear (Anytime)" — time picker hides, "Anytime" label appears
await page
.locator('.default-deadline-row')
.getByRole('button', { name: 'Clear (Anytime)' })
.click()
await expect(
page.locator('.default-deadline-row').locator('.time-picker-popover'),
).not.toBeVisible()
await expect(page.locator('.default-deadline-row').getByText('Anytime')).toBeVisible()
// Click "Set deadline" — time picker reappears
await page
.locator('.default-deadline-row')
.getByRole('button', { name: 'Set deadline' })
.click()
await expect(
page.locator('.default-deadline-row').locator('.time-picker-popover'),
).toBeVisible()
})
test('2.4 Exception time — "Set different time" creates per-day override', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Select Monday and Wednesday
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
// Wednesday row: click "Set different time"
const weRow = page.locator('.exception-row').filter({
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
})
await weRow.getByRole('button', { name: 'Set different time' }).click()
// Wednesday now shows its own time picker
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
// Monday row still shows the default label (no time picker override)
const moRow = page.locator('.exception-row').filter({
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
})
await expect(moRow.locator('.default-label')).toBeVisible()
await expect(moRow.locator('.time-picker-popover')).not.toBeVisible()
})
test('2.5 Exception time — "Reset to default" removes the override', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Set up Monday + Wednesday, add Wednesday exception
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
const weRow = page.locator('.exception-row').filter({
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
})
await weRow.getByRole('button', { name: 'Set different time' }).click()
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
// Reset Wednesday to default
await weRow.getByRole('button', { name: 'Reset to default' }).click()
await expect(weRow.locator('.default-label')).toBeVisible()
await expect(weRow.locator('.time-picker-popover')).not.toBeVisible()
})
test('2.6 Save with specific days — schedule persists on reopen', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Select Monday and Friday
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
await page.locator('.day-chips').getByRole('button', { name: 'Fr' }).click()
// Set deadline to 03:00 PM
const timePicker = page.locator('.default-deadline-row').locator('.time-picker-popover')
await setTimePicker(page, timePicker, 3, 0, 'PM')
// Save
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.locator('.modal-title')).not.toBeVisible()
// Navigate fresh to reset card ready-state, then reopen to verify
await page.goto(`/parent/${childId}`)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
/active/,
)
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
/active/,
)
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
'03:00 PM',
)
})
test('2.7 Save with zero days deletes the schedule', async ({ page, request }) => {
// Seed: create a days schedule so the modal opens with existing days
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 9, minute: 0 })
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Deselect all active chips
const chips = page.locator('.day-chips').getByRole('button')
for (const chip of await chips.all()) {
const cls = await chip.getAttribute('class')
if (cls?.includes('active')) await chip.click()
}
// Save (removing schedule)
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.locator('.modal-title')).not.toBeVisible()
// Navigate fresh to reset card ready-state, then reopen to verify no days selected
await page.goto(`/parent/${childId}`)
await card.waitFor()
await openScheduleModal(page, card)
for (const chip of await page.locator('.day-chips').getByRole('button').all()) {
await expect(chip).not.toHaveClass(/active/)
}
})
test('2.8 Dirty detection — changing days enables Save', async ({ page, request }) => {
const nonToday = nonTodayDayIndex()
await setDaysSchedule(request, childId, choreId, [nonToday], { hour: 8, minute: 0 })
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Save should be disabled (nothing changed yet)
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
// Toggle a different day chip → dirty
const otherDay = (nonToday + 1) % 7
const dayLabels = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
await page.locator('.day-chips').getByRole('button', { name: dayLabels[otherDay] }).click()
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
})
})

View File

@@ -1,187 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 4: Schedule Enable/Disable Toggle
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
deleteSchedule,
setDaysSchedule,
choreCard,
openScheduleModal,
todayDayIndex,
nonTodayDayIndex,
goToChildView,
} from './helpers'
const CHILD_NAME = 'ScheduleToggleChild'
const CHORE_NAME = 'ScheduleToggleChore'
test.describe('Schedule Enable/Disable Toggle', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test.beforeEach(async ({ request }) => {
await deleteSchedule(request, childId, choreId)
})
test('4.1 Toggle row is visible in the schedule modal', async ({ page, request }) => {
// Seed a schedule so the modal has something to show
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.locator('.schedule-toggle-row')).toBeVisible()
await expect(page.getByRole('switch')).toBeVisible()
await expect(page.locator('.toggle-label')).toBeVisible()
})
test('4.2 Toggling OFF shows "Paused" label and dims the form body', async ({
page,
request,
}) => {
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Toggle is ON initially
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
// Toggle OFF
await page.getByRole('switch').click()
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
await expect(page.locator('.toggle-label')).toHaveText('Paused')
// Form body dims
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
// Cancel/Save buttons remain interactive
await expect(page.getByRole('button', { name: 'Cancel' })).toBeEnabled()
})
test('4.3 Toggling ON restores "Enabled" label and form body', async ({ page, request }) => {
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Toggle OFF then back ON
await page.getByRole('switch').click()
await expect(page.locator('.toggle-label')).toHaveText('Paused')
await page.getByRole('switch').click()
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
await expect(page.locator('.schedule-body')).not.toHaveClass(/disabled/)
})
test('4.4 Save paused state — persists on reopen', async ({ page, request }) => {
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Toggle OFF and save
await page.getByRole('switch').click()
await expect(page.locator('.toggle-label')).toHaveText('Paused')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.locator('.modal-title')).not.toBeVisible()
// Navigate fresh to reset card ready-state, then reopen to verify
await page.goto(`/parent/${childId}`)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
await expect(page.locator('.toggle-label')).toHaveText('Paused')
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
})
test('4.5 Dirty detection — toggling enabled state enables/disables Save', async ({
page,
request,
}) => {
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Save is disabled initially
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
// Toggle OFF → dirty
await page.getByRole('switch').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
// Toggle back ON → not dirty again
await page.getByRole('switch').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
})
test('4.6 Paused schedule in parent mode — chore is visible and not grayed out', async ({
page,
request,
}) => {
// Schedule for a non-today weekday, but paused (enabled=false)
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
// Chore is visible
await expect(card).toBeVisible()
// Paused = acts like unscheduled = not grayed out
await expect(card).not.toHaveClass(/chore-inactive/)
})
test('4.7 Paused schedule in child mode — chore is visible', async ({ page, request }) => {
// Non-today schedule, paused
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
await goToChildView(page, childId)
const card = choreCard(page, CHORE_NAME)
// Paused = isScheduledToday returns true → chore is visible in child mode
await expect(card).toBeVisible()
})
test('4.8 Paused schedule shows no "Due by" label', async ({ page, request }) => {
// Schedule for today with a deadline, but paused
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], {
hour: 23,
minute: 59,
enabled: false,
})
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
// Paused = getDueTimeToday returns null → no "Due by" label
await expect(card.locator('.due-label')).not.toBeVisible()
})
})

View File

@@ -1,178 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 3: Schedule Modal Every X Days Mode
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
deleteSchedule,
setIntervalSchedule,
choreCard,
openScheduleModal,
setTimePicker,
setDateInput,
todayISO,
futureDateISO,
} from './helpers'
const CHILD_NAME = 'ScheduleIntervalChild'
const CHORE_NAME = 'ScheduleIntervalChore'
test.describe('Schedule Modal — Every X Days mode', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test.beforeEach(async ({ request }) => {
await deleteSchedule(request, childId, choreId)
})
test('3.1 Interval stepper increments and decrements within 17', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await page.getByRole('button', { name: 'Every X Days' }).click()
// Default value is 1
await expect(page.locator('.stepper-value')).toHaveText('1')
// Decrement is disabled at 1
const decBtn = page.locator('.stepper').getByRole('button').first()
await expect(decBtn).toBeDisabled()
// Increment 6 times → value becomes 7
const incBtn = page.locator('.stepper').getByRole('button').last()
for (let i = 0; i < 6; i++) await incBtn.click()
await expect(page.locator('.stepper-value')).toHaveText('7')
// Increment is disabled at 7
await expect(incBtn).toBeDisabled()
})
test("3.2 Anchor date field shows today's date by default", async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await page.getByRole('button', { name: 'Every X Days' }).click()
await expect(page.locator('input.date-input-hidden')).toHaveValue(todayISO())
})
test('3.3 Next occurrence preview label updates correctly', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await page.getByRole('button', { name: 'Every X Days' }).click()
// Set interval to 2 (click + once from default 1)
await page.locator('.stepper').getByRole('button').last().click()
await expect(page.locator('.stepper-value')).toHaveText('2')
// Ensure anchor is today
await setDateInput(page, todayISO())
// Next occurrence row should be visible
await expect(page.locator('.next-occurrence-label')).toBeVisible()
await expect(page.locator('.next-occurrence-label')).toContainText('Next occurrence:')
})
test('3.4 Interval deadline "Anytime" toggle works', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await page.getByRole('button', { name: 'Every X Days' }).click()
// Time picker visible by default
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
// Click "Clear (Anytime)"
await page
.locator('.interval-time-row')
.getByRole('button', { name: 'Clear (Anytime)' })
.click()
await expect(
page.locator('.interval-time-row').locator('.time-picker-popover'),
).not.toBeVisible()
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
// Click "Set deadline" — time picker reappears
await page.locator('.interval-time-row').getByRole('button', { name: 'Set deadline' }).click()
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
})
test('3.5 Save interval schedule — persists on reopen', async ({ page }) => {
const anchor = futureDateISO(60) // 60 days ahead — well past today min constraint
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await page.getByRole('button', { name: 'Every X Days' }).click()
// Set interval to 3 (click + twice from default 1)
const incBtn = page.locator('.stepper').getByRole('button').last()
await incBtn.click()
await incBtn.click()
await expect(page.locator('.stepper-value')).toHaveText('3')
// Set anchor date
await setDateInput(page, anchor)
// Set deadline to 04:30 PM
const timePicker = page.locator('.interval-time-row').locator('.time-picker-popover')
await setTimePicker(page, timePicker, 4, 30, 'PM')
// Save
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.locator('.modal-title')).not.toBeVisible()
// Navigate fresh to reset card ready-state, then reopen to verify
await page.goto(`/parent/${childId}`)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
await expect(page.locator('.stepper-value')).toHaveText('3')
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchor)
await expect(page.locator('.interval-time-row').locator('.time-display')).toHaveText('04:30 PM')
})
test('3.6 Dirty detection — changing interval enables Save', async ({ page, request }) => {
await setIntervalSchedule(request, childId, choreId, 3, futureDateISO(30))
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Save is disabled (no changes yet)
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
// Change interval from 3 to 5 (click + twice)
const incBtn = page.locator('.stepper').getByRole('button').last()
await incBtn.click()
await incBtn.click()
await expect(page.locator('.stepper-value')).toHaveText('5')
// Save is now enabled
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
})
})

View File

@@ -1,171 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 11: Schedule Loading / Backward Compatibility
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
deleteSchedule,
setIntervalSchedule,
choreCard,
openScheduleModal,
futureDateISO,
nonTodayDayIndex,
} from './helpers'
const CHILD_NAME = 'LoadCompatChild'
const CHORE_NAME = 'LoadCompatChore'
test.describe('Schedule Loading — Backward Compatibility', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test.beforeEach(async ({ request }) => {
await deleteSchedule(request, childId, choreId)
})
test('11.1 Schedule without "enabled" field defaults to enabled in modal', async ({
page,
request,
}) => {
// Create schedule via raw API call — omit the `enabled` field
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'days',
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
default_hour: 8,
default_minute: 0,
default_has_deadline: true,
// enabled intentionally omitted — backend defaults to true
},
})
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Toggle should default to "Enabled"
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
})
test('11.2 Schedule with interval_has_deadline=false shows "Anytime" in modal', async ({
page,
request,
}) => {
await setIntervalSchedule(request, childId, choreId, 2, futureDateISO(30), {
hasDeadline: false,
})
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// "Every X Days" mode is active
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
// Anytime label is shown instead of time picker
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
await expect(
page.locator('.interval-time-row').locator('.time-picker-popover'),
).not.toBeVisible()
})
test('11.3 Days schedule with per-day exception loads deadline rows correctly', async ({
page,
request,
}) => {
// Monday=08:00, Wednesday=08:00 (default), Friday=10:30 (exception)
// Use raw API to set up mixed schedule
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'days',
day_configs: [
{ day: 1, hour: 8, minute: 0 }, // Monday
{ day: 3, hour: 8, minute: 0 }, // Wednesday
{ day: 5, hour: 10, minute: 30 }, // Friday — exception from default 08:00
],
default_hour: 8,
default_minute: 0,
default_has_deadline: true,
enabled: true,
},
})
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Monday, Wednesday, Friday chips should be active
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
/active/,
)
await expect(page.locator('.day-chips').getByRole('button', { name: 'We' })).toHaveClass(
/active/,
)
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
/active/,
)
// Default deadline shows 08:00 AM
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
'08:00 AM',
)
// Friday has its own time picker (exception) — "Reset to default" button visible
const frRow = page.locator('.exception-row').filter({
has: page.locator('.exception-day-name', { hasText: 'Friday' }),
})
await expect(frRow.getByRole('button', { name: 'Reset to default' })).toBeVisible()
await expect(frRow.locator('.time-display')).toHaveText('10:30 AM')
// Monday has no exception — shows default label
const moRow = page.locator('.exception-row').filter({
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
})
await expect(moRow.locator('.default-label')).toBeVisible()
})
test('11.4 Interval schedule restores anchor date and interval on reopen', async ({
page,
request,
}) => {
const anchorDate = '2026-06-15'
const intervalDays = 3
await setIntervalSchedule(request, childId, choreId, intervalDays, anchorDate, {
hour: 9,
minute: 0,
})
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// "Every X Days" mode is active
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
// Stepper shows 3
await expect(page.locator('.stepper-value')).toHaveText(`${intervalDays}`)
// Date input has the correct anchor date
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchorDate)
})
})

View File

@@ -1,129 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 1: Schedule Modal Opening & Structure
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
deleteSchedule,
choreCard,
openChoreOptions,
openScheduleModal,
} from './helpers'
const CHILD_NAME = 'ScheduleModalChild'
const CHORE_NAME = 'ScheduleModalChore'
test.describe('Schedule Modal — Opening & Structure', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test.beforeEach(async ({ request }) => {
await deleteSchedule(request, childId, choreId)
})
test('1.1 Schedule option appears in kebab menu', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openChoreOptions(page, card)
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
})
test('1.2 Schedule modal opens with correct title and subtitle', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.locator('.modal-title')).toHaveText('Schedule Chore')
await expect(page.locator('.modal-subtitle')).toHaveText(CHORE_NAME)
})
test('1.3 Default state: Specific Days mode, no chips selected, toggle Enabled', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Specific Days mode button is active
await expect(page.getByRole('button', { name: 'Specific Days' })).toHaveClass(/active/)
await expect(page.getByRole('button', { name: 'Every X Days' })).not.toHaveClass(/active/)
// No day chips are active
const chips = page.locator('.day-chips').getByRole('button')
for (const chip of await chips.all()) {
await expect(chip).not.toHaveClass(/active/)
}
// Toggle is ON and shows "Enabled"
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
})
test('1.4 Mode toggle switches between Specific Days and Every X Days', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Switch to Every X Days
await page.getByRole('button', { name: 'Every X Days' }).click()
await expect(page.locator('.interval-form')).toBeVisible()
await expect(page.locator('.days-form')).not.toBeVisible()
// Switch back to Specific Days
await page.getByRole('button', { name: 'Specific Days' }).click()
await expect(page.locator('.days-form')).toBeVisible()
await expect(page.locator('.interval-form')).not.toBeVisible()
})
test('1.5 Cancel closes the modal without saving', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Select Monday chip
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
/active/,
)
// Cancel — modal closes
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.locator('.modal-title')).not.toBeVisible()
// Navigate fresh to reset card ready-state, then reopen modal
await page.goto(`/parent/${childId}`)
await card.waitFor()
await openScheduleModal(page, card)
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).not.toHaveClass(
/active/,
)
})
test('1.6 Save is disabled when form is not dirty', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await openScheduleModal(page, card)
// Save button is disabled — no changes made
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
})
})

View File

@@ -1,97 +0,0 @@
// spec: e2e/plans/chore-scheduler.plan.md — Section 10: SSE Real-Time Updates
import { test, expect } from '@playwright/test'
import {
createChild,
createChoreTask,
assignChore,
setDaysSchedule,
deleteSchedule,
choreCard,
todayDayIndex,
nonTodayDayIndex,
todayISO,
} from './helpers'
const CHILD_NAME = 'SSEScheduleChild'
const CHORE_NAME = 'SSEScheduleChore'
test.describe('SSE Real-Time Updates', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createChoreTask(request, CHORE_NAME)
await assignChore(request, childId, [choreId])
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test.beforeEach(async ({ request }) => {
await deleteSchedule(request, childId, choreId)
})
test('10.1 Setting a schedule via API reflects in an already-open parent view', async ({
page,
request,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
// No schedule: card should be visible and not grayed out
await expect(card).not.toHaveClass(/chore-inactive/)
// Set a non-today schedule via API (triggers SSE chore_schedule_modified)
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
// SSE fires → card should become grayed out (chore-inactive)
await expect(card).toHaveClass(/chore-inactive/, { timeout: 5000 })
})
test('10.2 Deleting a schedule via API un-grays the chore in parent view', async ({
page,
request,
}) => {
// Seed a non-today schedule first
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await expect(card).toHaveClass(/chore-inactive/)
// Delete schedule via API
await request.delete(`/api/child/${childId}/task/${choreId}/schedule`)
// SSE fires → card returns to normal
await expect(card).not.toHaveClass(/chore-inactive/, { timeout: 5000 })
})
test('10.3 Extending time via API reflects in an already-open parent view', async ({
page,
request,
}) => {
// Set today schedule with past deadline (chore starts expired)
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], { hour: 0, minute: 1 })
await page.goto(`/parent/${childId}`)
const card = choreCard(page, CHORE_NAME)
await card.waitFor()
await expect(card.getByText('TOO LATE')).toBeVisible()
// Extend via API
await request.post(`/api/child/${childId}/task/${choreId}/extend`, {
data: { date: todayISO() },
})
// SSE chore_time_extended fires → TOO LATE badge disappears
await expect(card.getByText('TOO LATE')).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,16 +0,0 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import { STORAGE_STATE_NO_PIN } from '../../e2e-constants'
test.use({ storageState: STORAGE_STATE_NO_PIN })
test.describe('Create Child', () => {
test('Add Child FAB is hidden when parent auth is expired', async ({ page }) => {
// Navigate to app root - with no parent auth, router redirects to /child
await page.goto('/')
// expect: the 'Add Child' FAB is NOT visible (not in parent mode)
await expect(page.getByRole('button', { name: 'Add Child' })).not.toBeVisible()
})
})

View File

@@ -1,163 +0,0 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
async function deleteNamedChildren(request: any, names: string[]) {
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (names.includes(child.name)) {
await request.delete(`/api/child/${child.id}`)
}
}
}
async function deleteAllChildren(request: any) {
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
await request.delete(`/api/child/${child.id}`)
}
}
test.describe('Create Child', () => {
// Serial mode: the 'empty state' test calls deleteAllChildren() which would
// race against sibling tests creating children if they ran in parallel.
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test('Create a child with name and age only', async ({ page, request }) => {
await deleteNamedChildren(request, ['Alice'])
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
await page.goto('/')
await expect(page).toHaveURL('/parent')
// 2. Click the 'Add Child' FAB
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
await expect(nameInput).toBeVisible()
await expect(ageInput).toBeVisible()
// Submit should be disabled until all required fields are valid.
await expect(createButton).toBeDisabled()
// 3. Enter 'Alice' in the Name field
await nameInput.fill('Alice')
await expect(createButton).toBeDisabled()
// 4. Enter '8' in the Age field
await ageInput.fill('8')
// 5. Leave Image as default and click Create
// Use toPass() to handle SSE-triggered form resets from parallel tests
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
await deleteNamedChildren(request, ['Alice'])
await nameInput.fill('Alice')
await ageInput.fill('8')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Alice')).toBeVisible()
})
test('Create a child via the inline Create button in empty state', async ({ page, request }) => {
// 1. Navigate to empty state and click inline Create.
// Retry-loop handles SSE-triggered re-renders: a parallel test may add a child
// between deleteAllChildren() and the click, detaching the empty-state button.
await expect(async () => {
await deleteAllChildren(request)
await page.goto('/')
await expect(page.getByText('No children')).toBeVisible({ timeout: 5000 })
await page.getByRole('button', { name: 'Create' }).click({ timeout: 5000 })
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible({
timeout: 5000,
})
}).toPass({ timeout: 30000 })
// 2. Enter 'Bob' and '10', then submit
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
// Submit should be disabled until all required fields are valid
await expect(createButton).toBeDisabled()
await nameInput.fill('Bob')
await expect(createButton).toBeDisabled()
await ageInput.fill('10')
// Handle async form initialization race and SSE-triggered form resets
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
await deleteNamedChildren(request, ['Bob'])
await nameInput.fill('Bob')
await ageInput.fill('10')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Bob')).toBeVisible()
})
test('Create a child with a custom uploaded image', async ({ page, request }) => {
await deleteNamedChildren(request, ['Grace'])
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 2. Enter 'Grace' and '6'
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
// Submit should be disabled until all required fields are valid
await expect(createButton).toBeDisabled()
await nameInput.fill('Grace')
await expect(createButton).toBeDisabled()
await ageInput.fill('6')
// 3. Upload a local image file via 'Add from device'
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Add from device' }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(TEST_IMAGE)
// Handle async form initialization race and SSE-triggered form resets
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
await deleteNamedChildren(request, ['Grace'])
await nameInput.fill('Grace')
await ageInput.fill('6')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Grace')).toBeVisible()
})
})

View File

@@ -1,21 +0,0 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
test.describe('Create Child', () => {
test('Cancel navigates back without saving', async ({ page }) => {
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 2. Fill in 'Frank' and '9', then click Cancel
await page.getByLabel('Name').fill('Frank')
await page.getByLabel('Age').fill('9')
await page.getByRole('button', { name: 'Cancel' }).click()
// expect: back on /parent and 'Frank' is NOT listed
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Frank')).not.toBeVisible()
})
})

View File

@@ -1,108 +0,0 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import { STORAGE_STATE_CC } from '../../e2e-constants'
test.describe('Create Child', () => {
test.describe.configure({ mode: 'serial' })
test('New child appears in list without page reload', async ({ page, context, request }) => {
// Clean up 'Hannah' before test
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (child.name === 'Hannah') {
await request.delete(`/api/child/${child.id}`)
}
}
// 1. Open two browser tabs both on /parent (children list)
await page.goto('/')
await expect(page).toHaveURL('/parent')
const tab2 = await context.newPage()
await tab2.goto('/')
await expect(tab2).toHaveURL('/parent')
// 2. In Tab 1, create child 'Hannah' age '4'
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
// Clean up any Hannah created in a previous attempt where navigation was cancelled
const lr = await request.get('/api/child/list')
for (const c of (await lr.json()).children ?? []) {
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
}
await page.getByLabel('Name').fill('Hannah')
await page.getByLabel('Age').fill('4')
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page).toHaveURL('/parent')
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
// 3. Tab 2 should show 'Hannah' via SSE without a manual refresh
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
await tab2.close()
})
test('New child appears in child mode list without page reload', async ({
page,
browser,
request,
}) => {
// Clean up 'Hannah' before test
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (child.name === 'Hannah') {
await request.delete(`/api/child/${child.id}`)
}
}
// 1. Tab 1: parent mode
await page.goto('/')
await expect(page).toHaveURL('/parent')
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
const childContext = await browser.newContext({ storageState: STORAGE_STATE_CC })
const tab2 = await childContext.newPage()
// Load the app first (to ensure localStorage is seeded from storageState),
// then remove parentAuth so the next navigation boots in child mode.
await tab2.goto('/')
await tab2.evaluate(() => localStorage.removeItem('parentAuth'))
// Full navigation triggers a fresh Vue init — auth store reads no parentAuth
// so the router allows /child
await tab2.goto('/child')
await expect(tab2).toHaveURL('/child')
// 3. In Tab 1, create child 'Hannah' age '4'
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
// Clean up any Hannah created in a previous attempt where navigation was cancelled
const lr = await request.get('/api/child/list')
for (const c of (await lr.json()).children ?? []) {
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
}
await page.getByLabel('Name').fill('Hannah')
await page.getByLabel('Age').fill('4')
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page).toHaveURL('/parent')
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
// 4. Tab 2 (child mode) should show 'Hannah' via SSE without a manual refresh
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
await childContext.close()
})
})

View File

@@ -1,88 +0,0 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
test.describe('Create Child', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
// Navigate to app root - router redirects to /parent (children list) when parent-authenticated
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
})
test('Reject submission when Name is empty', async ({ page }) => {
// 2. Leave Name empty, enter '7' in Age - Create remains disabled
const createButton = page.getByRole('button', { name: 'Create' })
await page.getByLabel('Age').fill('7')
await expect(createButton).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
test('Reject submission when Name is whitespace only', async ({ page }) => {
// 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled
const createButton = page.getByRole('button', { name: 'Create' })
await page.getByLabel('Name').fill(' ')
await page.getByLabel('Age').fill('7')
await expect(createButton).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
test('Reject submission when Age is empty', async ({ page }) => {
// 2. Enter 'Charlie', clear Age - Create button should be disabled
await page.getByLabel('Name').fill('Charlie')
await page.getByLabel('Age').clear()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
test('Reject negative age', async ({ page }) => {
// 2. Enter 'Dave', enter '-1' - Create remains disabled
const createButton = page.getByRole('button', { name: 'Create' })
await page.getByLabel('Name').fill('Dave')
await page.getByLabel('Age').fill('-1')
await expect(createButton).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
// Use toPass() to handle SSE-triggered form resets from parallel tests
const FULL_NAME = 'A'.repeat(64)
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
// Clean up any previously-created 64-A child in case prev attempt created but nav was cancelled
const lr = await request.get('/api/child/list')
for (const c of (await lr.json()).children ?? []) {
if (c.name === FULL_NAME) await request.delete(`/api/child/${c.id}`)
}
// Fill 65 chars — HTML maxlength=64 truncates to 64
await nameInput.fill('A'.repeat(65))
// Verify truncation via non-retrying read (throws if SSE cleared the field)
const truncated = await nameInput.inputValue()
if (truncated !== FULL_NAME)
throw new Error(`maxlength truncation failed: got "${truncated}"`)
await ageInput.fill('5')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
})
test('Reject age greater than 120', async ({ page }) => {
// 2. Enter 'Eve', enter '121' in Age - Create button should be disabled
await page.getByLabel('Name').fill('Eve')
await page.getByLabel('Age').fill('121')
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
})

View File

@@ -1,164 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §2
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'ChoreNotifApproveChild'
const CHORE_NAME = 'ChoreNotifApproveChore'
const CHORE_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
test.describe('Chore Notification — Appearance and In-App Approval', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let pointsBeforeApproval = 0
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// Set a daily interval schedule so the chore persists post-approval
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
// Confirm the chore to create a pending notification
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 2.1 Confirmed chore appears in the notification view
test('Confirmed chore appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText('completed')
})
// 2.2 Notification item displays the child's name
test('Notification item displays the child name', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText(CHILD_NAME)
})
// 2.3 Clicking notification navigates to ParentView with correct query params
test('Clicking notification navigates to ParentView with correct query params', async ({
page,
}) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${choreId}`)
expect(page.url()).toContain('entityType=chore')
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
})
// 2.4 Pending chore card shows PENDING stamp in ParentView
test('Pending chore card shows PENDING stamp in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await expect(card).toContainText('PENDING')
})
// 2.5 Clicking a pending chore card opens ChoreApproveDialog
test('Clicking a pending chore card opens ChoreApproveDialog', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
})
// 2.6 Approving via ChoreApproveDialog removes the PENDING stamp
test('Approving via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
// Capture points before approval for test 2.8 verification
pointsBeforeApproval = await getPoints(page)
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const approveBtn = page.getByRole('button', { name: 'Approve' })
await expect(approveBtn).toBeVisible({ timeout: 5000 })
await approveBtn.click()
await expect(approveBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
})
// 2.7 Approving a chore clears it from the notification view
test('Approving a chore clears it from notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
// 2.8 Approving a chore awards points to the child
test('Approving a chore awards points to the child', async ({ page }) => {
// The chore was approved in test 2.6. Verify the points were updated correctly.
// (Cannot re-confirm as the chore was already approved today.)
await page.goto(`/parent/${childId}`)
const currentPoints = await getPoints(page)
expect(currentPoints).toBe(pointsBeforeApproval + CHORE_POINTS)
})
})

View File

@@ -1,141 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §3
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'ChoreNotifDenyChild'
const CHORE_NAME = 'ChoreNotifDenyChore'
const CHORE_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
test.describe('Chore Notification — In-App Rejection', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// Set a daily interval schedule so the chore persists post-rejection
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 3.1 Confirmed chore appears in the notification view
test('Confirmed chore appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
})
// 3.2 ChoreApproveDialog shows both Approve and Reject buttons
test('ChoreApproveDialog shows both Approve and Reject buttons', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
})
// 3.3 Rejecting via ChoreApproveDialog removes the PENDING stamp
test('Rejecting via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const rejectBtn = page.getByRole('button', { name: 'Reject' })
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
await rejectBtn.click()
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
})
// 3.4 Rejecting a chore clears it from the notification view
test('Rejecting a chore clears it from notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
// 3.5 Rejecting a chore does not award points
test('Rejecting a chore does not award points', async ({ page, request }) => {
// Re-confirm the chore for this test
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
await page.goto(`/parent/${childId}`)
const before = await getPoints(page)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const rejectBtn = page.getByRole('button', { name: 'Reject' })
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
await rejectBtn.click()
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
})
})

View File

@@ -1,204 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §6
import { test, expect, type APIRequestContext, type Page, type Locator } from '@playwright/test'
const CHILD_NAME = 'DeepLinkChild'
const CHORE_PREFIX = 'DeepLinkChore'
const CHORE_COUNT = 10
const REWARD_NAME = 'DeepLinkReward'
const REWARD_COST = 20
const INITIAL_POINTS = 100
const NARROW_VIEWPORT = { width: 480, height: 800 }
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'deep link test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function isInViewport(locator: Locator): Promise<boolean> {
return locator.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth)
)
})
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('ParentView Deep-Link Navigation', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
const choreIds: string[] = []
let targetChoreId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
for (let i = 1; i <= CHORE_COUNT; i++) {
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
}
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: choreIds, type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
// Target the last chore (most likely off-screen)
targetChoreId = choreIds[CHORE_COUNT - 1]
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: targetChoreId },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of choreIds) await request.delete(`/api/task/${id}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
// 6.1 Deep link to chore — correct card is scrolled into viewport
test('Deep link to chore scrolls correct card into viewport', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=chore`)
const card = choreSection(page)
.locator('.item-card')
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
// 6.2 Deep link to reward — correct card is scrolled into viewport
test('Deep link to reward scrolls correct card into viewport', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}?scrollTo=${rewardId}&entityType=reward`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
// 6.3 Deep link with unknown entityType — page loads without JS error
test('Deep link with unknown entityType loads without JS error', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=unknown`)
await page.waitForTimeout(500)
expect(errors).toHaveLength(0)
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
})
// 6.4 Deep link with missing scrollTo — page loads normally
test('Deep link with missing scrollTo loads normally', async ({ page }) => {
await page.goto(`/parent/${childId}?entityType=chore`)
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
})
// 6.5 Deep link with no query params — page loads normally
test('Deep link with no query params loads normally', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
await expect(page.locator('h3', { hasText: 'Rewards' })).toBeVisible()
})
// 6.6 Clicking a chore notification produces the correct deep link
test('Clicking a chore notification produces correct deep link', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${targetChoreId}`)
expect(page.url()).toContain('entityType=chore')
const card = choreSection(page)
.locator('.item-card')
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
// 6.7 Clicking a reward notification produces the correct deep link
test('Clicking a reward notification produces correct deep link', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${rewardId}`)
expect(page.url()).toContain('entityType=reward')
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
})

View File

@@ -1,118 +0,0 @@
// 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,
}) => {
// Wait for action to complete
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get(`/api/child/list`)
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
expect(child.points).toBeGreaterThanOrEqual(CHORE_POINTS)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === choreId,
)
// Should be no pending (status == 'pending') confirmation
expect(pending.filter((c: any) => c.status === 'pending')).toHaveLength(0)
})
})

View File

@@ -1,111 +0,0 @@
// 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 }) => {
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get('/api/child/list')
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
expect(child.points).toBe(INITIAL_POINTS - REWARD_COST)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
)
expect(pending).toHaveLength(0)
})
})

View File

@@ -1,106 +0,0 @@
// 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,
}) => {
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get(`/api/child/list`)
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
// No points awarded for rejection
expect(child.points).toBe(0)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === choreId && c.status === 'pending',
)
expect(pending).toHaveLength(0)
})
})

View File

@@ -1,111 +0,0 @@
// 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 }) => {
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get('/api/child/list')
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
// Points should be unchanged (deny doesn't deduct anything)
expect(child.points).toBe(INITIAL_POINTS)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
)
expect(pending).toHaveLength(0)
})
})

View File

@@ -1,167 +0,0 @@
// 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 — should succeed (302)
const first = await unauthCtx.get(`/api/digest-action/${token}`, { maxRedirects: 0 })
expect(first.status()).toBe(302)
// Second use — token is consumed
const second = await unauthCtx.get(`/api/digest-action/${token}`, { maxRedirects: 0 })
expect(second.status()).toBe(400)
await unauthCtx.dispose()
// Re-create a pending chore confirmation for subsequent tests
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
// 8.3 Tampered token returns 400
test('Tampered token returns 400', async ({ request, playwright }) => {
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'approve',
},
})
expect(tokenRes.status()).toBe(200)
const validToken: string = (await tokenRes.json()).token
// Tamper: replace the last character
const lastChar = validToken[validToken.length - 1]
const replacement = lastChar === 'a' ? 'b' : 'a'
const tamperedToken = validToken.slice(0, -1) + replacement
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get(`/api/digest-action/${tamperedToken}`, { maxRedirects: 0 })
expect(res.status()).toBe(400)
await unauthCtx.dispose()
})
// 8.4 Completely fake token returns 400
test('Completely fake token returns 400', async ({ playwright }) => {
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get('/api/digest-action/this-token-does-not-exist', {
maxRedirects: 0,
})
expect(res.status()).toBe(400)
await unauthCtx.dispose()
})
// 8.5 Error response is a plain HTML page with no redirect
test('Error response is plain HTML with no Location header', async ({ request, playwright }) => {
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'approve',
expires_in_hours: -1,
},
})
const expiredToken = (await tokenRes.json()).token
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
expect(res.status()).toBe(400)
const contentType = res.headers()['content-type'] ?? ''
expect(contentType).toContain('text/html')
expect(res.headers()['location']).toBeUndefined()
await unauthCtx.dispose()
})
})

View File

@@ -1,88 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §4
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'NotifOverflowChild'
const LONG_CHORE_NAME = 'This Is A Very Long Chore Name That Should Not Overflow The Card Boundary'
const CHORE_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
test.describe('Notification card overflow', () => {
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, LONG_CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// Confirm chore to create a pending notification
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
test('Notification text does not overflow card boundaries', async ({ page }) => {
await page.goto('/parent/notifications')
const notifItem = page.locator('.list-item').filter({ hasText: LONG_CHORE_NAME })
await expect(notifItem).toBeVisible({ timeout: 5000 })
// Get bounding boxes
const cardBox = await notifItem.boundingBox()
expect(cardBox).not.toBeNull()
// Verify the card doesn't overflow horizontally beyond its parent
const parentBox = await notifItem.locator('..').boundingBox()
expect(parentBox).not.toBeNull()
if (cardBox && parentBox) {
// Card's right edge should not extend beyond parent's right edge
expect(cardBox.x + cardBox.width).toBeLessThanOrEqual(parentBox.x + parentBox.width + 1)
}
})
test('Long notification entity name wraps or clips within card', async ({ page }) => {
await page.goto('/parent/notifications')
const notifItem = page.locator('.list-item').filter({ hasText: LONG_CHORE_NAME })
await expect(notifItem).toBeVisible()
// The text content inside the notification should not cause horizontal scroll
const hasHorizontalScroll = await notifItem.evaluate((el) => {
return el.scrollWidth > el.clientWidth
})
expect(hasHorizontalScroll).toBe(false)
})
})

View File

@@ -1,167 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §9
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'NotifScrollChild'
const CHORE_COUNT = 10
const CHORE_PREFIX = 'NotifScrollChore'
const TARGET_CHORE_IDX = CHORE_COUNT // last chore, likely off-screen
const REWARD_NAME = 'NotifScrollReward'
const REWARD_COST = 10
const INITIAL_CHILD_POINTS = 50
const NARROW_VIEWPORT = { width: 480, height: 800 }
async function isInViewport(locator: Locator): Promise<boolean> {
return locator.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth)
)
})
}
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'notif test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Notification click navigates and scrolls to task', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
const choreIds: string[] = []
let targetChoreId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
for (let i = 1; i <= CHORE_COUNT; i++) {
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
}
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: choreIds, type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
// The target chore is the last one — most likely off-screen
targetChoreId = choreIds[TARGET_CHORE_IDX - 1]
// Confirm the target chore → creates pending notification
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: targetChoreId },
})
// Request the reward → creates pending notification
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of choreIds) await request.delete(`/api/task/${id}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
test('Clicking a chore notification navigates and scrolls to the chore card', async ({
page,
}) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto('/parent/notifications')
const targetChoreName = `${CHORE_PREFIX}${TARGET_CHORE_IDX}`
const notifItem = page.locator('.list-item').filter({ hasText: targetChoreName })
await expect(notifItem).toBeVisible({ timeout: 5000 })
await notifItem.click()
// Verify navigation to parent child view with scrollTo query param
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
timeout: 5000,
})
// Verify the target chore card is scrolled into viewport
const targetCard = choreSection(page).locator('.item-card').filter({ hasText: targetChoreName })
await expect(targetCard).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(1000)
expect(await isInViewport(targetCard)).toBe(true)
})
test('Clicking a reward notification scrolls reward card into viewport', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto('/parent/notifications')
const notifItem = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(notifItem).toBeVisible({ timeout: 5000 })
await notifItem.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
timeout: 5000,
})
const targetCard = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(targetCard).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(1000)
expect(await isInViewport(targetCard)).toBe(true)
})
})

View File

@@ -1,86 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §1
import { test, expect, type Page } from '@playwright/test'
function pushToggle(page: Page) {
return page
.locator('.toggle-field-row')
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
.locator('button.toggle-btn')
}
test.describe('Push subscription registration', () => {
// -------------------------------------------------------------------------
// 1.3 No subscription POST when notification permission not granted
// -------------------------------------------------------------------------
test('No subscription POST is sent when notification permission is not granted', async ({
page,
}) => {
// Do NOT grant notifications permission
let postCaptured = false
await page.route('**/api/push-subscription', async (route) => {
if (route.request().method() === 'POST') {
postCaptured = true
}
await route.continue()
})
await page.goto('/parent/profile')
const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 })
const isDisabled = await toggle.isDisabled().catch(() => false)
if (!isDisabled) {
// Try to click the toggle — it may prompt for permission but deny it
await toggle.click().catch(() => {})
await page
.getByRole('button', { name: 'Save' })
.click()
.catch(() => {})
await page.waitForTimeout(1000)
}
expect(postCaptured).toBe(false)
})
// -------------------------------------------------------------------------
// 1.4 Backend rejects unauthenticated subscription POST (401)
// -------------------------------------------------------------------------
test('Backend rejects unauthenticated subscription POST with 401', async ({ page }) => {
// Navigate first, then use credentials:'omit' to strip cookies and verify
// the backend enforces auth.
await page.goto('/parent/profile')
const status = await page.evaluate(async () => {
const res = await fetch('/api/push-subscription', {
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
keys: { p256dh: 'fake-key', auth: 'fake-auth' },
}),
})
return res.status
})
expect(status).toBe(401)
})
// -------------------------------------------------------------------------
// 1.5 Backend rejects unauthenticated subscription DELETE (401)
// -------------------------------------------------------------------------
test('Backend rejects unauthenticated subscription DELETE with 401', async ({ page }) => {
await page.goto('/parent/profile')
const status = await page.evaluate(async () => {
const res = await fetch('/api/push-subscription', {
method: 'DELETE',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
}),
})
return res.status
})
expect(status).toBe(401)
})
})

View File

@@ -1,153 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §4
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'RewardNotifApproveChild'
const REWARD_NAME = 'RewardNotifApproveReward'
const REWARD_COST = 10
const INITIAL_POINTS = 30
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'notif e2e test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Reward Notification — Appearance and In-App Grant', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
// 4.1 Requested reward appears in the notification view
test('Requested reward appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText('requested')
})
// 4.2 Notification item displays the child's name
test('Notification item displays the child name', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText(CHILD_NAME)
})
// 4.3 Clicking reward notification navigates to ParentView with correct query params
test('Clicking reward notification navigates to ParentView with correct query params', async ({
page,
}) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${rewardId}`)
expect(page.url()).toContain('entityType=reward')
})
// 4.4 Pending reward card shows PENDING badge in ParentView
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await expect(card).toContainText('PENDING')
})
// 4.5 Clicking a pending reward card opens the grant confirmation dialog
test('Clicking a pending reward card opens grant confirmation dialog', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// A confirm dialog should appear — look for a confirmation button
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
})
// 4.6 Confirming the grant removes PENDING badge and updates child points
test('Confirming grant removes PENDING badge and updates child points', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const before = await getPoints(page)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
await confirmBtn.click()
await expect(confirmBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST), {
timeout: 5000,
})
})
// 4.7 Granting a reward clears it from the notification view
test('Granting a reward clears it from notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,164 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §5
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'RewardNotifDenyChild'
const REWARD_NAME = 'RewardNotifDenyReward'
const REWARD_COST = 10
const INITIAL_POINTS = 30
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'notif e2e deny test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Reward Notification — In-App Denial', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
test.beforeEach(async ({ request }) => {
// Create a fresh pending request before each test
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterEach(async ({ request }) => {
// Clean up any unconsumed pending requests
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
})
// 5.1 Requested reward appears in the notification view
test('Requested reward appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
})
// 5.2 Pending reward card shows PENDING badge in ParentView
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await expect(card).toContainText('PENDING')
})
// 5.3 Denying a reward request removes the PENDING badge
test('Denying a reward request removes PENDING badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const before = await getPoints(page)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// Look for a Deny button in the dialog
const denyBtn = page.getByRole('button', { name: /deny/i })
await expect(denyBtn).toBeVisible({ timeout: 5000 })
await denyBtn.click()
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
// Points should be unchanged
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
})
// 5.4 Denying a reward request clears it from the notification view
test('Denying a reward request clears it from notification view', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const denyBtn = page.getByRole('button', { name: /deny/i })
await expect(denyBtn).toBeVisible({ timeout: 5000 })
await denyBtn.click()
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
// 5.5 POST deny-reward-request returns 200 when pending request exists (API layer)
test('POST deny-reward-request returns 200 when pending request exists', async ({ request }) => {
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
data: { reward_id: rewardId },
})
expect(res.status()).toBe(200)
})
// 5.6 POST deny-reward-request returns 200 with graceful message when no pending request
test('POST deny-reward-request returns 200 gracefully when no pending request', async ({
request,
}) => {
// Cancel existing request first (afterEach will also try but that's fine)
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
data: { reward_id: rewardId },
})
// Backend returns 200 with a graceful "already resolved" message per the spec
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.message).toContain('already been resolved')
})
})

View File

@@ -1,149 +0,0 @@
// spec: e2e/plans/parent-notifications.plan.md §10
import { test, expect, type Page } from '@playwright/test'
function digestToggle(page: Page) {
return page
.locator('.toggle-field-row')
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Daily Digest' }) })
.locator('button.toggle-btn')
}
function pushToggle(page: Page) {
return page
.locator('.toggle-field-row')
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
.locator('button.toggle-btn')
}
test.describe('User Profile Notification Settings', () => {
test.describe.configure({ mode: 'serial' })
// ---------------------------------------------------------------------------
// 10.1 Email Digest toggle is visible on User Profile page
// ---------------------------------------------------------------------------
test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
await page.goto('/parent/profile')
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
})
// ---------------------------------------------------------------------------
// 10.2 Email Digest toggle initialises from the server value
// ---------------------------------------------------------------------------
test('Email Digest toggle initialises from the server value', async ({ page, request }) => {
// Set known state: enabled
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
await page.goto('/parent/profile')
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
// Set known state: disabled
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
await page.reload()
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
// Restore default
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
})
// ---------------------------------------------------------------------------
// 10.3 Toggling Email Digest off sends email_digest_enabled: false to API
// ---------------------------------------------------------------------------
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
await page.goto('/parent/profile')
// Wait for profile to load and toggle to reflect server state
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
let capturedBody: Record<string, unknown> | null = null
await page.route('**/api/user/profile', async (route) => {
if (route.request().method() === 'PUT') {
capturedBody = JSON.parse(route.request().postData() ?? '{}')
}
await route.continue()
})
await digestToggle(page).click()
// Submit the form (Save button)
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForTimeout(500)
expect(capturedBody).not.toBeNull()
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 3000 })
// Restore
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
})
// ---------------------------------------------------------------------------
// 10.4 Toggling Email Digest on sends email_digest_enabled: true to API
// ---------------------------------------------------------------------------
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
await page.goto('/parent/profile')
// Wait for profile to load and toggle to reflect server state
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
let capturedBody: Record<string, unknown> | null = null
await page.route('**/api/user/profile', async (route) => {
if (route.request().method() === 'PUT') {
capturedBody = JSON.parse(route.request().postData() ?? '{}')
}
await route.continue()
})
await digestToggle(page).click()
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForTimeout(500)
expect(capturedBody).not.toBeNull()
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(true)
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 3000 })
// Restore
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
})
// ---------------------------------------------------------------------------
// 10.5 Push Notifications toggle is visible on User Profile page
// ---------------------------------------------------------------------------
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
await page.goto('/parent/profile')
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
})
// ---------------------------------------------------------------------------
// 10.6 Push Notifications toggle initialises to off when no subscription exists
// ---------------------------------------------------------------------------
test('Push Notifications toggle initialises to off when no subscription exists', async ({
page,
}) => {
// Do NOT grant notifications permission — toggle should start unchecked
await page.goto('/parent/profile')
const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 })
// Either aria-pressed="false" or disabled (when browser denies permissions)
const ariaPressed = await toggle.getAttribute('aria-pressed')
expect(ariaPressed).toBe('false')
})
// ---------------------------------------------------------------------------
// 10.8 Push Notifications toggle is disabled when browser permission is denied
// ---------------------------------------------------------------------------
test('Push Notifications toggle is disabled when browser permission is denied', async ({
page,
context,
}) => {
// Do not grant permission (leave as denied/prompt)
await context.clearPermissions()
await page.goto('/parent/profile')
const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 })
// When permission is denied, the toggle should be disabled or aria-pressed="false"
const isDisabled = await toggle.isDisabled().catch(() => false)
const ariaPressed = await toggle.getAttribute('aria-pressed')
// Either disabled or not pressed — permission denied prevents enabling it
expect(isDisabled || ariaPressed === 'false').toBe(true)
})
})

View File

@@ -1,25 +0,0 @@
import { test, expect } from '@playwright/test'
import { E2E_PIN } from '../../e2e-constants'
test.describe('Parent profile button temporary parent mode', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/parent')
// Switch from permanent mode to temporary mode:
// 1. Exit parent mode via Child Mode
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
// 2. Re-enter parent mode WITHOUT the persistence checkbox
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_PIN)
// Do NOT check 'Stay in parent mode'
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
})
test('Badge no 🔒 badge in temporary parent mode', async ({ page }) => {
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
})
})

View File

@@ -1,86 +0,0 @@
import { test, expect } from '@playwright/test'
import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
test.describe('Parent profile button permanent parent mode', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/parent')
})
test('Badge shows 🔒 in permanent parent mode', async ({ page }) => {
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
await expect(page.getByLabel('Persistent parent mode active')).toHaveText('🔒')
})
test('Menu shows user name and email', async ({ page }) => {
await page.getByRole('button', { name: 'Parent menu' }).click()
await expect(page.getByText(E2E_FIRST_NAME, { exact: true })).toBeVisible()
await expect(page.getByText(E2E_EMAIL, { exact: true })).toBeVisible()
})
test('Menu Profile option navigates to the User Profile page', async ({ page }) => {
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Profile' }).click()
await expect(page).toHaveURL(/\/parent\/profile/)
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
})
test('Menu Child Mode exits parent mode', async ({ page }) => {
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
// View-selector navigation buttons disappear after exiting parent mode
await expect(page.getByRole('button', { name: 'Children' })).not.toBeVisible()
// Profile button changes label to "Parent login" when not in parent mode
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
})
test('Menu Child Mode re-entry without checkbox enters temporary mode', async ({ page }) => {
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
// Click profile button to open PIN modal
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_PIN)
// Do NOT check "Stay in parent mode"
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
// No persistent badge — temporary mode
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
})
test('Menu Child Mode re-entry with checkbox enters permanent mode', async ({ page }) => {
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
// Click profile button to open PIN modal
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_PIN)
await page.getByLabel('Stay in parent mode on this device').check()
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
// Persistent badge visible — permanent mode
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
})
test('Menu Sign Out navigates to landing page', async ({ page }) => {
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Sign out' }).click()
await expect(page).toHaveURL('/')
await expect(
page.getByRole('navigation').getByRole('button', { name: 'Sign In' }),
).toBeVisible()
})
})

View File

@@ -1,78 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Reward cancellation', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test.describe.configure({ mode: 'serial' })
test('Cancel reward creation', async ({ page }) => {
const name = `CancelMe-${Date.now()}`
// 1. Navigate and open create form
await page.goto('/parent/rewards')
await page.getByRole('button', { name: 'Create Reward' }).click()
await expect(page.locator('text=Reward Name')).toBeVisible()
// 2. Fill then cancel
await page.evaluate((n) => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', n)
setVal('#cost', '5')
}, name)
await page.getByRole('button', { name: 'Cancel' }).click()
// verify return to list and the cancelled item was not saved
await expect(page.getByText(name, { exact: true })).toHaveCount(0)
})
test('Cancel reward edit', async ({ page }) => {
// 1. Ensure Toy car exists and open edit
const suffix = Date.now()
const original = `Toy car ${suffix}`
await page.goto('/parent/rewards')
if (!(await page.locator(`text=${original}`).count())) {
await page.getByRole('button', { name: 'Create Reward' }).click()
await page.evaluate((name) => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#cost', '20')
}, original)
await page.getByRole('button', { name: 'Create' }).click()
}
await expect(page.locator(`text=${original}`)).toBeVisible()
await page.click(`text=${original}`)
await expect(page.locator('text=Reward Name')).toBeVisible()
// 2. make a change then cancel
await page.evaluate(() => {
const el = document.querySelector('#name') as HTMLInputElement | null
if (el) {
el.value = 'Should not save'
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
})
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.locator('text=Should not save')).toHaveCount(0)
await expect(page.locator(`text=${original}`)).toBeVisible()
})
})

View File

@@ -1,50 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Convert default reward', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test.describe.configure({ mode: 'serial' })
test('Convert a default reward to a user item', async ({ page }) => {
await page.goto('/parent/rewards')
// remove any row that has a delete button (covers custom copies and renamed originals)
const allRows = page.locator('text=Choose meal')
const total = await allRows.count()
for (let i = 0; i < total; i++) {
const r = allRows.nth(i)
const delBtn = r.locator('..').getByRole('button', { name: 'Delete' })
if ((await delBtn.count()) > 0) {
await delBtn.click()
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
await page.locator('button.btn-danger:has-text("Delete")').click()
}
}
// locate the remaining default reward (exact text)
const row = page.getByText('Choose meal', { exact: true }).first()
await expect(row).toBeVisible()
// delete button should not exist initially on default
await expect(row.locator('..').getByRole('button', { name: 'Delete' })).toHaveCount(0)
// open edit form
await row.click()
await expect(page.locator('input#name')).toHaveValue('Choose meal')
// change fields
await page.locator('#name').fill('Choose meal (custom)')
await page.locator('#cost').fill('35')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
// after save, ensure row now has delete control
const updated = page.getByText('Choose meal (custom)', { exact: true }).first()
await expect(updated).toBeVisible()
await expect(updated.locator('..').getByRole('button', { name: 'Delete' })).toBeVisible()
})
})

View File

@@ -1,139 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Reward management', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test.describe.configure({ mode: 'serial' })
test('Create a new reward', async ({ page }) => {
const suffix = Date.now()
const name = `Toy car ${suffix}`
// 1. Navigate to /parent/rewards and verify default list
await page.goto('/parent/rewards')
// expect: some reward name visible (use default item)
await expect(page.getByText('Choose meal', { exact: true })).toBeVisible()
// 2. Click the 'Create Reward' FAB
await page.getByRole('button', { name: 'Create Reward' }).click()
// expect: form appears with fields
await expect(page.locator('text=Reward Name')).toBeVisible()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
// 3. Fill in name and cost via DOM events
await page.evaluate((name) => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#cost', '20')
}, name)
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
// 4. Submit
await page.click('button:has-text("Create")')
await expect(page.locator('.error')).toHaveCount(0)
await expect(page).toHaveURL(/\/parent\//)
await expect(page.locator(`text=${name}`)).toBeVisible()
})
test('Edit an existing reward', async ({ page }) => {
const suffix = Date.now()
const original = `Toy car ${suffix}`
const updated = `Toy boat ${suffix}`
// ensure the item exists
await page.goto('/parent/rewards')
if (!(await page.locator(`text=${original}`).count())) {
await page.getByRole('button', { name: 'Create Reward' }).click()
await page.evaluate((name) => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#cost', '20')
}, original)
await page.getByRole('button', { name: 'Create' }).click()
}
await expect(page.locator(`text=${original}`)).toBeVisible()
// open edit form
await page.click(`text=${original}`)
await expect(page.locator('text=Reward Name')).toBeVisible()
// change values
await page.evaluate((name) => {
const setVal = (sel: string, val: string | number) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
if (typeof val === 'number') {
el.valueAsNumber = val
} else {
el.value = val
}
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
// set numeric cost to ensure backend receives integer
setVal('#cost', 25)
}, updated)
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.click('button:has-text("Save")')
await expect(page.locator('.error')).toHaveCount(0)
await expect(page).toHaveURL(/\/parent\//)
// allow extra time for the updated row to appear via SSE
await expect(page.locator(`text=${updated}`)).toBeVisible({ timeout: 10000 })
})
test('Delete a reward', async ({ page }) => {
const suffix = Date.now()
const name = `Toy car ${suffix}`
await page.goto('/parent/rewards')
await page.getByRole('button', { name: 'Create Reward' }).click()
await page.evaluate((name) => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#cost', '20')
}, name)
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${name}`)).toBeVisible()
// delete using row-local button
await page
.locator(`text=${name}`)
.first()
.locator('..')
.getByRole('button', { name: 'Delete' })
.click()
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
await page.locator('button.btn-danger:has-text("Delete")').click()
await expect(page.locator(`text=${name}`)).toHaveCount(0)
})
})

View File

@@ -1,124 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Default reward cost edit and restore', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test.describe.configure({ mode: 'serial' })
test('Edit default cost and verify restoration on delete', async ({ page }) => {
await page.goto('/parent/rewards')
// cleanup any user-copy of "Choose meal"
// Keep deleting rows until there are none left avoids DOM-shift race when
// multiple matching rows exist (e.g. leftovers from previous specs). Because
// other workers may modify the list concurrently, we also retry the entire
// loop once after a short delay if a delete button still shows up.
async function purge(): Promise<void> {
while (
await page
.getByText('Choose meal', { exact: true })
.locator('..')
.getByRole('button', { name: 'Delete' })
.count()
) {
const btn = page
.getByText('Choose meal', { exact: true })
.locator('..')
.getByRole('button', { name: 'Delete' })
.first()
await btn.click()
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
await page.locator('button.btn-danger:has-text("Delete")').click()
// give UI a moment to refresh the list
await page.waitForTimeout(200)
}
}
await purge()
// if something else sneaks one in (concurrent spec), wait then try again
if (
await page
.getByText('Choose meal', { exact: true })
.locator('..')
.getByRole('button', { name: 'Delete' })
.count()
) {
await page.waitForTimeout(500)
await purge()
}
// reload to make sure the list has settled after all deletions
await page.reload()
await page.waitForURL('/parent/rewards')
// ensure default exists with no delete icon
const defaultRow = page.getByText('Choose meal', { exact: true }).first()
await expect(defaultRow).toBeVisible()
// sometimes concurrent specs race and temporarily re-add a delete icon; give
// it a moment and retry once
let deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count()
if (deleteCount) {
await page.waitForTimeout(500)
deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count()
}
await expect(deleteCount).toBe(0)
// open edit and change cost
await defaultRow.click()
await expect(page.locator('input#name')).toHaveValue('Choose meal')
await page.evaluate(() => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#cost', '50')
})
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.click('button:has-text("Save")')
// After save: pick the row wrapper that now shows 50 pts (user copy)
const customRow = page
.getByText('Choose meal', { exact: true })
.locator('..')
.filter({ has: page.locator('text=50 pts') })
.first()
await expect(customRow).toBeVisible({ timeout: 10000 })
await expect(customRow.getByRole('button', { name: /Delete/ })).toBeVisible()
// delete and confirm restoration using the same customRow
await customRow.getByRole('button', { name: /Delete/ }).click()
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
await page.locator('button.btn-danger:has-text("Delete")').click()
// wait until the exact 'Choose meal' copy showing the edited cost is gone
await expect(
page
.getByText('Choose meal', { exact: true })
.locator('..')
.filter({ has: page.locator('text=50 pts') }),
).toHaveCount(0, { timeout: 10000 })
// reload the list to clear any lingering delete icon artifacts
await page.reload()
await page.waitForURL('/parent/rewards')
// ensure no delete button is visible on the exact default 'Choose meal' row
await expect(
page
.getByText('Choose meal', { exact: true })
.locator('..')
.getByRole('button', { name: 'Delete' }),
).toHaveCount(0, { timeout: 10000 })
const finalRow = page.getByText('Choose meal', { exact: true }).first()
await expect(finalRow).toBeVisible()
await expect(finalRow).not.toContainText('50 pts')
})
})

View File

@@ -1,177 +0,0 @@
// spec: e2e/plans/task-activated.plan.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ActivateChoreChild'
const CHORE_NAME = 'TriggerTestChore'
const KIND_NAME = 'TriggerChoreHelperKindness'
const CHORE_POINTS = 10
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
test.describe('Chore activation', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let kindId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
kindId = await createTask(request, KIND_NAME, 'kindness', 5)
// assign chore
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// assign kindness (set-tasks preserves other types)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId], type: 'kindness' },
})
// Add a schedule so COMPLETED status persists after trigger
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (kindId) await request.delete(`/api/task/${kindId}`)
})
test('Kebab button appears after first chore click', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Initially hidden
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
// First click: item enters ready state, kebab becomes visible
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
})
test('Kebab button disappears when clicking a non-chore item', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
const kindCard = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await kindCard.waitFor({ state: 'visible' })
// Click chore — kebab appears
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
// Click a kindness item — selectedChoreId resets to null, kebab gone
await kindCard.click()
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
})
test('Cancel chore confirmation — no points awarded', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(before))
})
test('Confirm chore activation — points awarded', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before + CHORE_POINTS))
})
test('Confirmed chore shows COMPLETED stamp', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('COMPLETED')).toBeVisible()
})
test('Clicking a completed chore does not open confirmation', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
// Two-click completed chore
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// No confirmation dialog should appear; points must remain unchanged
await expect(page.locator('.points .value')).toHaveText(String(before))
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
})
})

View File

@@ -1,128 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §2
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'GeneralChoreChild'
const GENERAL_CHORE_NAME = 'GeneralTestChore'
const SCHEDULED_CHORE_NAME = 'ScheduledTestChore'
const CHORE_POINTS = 10
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
test.describe('General chore — no COMPLETED stamp', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let generalChoreId = ''
let scheduledChoreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
generalChoreId = await createTask(request, GENERAL_CHORE_NAME, CHORE_POINTS)
scheduledChoreId = await createTask(request, SCHEDULED_CHORE_NAME, CHORE_POINTS)
// Assign both chores
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [generalChoreId, scheduledChoreId], type: 'chore' },
})
// Only the second chore gets a schedule
await request.put(`/api/child/${childId}/task/${scheduledChoreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (generalChoreId) await request.delete(`/api/task/${generalChoreId}`)
if (scheduledChoreId) await request.delete(`/api/task/${scheduledChoreId}`)
})
test('Approving a general chore awards points', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before + CHORE_POINTS))
})
test('General chore does NOT show COMPLETED stamp after approval', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('COMPLETED')).not.toBeVisible()
})
test('General chore can be triggered again immediately after approval', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: GENERAL_CHORE_NAME })
await card.waitFor({ state: 'visible' })
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Scheduled chore DOES show COMPLETED after approval', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: SCHEDULED_CHORE_NAME })
await card.waitFor({ state: 'visible' })
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(card.getByText('COMPLETED')).toBeVisible()
})
})

View File

@@ -1,149 +0,0 @@
// spec: e2e/plans/task-activated.plan.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ActivateKindnessChild'
const KIND_NAME = 'TriggerTestKindness'
const KIND_HELPER_NAME = 'TriggerKindnessHelper'
const KIND_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
test.describe('Kindness act activation', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let kindId = ''
let kindHelperId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
kindHelperId = await createTask(request, KIND_HELPER_NAME, 'kindness', KIND_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId, kindHelperId], type: 'kindness' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (kindId) await request.delete(`/api/task/${kindId}`)
if (kindHelperId) await request.delete(`/api/task/${kindHelperId}`)
})
test('Edit button appears on first kindness act click', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
// Initially hidden
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
// First click: item enters ready state, edit button becomes visible
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
})
test('Edit button disappears when clicking a different kindness card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const section = kindnessSection(page)
const card = section.locator('.item-card').filter({ hasText: KIND_NAME })
const helperCard = section.locator('.item-card').filter({ hasText: KIND_HELPER_NAME })
await card.waitFor({ state: 'visible' })
await helperCard.waitFor({ state: 'visible' })
// First click on target card — edit button appears
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
// Click on a different card — target's edit button disappears
await helperCard.click()
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
})
test('Cancel kindness act confirmation — no points awarded', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(before))
})
test('Confirm kindness act activation — points awarded', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before + KIND_POINTS))
})
test('Kindness act can be activated a second time — points awarded again', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
// Second activation of the same kindness act
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before + KIND_POINTS))
})
})

View File

@@ -1,173 +0,0 @@
// spec: e2e/plans/task-activated.plan.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ActivatePenaltyChild'
const PEN_NAME = 'TriggerTestPenalty'
const PEN_HELPER_NAME = 'TriggerPenaltyHelper'
const PEN_POINTS = 20
const INITIAL_POINTS = 50
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function penaltySection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Penalties' }),
})
}
test.describe('Penalty activation', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let penId = ''
let penHelperId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
penHelperId = await createTask(request, PEN_HELPER_NAME, 'penalty', PEN_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [penId, penHelperId], type: 'penalty' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (penId) await request.delete(`/api/task/${penId}`)
if (penHelperId) await request.delete(`/api/task/${penHelperId}`)
})
test('Edit button appears on first penalty click', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
// Initially hidden
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
// First click: item enters ready state, edit button becomes visible
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
})
test('Edit button disappears when clicking a different penalty card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const section = penaltySection(page)
const card = section.locator('.item-card').filter({ hasText: PEN_NAME })
const helperCard = section.locator('.item-card').filter({ hasText: PEN_HELPER_NAME })
await card.waitFor({ state: 'visible' })
await helperCard.waitFor({ state: 'visible' })
// First click on target card — edit button appears
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
// Click on a different card — target's edit button disappears
await helperCard.click()
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
})
test('Cancel penalty confirmation — no points deducted', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(before))
})
test('Confirm penalty activation — points deducted', async ({ page }) => {
// Starts at INITIAL_POINTS (50)
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
// 50 - 20 = 30
await expect(page.locator('.points .value')).toHaveText(String(before - PEN_POINTS))
})
test('Penalty can be activated multiple times — points deducted again', async ({ page }) => {
// Starts at 30 after previous test
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
// 30 - 20 = 10
await expect(page.locator('.points .value')).toHaveText(String(before - PEN_POINTS))
})
test('Points floor at 0 — penalty cannot make points negative', async ({ page }) => {
// Starts at 10 after previous test; penalty is 20 pts
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
// 10 - 20 would be -10; app floors at 0
await expect(page.locator('.points .value')).toHaveText('0')
})
})

View File

@@ -1,359 +0,0 @@
// spec: e2e/plans/task-activated.plan.md
// Tests for the "pending reward warning" dialog that appears when the parent tries to
// activate a chore, kindness act, penalty, or a different reward while one of the child's
// reward requests is in a pending state.
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'PendingRewardWarnChild'
const CHORE_NAME = 'PendingWarnChore'
const KIND_NAME = 'PendingWarnKindness'
const PEN_NAME = 'PendingWarnPenalty'
const PENDING_REWARD_NAME = 'PendingWarnReward'
const OTHER_REWARD_NAME = 'PendingWarnOtherReward'
const REWARD_COST = 10
const CHORE_POINTS = 5
const KIND_POINTS = 5
const PEN_POINTS = 5
const INITIAL_POINTS = 30 // must be >= REWARD_COST so the request-reward call succeeds
// ── helpers ───────────────────────────────────────────────────────────────────
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'E2E pending-warn test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
function penaltySection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Penalties' }),
})
}
function rewardSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
// ── suite ─────────────────────────────────────────────────────────────────────
test.describe('Pending reward warning dialog', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let kindId = ''
let penId = ''
let pendingRewardId = ''
let otherRewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
pendingRewardId = await createReward(request, PENDING_REWARD_NAME, REWARD_COST)
otherRewardId = await createReward(request, OTHER_REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId], type: 'kindness' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [penId], type: 'penalty' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [pendingRewardId, otherRewardId] },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (kindId) await request.delete(`/api/task/${kindId}`)
if (penId) await request.delete(`/api/task/${penId}`)
if (pendingRewardId) await request.delete(`/api/reward/${pendingRewardId}`)
if (otherRewardId) await request.delete(`/api/reward/${otherRewardId}`)
})
// Ensure each test starts with the pending reward freshly requested
test.beforeEach(async ({ request }) => {
// Reset child points so the request-reward call always succeeds
await request.put(`/api/child/${childId}/edit`, { data: { points: INITIAL_POINTS } })
// Cancel any leftover request (404 if none — ignored)
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: pendingRewardId },
})
// Place a fresh pending request
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: pendingRewardId },
})
})
// ── banner ────────────────────────────────────────────────────────────────
test('Pending reward card shows PENDING banner', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page)
.locator('.item-card')
.filter({ hasText: PENDING_REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.locator('.pending')).toHaveText('PENDING')
})
// ── chore ─────────────────────────────────────────────────────────────────
test('Chore activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const pointsBefore = await getPoints(page)
await activateItem(card)
// Pending reward warning dialog appears (has a "No" button)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
// Dialog dismissed; task confirmation dialog must NOT have appeared
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
// Points unchanged
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
// PENDING banner still visible on the reward card
const rewardCard = rewardSection(page)
.locator('.item-card')
.filter({ hasText: PENDING_REWARD_NAME })
await expect(rewardCard.locator('.pending')).toHaveText('PENDING')
})
test('Chore activation — Confirm on pending dialog cancels pending reward and triggers chore', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const pointsBefore = await getPoints(page)
await activateItem(card)
// Pending reward warning dialog
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
// After confirming, the task confirmation dialog appears (has "Cancel" button)
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
await page.getByRole('button', { name: 'Yes' }).click()
// Chore points awarded — points changed from initial value
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
timeout: 3000,
})
})
// ── kindness ──────────────────────────────────────────────────────────────
test('Kindness activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
const pointsBefore = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
})
test('Kindness activation — Confirm on pending dialog cancels pending reward and triggers kindness', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
const pointsBefore = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
timeout: 3000,
})
})
// ── penalty ───────────────────────────────────────────────────────────────
test('Penalty activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
const pointsBefore = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
})
test('Penalty activation — Confirm on pending dialog cancels pending reward and triggers penalty', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
const pointsBefore = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
await page.getByRole('button', { name: 'Yes' }).click()
// Penalty deducted points
await expect(page.locator('.points .value')).not.toHaveText(String(pointsBefore), {
timeout: 3000,
})
})
// ── other reward ──────────────────────────────────────────────────────────
test('Other reward activation — Cancel on pending dialog leaves state unchanged', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
// Both rewards should be ready (INITIAL_POINTS >= REWARD_COST)
const pendingCard = rewardSection(page)
.locator('.item-card')
.filter({ hasText: PENDING_REWARD_NAME })
const otherCard = rewardSection(page)
.locator('.item-card')
.filter({ hasText: OTHER_REWARD_NAME })
await pendingCard.waitFor({ state: 'visible' })
await otherCard.waitFor({ state: 'visible' })
await expect(pendingCard.locator('.pending')).toHaveText('PENDING')
await expect(otherCard.getByText('REWARD READY')).toBeVisible()
const pointsBefore = await getPoints(page)
await activateItem(otherCard)
// Pending reward warning dialog appears when another reward has a pending request
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
// PENDING banner still visible — reward request was not cancelled
await expect(pendingCard.locator('.pending')).toHaveText('PENDING')
})
test('Other reward activation — Confirm on pending dialog cancels the pending reward', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const pendingCard = rewardSection(page)
.locator('.item-card')
.filter({ hasText: PENDING_REWARD_NAME })
const otherCard = rewardSection(page)
.locator('.item-card')
.filter({ hasText: OTHER_REWARD_NAME })
await pendingCard.waitFor({ state: 'visible' })
await otherCard.waitFor({ state: 'visible' })
await activateItem(otherCard)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
// Pending dialog closes; reward confirm does NOT auto-open (other reward must be re-clicked)
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
// The pending reward request was cancelled — PENDING banner should disappear
await expect(pendingCard.locator('.pending')).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,181 +0,0 @@
// spec: e2e/plans/task-activated.plan.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ActivateRewardChild'
const REWARD_NAME = 'TriggerTestReward'
const REWARD_HELPER_NAME = 'TriggerRewardHelper'
const REWARD_COST = 20
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function rewardSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Reward redemption', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
let rewardHelperId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
rewardHelperId = await createReward(request, REWARD_HELPER_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId, rewardHelperId] },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
if (rewardHelperId) await request.delete(`/api/reward/${rewardHelperId}`)
})
test('Locked reward shows correct points needed text', async ({ page }) => {
// Child starts at 0 pts; reward costs 20 → "20 more points"
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
await expect(card.getByText('REWARD READY')).not.toBeVisible()
})
test('Clicking a locked reward does not open confirmation dialog', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
// Two-click on locked reward
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// triggerReward returns early when points_needed > 0 — no dialog
await expect(page.locator('.points .value')).toHaveText('0')
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
})
test('Reward shows REWARD READY when child has sufficient points', async ({ page, request }) => {
// Set child points to match reward cost
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('REWARD READY')).toBeVisible()
await expect(card.getByText(/more points/)).not.toBeVisible()
})
test('Edit button appears on first click of a ready reward', async ({ page }) => {
// Child has REWARD_COST pts (set in previous test)
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
// Initially hidden
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
// First click: item enters ready state, edit button becomes visible
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
})
test('Edit button disappears when clicking a different reward card', async ({ page }) => {
// Child has REWARD_COST pts (set in previous test); both rewards show REWARD READY
await page.goto(`/parent/${childId}`)
const section = rewardSection(page)
const card = section.locator('.item-card').filter({ hasText: REWARD_NAME })
const helperCard = section.locator('.item-card').filter({ hasText: REWARD_HELPER_NAME })
await card.waitFor({ state: 'visible' })
await helperCard.waitFor({ state: 'visible' })
// First click on target card — edit button appears
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
// Click on a different reward card — target's edit button disappears
await helperCard.click()
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
})
test('Cancel reward redemption — points not deducted', async ({ page }) => {
// Child has REWARD_COST pts (from two tests ago; edit button test did not consume them)
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('REWARD READY')).toBeVisible()
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(before))
await expect(card.getByText('REWARD READY')).toBeVisible()
})
test('Confirm reward redemption — points deducted and reward locks again', async ({ page }) => {
// Child still has REWARD_COST pts after the cancelled redemption
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('REWARD READY')).toBeVisible()
const before = await getPoints(page)
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
// Points reduced by reward cost
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST))
// Reward is now locked again — shows points needed
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
await expect(card.getByText('REWARD READY')).not.toBeVisible()
})
})

View File

@@ -1,194 +0,0 @@
// spec: e2e/plans/task-activated.plan.md
// Tests for scrollable list flow and first-click scroll-to-center behavior.
// 8 kindness items are assigned to a dedicated child so the list overflows
// a narrow (480 px) viewport, reliably exercising the horizontal scroll path.
import { test, expect, type APIRequestContext, type Locator } from '@playwright/test'
/**
* Returns true if the element's bounding rect overlaps the current page viewport.
* Uses getBoundingClientRect() — reliable across all Playwright versions and
* correctly handles elements clipped by overflow scroll containers.
*/
async function isInViewport(locator: Locator): Promise<boolean> {
return locator.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth)
)
})
}
const CHILD_NAME = 'ScrollFlowChild'
const ITEM_COUNT = 8
const ITEM_NAMES = Array.from({ length: ITEM_COUNT }, (_, i) => `ScrollTestKindness${i + 1}`)
const NARROW_VIEWPORT = { width: 480, height: 800 }
// ── helpers ───────────────────────────────────────────────────────────────────
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points: 5, type: 'kindness' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
// ── tests ─────────────────────────────────────────────────────────────────────
test.describe('Scroll and flow', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
const taskIds: string[] = []
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
for (const name of ITEM_NAMES) {
taskIds.push(await createTask(request, name))
}
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: taskIds, type: 'kindness' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of taskIds) await request.delete(`/api/task/${id}`)
taskIds.length = 0
})
test('Kindness list is horizontally scrollable when many items are assigned', async ({
page,
}) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}`)
const scrollWrapper = page
.locator('.child-list-container')
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
.locator('.scroll-wrapper')
await scrollWrapper.waitFor({ state: 'visible' })
// Total content width (8 × 140 px + 7 × 12 px gaps ≈ 1204 px) must exceed container width
const isOverflowing = await scrollWrapper.evaluate(
(el: HTMLElement) => el.scrollWidth > el.clientWidth,
)
expect(isOverflowing).toBe(true)
// The last card in DOM order is outside the 480 px viewport at scrollLeft=0
const section = page
.locator('.child-list-container')
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
const lastCard = section.locator('.item-card').last()
expect(await isInViewport(lastCard)).toBe(false)
})
test('Scrolling the list reveals hidden items', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}`)
const section = page
.locator('.child-list-container')
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
const scrollWrapper = section.locator('.scroll-wrapper')
await scrollWrapper.waitFor({ state: 'visible' })
// Use DOM-order first/last — independent of API return order
const firstCard = section.locator('.item-card').first()
const lastCard = section.locator('.item-card').last()
// Initially: first item is in viewport, last item is scrolled off to the right
expect(await isInViewport(firstCard)).toBe(true)
expect(await isInViewport(lastCard)).toBe(false)
// Programmatically scroll to the end — override smooth-scroll so position is immediate
await scrollWrapper.evaluate((el: HTMLElement) => {
el.style.scrollBehavior = 'auto'
el.scrollLeft = el.scrollWidth
})
await page.waitForTimeout(100)
// Last item is now visible; first item has scrolled off to the left
expect(await isInViewport(lastCard)).toBe(true)
expect(await isInViewport(firstCard)).toBe(false)
})
test('First click on an off-screen item scrolls it into view and marks it ready', async ({
page,
}) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}`)
const section = page
.locator('.child-list-container')
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
const scrollWrapper = section.locator('.scroll-wrapper')
await scrollWrapper.waitFor({ state: 'visible' })
// The last item in DOM order is definitely off-screen at scrollLeft=0 on a 480 px viewport
const lastCard = section.locator('.item-card').last()
expect(await isInViewport(lastCard)).toBe(false)
// force: true is required because the element's bounding-box center is outside
// the page viewport — that is exactly the condition we are testing (scroll-to-center).
await lastCard.click({ force: true })
// The first-click handler runs centerItem() then emits item-ready
await expect(lastCard).toHaveClass(/item-ready/, { timeout: 5000 })
// Wait for the smooth-scroll animation to settle
await page.waitForTimeout(800)
// After centering, the item must be visible within the 480 px viewport
expect(await isInViewport(lastCard)).toBe(true)
})
test('Scrolling does not prevent first-click centering on a partially visible item', async ({
page,
}) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}`)
const section = page
.locator('.child-list-container')
.filter({ has: page.locator('h3', { hasText: 'Kindness Acts' }) })
const scrollWrapper = section.locator('.scroll-wrapper')
await scrollWrapper.waitFor({ state: 'visible' })
// Scroll to the middle — override smooth-scroll so position is immediate
await scrollWrapper.evaluate((el: HTMLElement) => {
el.style.scrollBehavior = 'auto'
el.scrollLeft = Math.floor(el.scrollWidth / 2)
})
await page.waitForTimeout(100)
// The first item in DOM order is now off-screen to the left after scrolling to the middle
const firstCard = section.locator('.item-card').first()
expect(await isInViewport(firstCard)).toBe(false)
// Click the first item (force: true because its center is left of the viewport)
await firstCard.click({ force: true })
await expect(firstCard).toHaveClass(/item-ready/, { timeout: 5000 })
await page.waitForTimeout(800)
// First item should now be centered and in the viewport
expect(await isInViewport(firstCard)).toBe(true)
})
})

View File

@@ -1,55 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §1
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'AssignTitleChild'
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
test.describe('Assign view heading shows child name', () => {
let childId = ''
test.beforeAll(async ({ request }) => {
childId = await createTestChild(request, CHILD_NAME)
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
})
test('"Assign Chores" heading contains child name', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`)
await expect(
page.getByRole('heading', { name: new RegExp(`Assign Chores.*${CHILD_NAME}`) }),
).toBeVisible()
})
test('"Assign Rewards" heading contains child name', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`)
await expect(
page.getByRole('heading', { name: new RegExp(`Assign Rewards.*${CHILD_NAME}`) }),
).toBeVisible()
})
test('"Assign Kindness Acts" heading contains child name', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`)
await expect(
page.getByRole('heading', { name: new RegExp(`Assign Kindness Acts.*${CHILD_NAME}`) }),
).toBeVisible()
})
test('"Assign Penalties" heading contains child name', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`)
await expect(
page.getByRole('heading', { name: new RegExp(`Assign Penalties.*${CHILD_NAME}`) }),
).toBeVisible()
})
})

View File

@@ -1,174 +0,0 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'AssignChoreChild'
const CHORE1_NAME = 'AssignTestChore1'
const CHORE2_NAME = 'AssignTestChore2'
const CHORE3_NAME = 'AssignTestChore3'
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
await request.put('/api/child/add', { data: { name, age: 8 } })
const res = await request.get('/api/child/list')
const data = (await res.json()) as { children?: { name: string; id: string }[] }
return data.children?.find((c) => c.name === name)?.id ?? ''
}
async function createTestTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
): Promise<string> {
await request.put('/api/task/add', { data: { name, points: 5, type } })
const res = await request.get('/api/task/list')
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
return data.tasks?.find((t) => t.name === name)?.id ?? ''
}
test.describe('Chore assignment', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let chore1Id = ''
let chore2Id = ''
let chore3Id = ''
test.beforeAll(async ({ request }) => {
// Clean up any stale child from a previous run
const listRes = await request.get('/api/child/list')
const listData = await listRes.json()
for (const c of listData.children ?? []) {
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
}
// Clean up any stale test tasks from a previous run
const taskRes = await request.get('/api/task/list')
const taskData = await taskRes.json()
for (const t of taskData.tasks ?? []) {
if ([CHORE1_NAME, CHORE2_NAME, CHORE3_NAME].includes(t.name)) {
await request.delete(`/api/task/${t.id}`)
}
}
childId = await createTestChild(request, CHILD_NAME)
chore1Id = await createTestTask(request, CHORE1_NAME, 'chore')
chore2Id = await createTestTask(request, CHORE2_NAME, 'chore')
chore3Id = await createTestTask(request, CHORE3_NAME, 'chore')
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (chore1Id) await request.delete(`/api/task/${chore1Id}`)
if (chore2Id) await request.delete(`/api/task/${chore2Id}`)
if (chore3Id) await request.delete(`/api/task/${chore3Id}`)
})
test('"Assign Chores" button navigates to the chore assign view', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Chores' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Test-specific chores are listed
await expect(page.getByText(CHORE1_NAME, { exact: true })).toBeVisible()
await expect(page.getByText(CHORE2_NAME, { exact: true })).toBeVisible()
// Fresh child — nothing pre-checked
await expect(
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Select chores and submit — items appear in ParentView chores section', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Chores' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Select two chores
await page.getByText(CHORE1_NAME, { exact: true }).click()
await page.getByText(CHORE2_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
).toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
await expect(page.getByText(CHORE1_NAME)).toBeVisible()
await expect(page.getByText(CHORE2_NAME)).toBeVisible()
})
test('Re-opening assign view shows assigned items as pre-checked', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-chores`)
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Previously assigned items must be pre-checked
await expect(
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
).toBeChecked()
// An unassigned item must not be checked
await expect(
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Chores' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Uncheck the two previously assigned chores
await page.getByText(CHORE1_NAME, { exact: true }).click()
await page.getByText(CHORE2_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: CHORE1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: CHORE2_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
await expect(page.getByText(CHORE1_NAME)).not.toBeVisible()
await expect(page.getByText(CHORE2_NAME)).not.toBeVisible()
})
test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Chores' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Check an item
await page.getByText(CHORE3_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
).toBeChecked()
// Cancel instead of submitting
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
// Navigate back to the assign view and verify the selection was not saved
await page.goto(`/parent/${childId}/assign-chores`)
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
await expect(
page.locator('.list-row').filter({ hasText: CHORE3_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
})

View File

@@ -1,143 +0,0 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'AssignKindnessChild'
const KIND1_NAME = 'AssignTestKindness1'
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
await request.put('/api/child/add', { data: { name, age: 8 } })
const res = await request.get('/api/child/list')
const data = (await res.json()) as { children?: { name: string; id: string }[] }
return data.children?.find((c) => c.name === name)?.id ?? ''
}
async function createTestTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
): Promise<string> {
await request.put('/api/task/add', { data: { name, points: 5, type } })
const res = await request.get('/api/task/list')
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
return data.tasks?.find((t) => t.name === name)?.id ?? ''
}
test.describe('Kindness act assignment', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let kind1Id = ''
test.beforeAll(async ({ request }) => {
// Clean up any stale child from a previous run
const listRes = await request.get('/api/child/list')
const listData = await listRes.json()
for (const c of listData.children ?? []) {
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
}
// Clean up any stale test task from a previous run
const taskRes = await request.get('/api/task/list')
const taskData = await taskRes.json()
for (const t of taskData.tasks ?? []) {
if (t.name === KIND1_NAME) await request.delete(`/api/task/${t.id}`)
}
childId = await createTestChild(request, CHILD_NAME)
kind1Id = await createTestTask(request, KIND1_NAME, 'kindness')
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (kind1Id) await request.delete(`/api/task/${kind1Id}`)
})
test('"Assign Kindness Acts" button navigates to the kindness assign view', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
// Test-specific kindness act is listed
await expect(page.getByText(KIND1_NAME, { exact: true })).toBeVisible()
// Fresh child — nothing pre-checked
await expect(
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Select a kindness act and submit — item appears in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
await page.getByText(KIND1_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
await expect(page.getByText(KIND1_NAME)).toBeVisible()
})
test('Re-opening assign view shows the assigned item as pre-checked', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-kindness`)
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
await expect(
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
).toBeChecked()
})
test('Uncheck the kindness act and submit — item is removed from ParentView', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
// Uncheck the previously assigned item
await page.getByText(KIND1_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
await expect(page.getByText(KIND1_NAME)).not.toBeVisible()
})
test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
// Check the item
await page.getByText(KIND1_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
).toBeChecked()
// Cancel instead of submitting
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
// Navigate back and verify the selection was not saved
await page.goto(`/parent/${childId}/assign-kindness`)
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
await expect(
page.locator('.list-row').filter({ hasText: KIND1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
})

View File

@@ -1,176 +0,0 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'AssignPenaltyChild'
const PEN1_NAME = 'AssignTestPenalty1'
const PEN2_NAME = 'AssignTestPenalty2'
const PEN3_NAME = 'AssignTestPenalty3'
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
await request.put('/api/child/add', { data: { name, age: 8 } })
const res = await request.get('/api/child/list')
const data = (await res.json()) as { children?: { name: string; id: string }[] }
return data.children?.find((c) => c.name === name)?.id ?? ''
}
async function createTestTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
): Promise<string> {
await request.put('/api/task/add', { data: { name, points: 5, type } })
const res = await request.get('/api/task/list')
const data = (await res.json()) as { tasks?: { name: string; id: string }[] }
return data.tasks?.find((t) => t.name === name)?.id ?? ''
}
test.describe('Penalty assignment', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let pen1Id = ''
let pen2Id = ''
let pen3Id = ''
test.beforeAll(async ({ request }) => {
// Clean up any stale child from a previous run
const listRes = await request.get('/api/child/list')
const listData = await listRes.json()
for (const c of listData.children ?? []) {
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
}
// Clean up any stale test tasks from a previous run
const taskRes = await request.get('/api/task/list')
const taskData = await taskRes.json()
for (const t of taskData.tasks ?? []) {
if ([PEN1_NAME, PEN2_NAME, PEN3_NAME].includes(t.name)) {
await request.delete(`/api/task/${t.id}`)
}
}
childId = await createTestChild(request, CHILD_NAME)
pen1Id = await createTestTask(request, PEN1_NAME, 'penalty')
pen2Id = await createTestTask(request, PEN2_NAME, 'penalty')
pen3Id = await createTestTask(request, PEN3_NAME, 'penalty')
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (pen1Id) await request.delete(`/api/task/${pen1Id}`)
if (pen2Id) await request.delete(`/api/task/${pen2Id}`)
if (pen3Id) await request.delete(`/api/task/${pen3Id}`)
})
test('"Assign Penalties" button navigates to the penalty assign view', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Penalties' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Test-specific penalty items are listed
await expect(page.getByText(PEN1_NAME, { exact: true })).toBeVisible()
await expect(page.getByText(PEN2_NAME, { exact: true })).toBeVisible()
// Fresh child — nothing pre-checked
await expect(
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Select penalties and submit — items appear in ParentView penalties section', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Penalties' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Select two penalties
await page.getByText(PEN1_NAME, { exact: true }).click()
await page.getByText(PEN2_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
).toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
await expect(page.getByText(PEN1_NAME)).toBeVisible()
await expect(page.getByText(PEN2_NAME)).toBeVisible()
})
test('Re-opening assign view shows assigned penalties as pre-checked', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-penalties`)
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Previously assigned items should be pre-checked
await expect(
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
).toBeChecked()
// An unassigned item should not be checked
await expect(
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Penalties' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Uncheck the previously assigned penalties
await page.getByText(PEN1_NAME, { exact: true }).click()
await page.getByText(PEN2_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: PEN1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: PEN2_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
await expect(page.getByText(PEN1_NAME)).not.toBeVisible()
await expect(page.getByText(PEN2_NAME)).not.toBeVisible()
})
test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Penalties' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Check an item
await page.getByText(PEN3_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
).toBeChecked()
// Cancel instead of submitting
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
// Navigate back and verify the selection was not saved
await page.goto(`/parent/${childId}/assign-penalties`)
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
await expect(
page.locator('.list-row').filter({ hasText: PEN3_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
})

View File

@@ -1,172 +0,0 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'AssignRewardChild'
const REW1_NAME = 'AssignTestReward1'
const REW2_NAME = 'AssignTestReward2'
const REW3_NAME = 'AssignTestReward3'
async function createTestChild(request: APIRequestContext, name: string): Promise<string> {
await request.put('/api/child/add', { data: { name, age: 8 } })
const res = await request.get('/api/child/list')
const data = (await res.json()) as { children?: { name: string; id: string }[] }
return data.children?.find((c) => c.name === name)?.id ?? ''
}
async function createTestReward(request: APIRequestContext, name: string): Promise<string> {
await request.put('/api/reward/add', { data: { name, description: 'Test reward', cost: 10 } })
const res = await request.get('/api/reward/list')
const data = (await res.json()) as { rewards?: { name: string; id: string }[] }
return data.rewards?.find((r) => r.name === name)?.id ?? ''
}
test.describe('Reward assignment', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rew1Id = ''
let rew2Id = ''
let rew3Id = ''
test.beforeAll(async ({ request }) => {
// Clean up any stale child from a previous run
const listRes = await request.get('/api/child/list')
const listData = await listRes.json()
for (const c of listData.children ?? []) {
if (c.name === CHILD_NAME) await request.delete(`/api/child/${c.id}`)
}
// Clean up any stale test rewards from a previous run
const rewardRes = await request.get('/api/reward/list')
const rewardData = await rewardRes.json()
for (const r of rewardData.rewards ?? []) {
if ([REW1_NAME, REW2_NAME, REW3_NAME].includes(r.name)) {
await request.delete(`/api/reward/${r.id}`)
}
}
childId = await createTestChild(request, CHILD_NAME)
rew1Id = await createTestReward(request, REW1_NAME)
rew2Id = await createTestReward(request, REW2_NAME)
rew3Id = await createTestReward(request, REW3_NAME)
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rew1Id) await request.delete(`/api/reward/${rew1Id}`)
if (rew2Id) await request.delete(`/api/reward/${rew2Id}`)
if (rew3Id) await request.delete(`/api/reward/${rew3Id}`)
})
test('"Assign Rewards" button navigates to the reward assign view', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Rewards' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Test-specific rewards are listed
await expect(page.getByText(REW1_NAME, { exact: true })).toBeVisible()
await expect(page.getByText(REW2_NAME, { exact: true })).toBeVisible()
// Fresh child — nothing pre-checked
await expect(
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Select rewards and submit — items appear in ParentView rewards section', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Rewards' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Select two rewards
await page.getByText(REW1_NAME, { exact: true }).click()
await page.getByText(REW2_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
).toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
await expect(page.getByText(REW1_NAME)).toBeVisible()
await expect(page.getByText(REW2_NAME)).toBeVisible()
})
test('Re-opening assign view shows assigned rewards as pre-checked', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-rewards`)
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Previously assigned items should be pre-checked
await expect(
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
).toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
).toBeChecked()
// An unassigned item should not be checked
await expect(
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Rewards' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Uncheck the previously assigned rewards
await page.getByText(REW1_NAME, { exact: true }).click()
await page.getByText(REW2_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: REW1_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await expect(
page.locator('.list-row').filter({ hasText: REW2_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
await page.getByRole('button', { name: 'Submit' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
await expect(page.getByText(REW1_NAME)).not.toBeVisible()
await expect(page.getByText(REW2_NAME)).not.toBeVisible()
})
test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Rewards' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Check an item
await page.getByText(REW3_NAME, { exact: true }).click()
await expect(
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
).toBeChecked()
// Cancel instead of submitting
await page.getByRole('button', { name: 'Cancel' }).click()
await page.waitForURL(new RegExp(`/parent/${childId}$`))
// Navigate back and verify the selection was not saved
await page.goto(`/parent/${childId}/assign-rewards`)
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
await expect(
page.locator('.list-row').filter({ hasText: REW3_NAME }).locator('.list-checkbox'),
).not.toBeChecked()
})
})

View File

@@ -1,238 +0,0 @@
// spec: e2e/plans/task-modified.plan copy.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ModifyChoreChild'
const CHORE_NAME = 'ModifyTestChore'
const CHORE_POINTS = 10
const HELPER_KIND_NAME = 'ModifyChoreHelperKindness'
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
return children?.find((c) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
return tasks?.find((t) => t.name === name)?.id ?? ''
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
async function openChoreOverrideModal(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
await card.getByRole('button', { name: 'Options' }).click()
await page.getByRole('button', { name: 'Edit Points' }).click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
test.describe('Chore edit points', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let helperKindId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
helperKindId = await createTask(request, HELPER_KIND_NAME, 'kindness', 5)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [helperKindId], type: 'kindness' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (helperKindId) await request.delete(`/api/task/${helperKindId}`)
})
test('Kebab button is hidden initially, visible after first click, hidden after deselect', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Initially hidden (v-show=false while selectedChoreId != item.id)
await expect(card.getByRole('button', { name: 'Options' })).toBeHidden()
// First click: card enters ready state, kebab shown
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
// Clicking a kindness card resets selectedChoreId to null → kebab hidden
const kindCard = kindnessSection(page)
.locator('.item-card')
.filter({ hasText: HELPER_KIND_NAME })
await kindCard.click()
await expect(card.getByRole('button', { name: 'Options' })).toBeHidden()
})
test('Kebab menu contains "Edit Points" and "Schedule"', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
await card.getByRole('button', { name: 'Options' }).click()
await expect(page.getByRole('button', { name: 'Edit Points' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
})
test('"Edit Points" opens override modal pre-populated with current points', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
await card.getByRole('button', { name: 'Options' }).click()
await page.getByRole('button', { name: 'Edit Points' }).click()
await expect(page.locator('.modal-title')).toHaveText(CHORE_NAME)
await expect(page.locator('#custom-value')).toHaveValue(String(CHORE_POINTS))
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Clicking outside the modal does not close it', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
// Click the backdrop area far from the modal dialog
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 }, force: true })
// Modal must still be visible
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Cancel does not save — card still shows original points, no ⭐ badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill('99')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText(`${CHORE_POINTS} Points`)
await expect(card.locator('.override-badge')).not.toBeVisible()
})
test('Saving a valid new value updates the displayed points on the card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill('50')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText('50 Points')
})
test('⭐ override badge appears on the card after saving a custom value', async ({ page }) => {
// Badge state persists server-side; verify on a fresh page load
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.locator('.override-badge')).toBeVisible()
})
test('⭐ badge persists after changing value back to the original default', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Change override back to original value — badge stays until item is unassigned/reassigned
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill(String(CHORE_POINTS))
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText(`${CHORE_POINTS} Points`)
await expect(card.locator('.override-badge')).toBeVisible()
})
test('Negative value disables the Save button', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill('-1')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill('10001')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.locator('#custom-value').fill('10000')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Zero is a valid value — card shows "0 Points" with ⭐ badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill('0')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText('0 Points')
await expect(card.locator('.override-badge')).toBeVisible()
})
})

View File

@@ -1,170 +0,0 @@
// spec: e2e/plans/task-modified.plan copy.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ModifyResetChild'
const CHORE_NAME = 'ModifyResetChore'
const CHORE_POINTS = 10
const HELPER_KIND_NAME = 'ModifyResetKindnessHelper'
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
return children?.find((c) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
return tasks?.find((t) => t.name === name)?.id ?? ''
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
async function openChoreKebab(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
await card.getByRole('button', { name: 'Options' }).click()
}
test.describe('Chore reset', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let helperKindId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
helperKindId = await createTask(request, HELPER_KIND_NAME, 'kindness', 5)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [helperKindId], type: 'kindness' },
})
// Add a schedule so COMPLETED status persists after trigger
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (helperKindId) await request.delete(`/api/task/${helperKindId}`)
})
test('"Reset" is absent from kebab menu when chore is not yet completed', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreKebab(page, card)
await expect(page.getByRole('button', { name: 'Reset' })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Edit Points' })).toBeVisible()
})
test('Chore shows COMPLETED stamp after activation and confirmation', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(card.getByText('COMPLETED')).toBeVisible()
})
test('"Reset" appears in kebab menu after chore is completed', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('COMPLETED')).toBeVisible()
await openChoreKebab(page, card)
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible()
})
test('Clicking "Reset" removes the COMPLETED stamp', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('COMPLETED')).toBeVisible()
await openChoreKebab(page, card)
await page.getByRole('button', { name: 'Reset' }).click()
await expect(card.getByText('COMPLETED')).not.toBeVisible()
})
test('Chore is re-activatable after reset — returns to COMPLETED on confirm', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Chore is not completed after the previous test's reset
await expect(card.getByText('COMPLETED')).not.toBeVisible()
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(card.getByText('COMPLETED')).toBeVisible()
})
test('Reset → activate cycle is repeatable without error', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Reset the now-completed chore
await openChoreKebab(page, card)
await page.getByRole('button', { name: 'Reset' }).click()
await expect(card.getByText('COMPLETED')).not.toBeVisible()
// Navigate fresh so item-ready state is cleared before activating
await page.goto(`/parent/${childId}`)
await card.waitFor({ state: 'visible' })
// Activate again — COMPLETED returns
await activateItem(card)
await page.getByRole('button', { name: 'Yes' }).click()
await expect(card.getByText('COMPLETED')).toBeVisible()
})
})

View File

@@ -1,176 +0,0 @@
// spec: e2e/plans/task-modified.plan copy.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ModifyKindnessChild'
const KIND_NAME = 'ModifyTestKindness'
const KIND_HELPER_NAME = 'ModifyKindnessHelper'
const KIND_POINTS = 15
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
return children?.find((c) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
return tasks?.find((t) => t.name === name)?.id ?? ''
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
async function openEditModal(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
test.describe('Kindness act edit points', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let kindId = ''
let kindHelperId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
kindHelperId = await createTask(request, KIND_HELPER_NAME, 'kindness', 5)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId, kindHelperId], type: 'kindness' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (kindId) await request.delete(`/api/task/${kindId}`)
if (kindHelperId) await request.delete(`/api/task/${kindHelperId}`)
})
test('Edit button appears on first click of a kindness card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
})
test('Edit button disappears when clicking a different kindness card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const section = kindnessSection(page)
const card = section.locator('.item-card').filter({ hasText: KIND_NAME })
const helperCard = section.locator('.item-card').filter({ hasText: KIND_HELPER_NAME })
await card.waitFor({ state: 'visible' })
await helperCard.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
await helperCard.click()
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
})
test('Edit button opens modal with kindness name and "Assign new points" subtitle', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.locator('.modal-title')).toHaveText(KIND_NAME)
await expect(page.locator('.modal-subtitle')).toContainText('new points')
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Input is pre-populated with the current point value', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await expect(page.locator('#custom-value')).toHaveValue(String(KIND_POINTS))
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Cancel does not save — points remain unchanged, no ⭐ badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('77')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText(`${KIND_POINTS} Points`)
await expect(card.locator('.override-badge')).not.toBeVisible()
})
test('Saving a new value updates displayed points and shows ⭐ badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('20')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText('20 Points')
await expect(card.locator('.override-badge')).toBeVisible()
})
test('Negative value disables the Save button', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('-5')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('10001')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.locator('#custom-value').fill('10000')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
})

View File

@@ -1,241 +0,0 @@
// spec: e2e/plans/task-modified.plan copy.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ModifyActivateChild'
const CHORE_NAME = 'ModifyActChore'
const KIND_NAME = 'ModifyActKindness'
const PEN_NAME = 'ModifyActPenalty'
const REWARD_NAME = 'ModifyActReward'
const CHORE_POINTS = 10
const KIND_POINTS = 10
const PEN_POINTS = 10
const REWARD_COST = 100
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
return children?.find((c) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
return tasks?.find((t) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
const list = await request.get('/api/reward/list')
const { rewards } = (await list.json()) as { rewards: { name: string; id: string }[] }
return rewards?.find((r) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
async function activateItem(itemCard: Locator): Promise<void> {
await itemCard.click()
await expect(itemCard).toHaveClass(/item-ready/, { timeout: 3000 })
await itemCard.click()
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
function penaltySection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Penalties' }),
})
}
function rewardSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
async function openChoreOverrideModal(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
await card.getByRole('button', { name: 'Options' }).click()
await page.getByRole('button', { name: 'Edit Points' }).click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
async function openEditModal(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
test.describe('Modified points take effect on activation', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let kindId = ''
let penId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId], type: 'kindness' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [penId], type: 'penalty' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (kindId) await request.delete(`/api/task/${kindId}`)
if (penId) await request.delete(`/api/task/${penId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
test('Modified chore points are awarded on activation (override 25, default 10)', async ({
page,
}) => {
// Edit override to 25 via kebab → Edit Points
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await openChoreOverrideModal(page, card)
await page.locator('#custom-value').fill('25')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Fresh navigation — activate chore and verify points awarded at override rate
await page.goto(`/parent/${childId}`)
const choreCard = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await choreCard.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(choreCard)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before + 25))
})
test('Modified kindness points are awarded on activation (override 8, default 10)', async ({
page,
}) => {
// Edit override to 8 via edit button
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('8')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Fresh navigation — activate and verify points use override value
await page.goto(`/parent/${childId}`)
const kindCard = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await kindCard.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(kindCard)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before + 8))
})
test('Modified penalty points are deducted at override rate (override 3, default 10)', async ({
page,
}) => {
// Edit penalty override to 3 via edit button
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('3')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Fresh navigation — activate penalty and verify deduction uses override value
await page.goto(`/parent/${childId}`)
const penCard = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await penCard.waitFor({ state: 'visible' })
const before = await getPoints(page)
await activateItem(penCard)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.locator('.points .value')).toHaveText(String(before - 3))
})
test('Modified reward cost is reflected in points needed display (override 30)', async ({
page,
}) => {
// Edit reward override to 30 via edit button
// Child currently has 25 + 8 - 3 = 30 points from previous tests
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('30')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Fresh navigation — verify reward shows "REWARD READY" (30pts held, 30 cost override)
await page.goto(`/parent/${childId}`)
const rewardCard = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await rewardCard.waitFor({ state: 'visible' })
await expect(rewardCard.getByText('REWARD READY')).toBeVisible()
})
})

View File

@@ -1,151 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §5
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'OverrideSSEChild'
const CHORE_NAME = 'OverrideSSEChore'
const KIND_NAME = 'OverrideSSEKindness'
const PEN_NAME = 'OverrideSSEPenalty'
const CHORE_POINTS = 10
const KIND_POINTS = 15
const PEN_POINTS = 8
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function kindnessSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Kindness Acts' }),
})
}
function penaltySection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Penalties' }),
})
}
test.describe('Override SSE updates child view', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let kindId = ''
let penId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore', CHORE_POINTS)
kindId = await createTask(request, KIND_NAME, 'kindness', KIND_POINTS)
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId], type: 'kindness' },
})
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [penId], type: 'penalty' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (kindId) await request.delete(`/api/task/${kindId}`)
if (penId) await request.delete(`/api/task/${penId}`)
})
test('Changing chore override points updates child view in real time', async ({
page,
request,
}) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Verify original points displayed
await expect(card.getByText(`${CHORE_POINTS} Points`)).toBeVisible()
// Set override via API
await request.put(`/api/child/${childId}/override`, {
data: { entity_id: choreId, entity_type: 'task', custom_value: 25 },
})
// Without page reload, verify the card updates
await expect(card.getByText('25 Points')).toBeVisible()
})
test('Changing kindness override points updates child view', async ({ page, request }) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText(`${KIND_POINTS} Points`)).toBeVisible()
await request.put(`/api/child/${childId}/override`, {
data: { entity_id: kindId, entity_type: 'task', custom_value: 30 },
})
await expect(card.getByText('30 Points')).toBeVisible()
})
test('Changing penalty override points updates child view', async ({ page, request }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText(`${PEN_POINTS} Points`)).toBeVisible()
await request.put(`/api/child/${childId}/override`, {
data: { entity_id: penId, entity_type: 'task', custom_value: 20 },
})
await expect(card.getByText('20 Points')).toBeVisible()
})
test('Deleting an override reverts child view to original points', async ({ page, request }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Currently showing overridden value from earlier test
await expect(card.getByText('25 Points')).toBeVisible()
// Delete the override
await request.delete(`/api/child/${childId}/override/${choreId}`)
// Without page reload, verify the card reverts to original points
await expect(card.getByText(`${CHORE_POINTS} Points`)).toBeVisible()
})
})

View File

@@ -1,182 +0,0 @@
// spec: e2e/plans/task-modified.plan copy.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ModifyPenaltyChild'
const PEN_NAME = 'ModifyTestPenalty'
const PEN_HELPER_NAME = 'ModifyPenaltyHelper'
const PEN_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
return children?.find((c) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type } })
const list = await request.get('/api/task/list')
const { tasks } = (await list.json()) as { tasks: { name: string; id: string }[] }
return tasks?.find((t) => t.name === name)?.id ?? ''
}
function penaltySection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Penalties' }),
})
}
async function openEditModal(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
test.describe('Penalty edit points', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let penId = ''
let penHelperId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
penId = await createTask(request, PEN_NAME, 'penalty', PEN_POINTS)
penHelperId = await createTask(request, PEN_HELPER_NAME, 'penalty', PEN_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [penId, penHelperId], type: 'penalty' },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (penId) await request.delete(`/api/task/${penId}`)
if (penHelperId) await request.delete(`/api/task/${penHelperId}`)
})
test('Edit button appears on first click of a penalty card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
})
test('Edit button disappears when clicking a different penalty card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const section = penaltySection(page)
const card = section.locator('.item-card').filter({ hasText: PEN_NAME })
const helperCard = section.locator('.item-card').filter({ hasText: PEN_HELPER_NAME })
await card.waitFor({ state: 'visible' })
await helperCard.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
await helperCard.click()
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
})
test('Edit button opens modal with penalty name and "Assign new points" subtitle', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.locator('.modal-title')).toHaveText(PEN_NAME)
await expect(page.locator('.modal-subtitle')).toContainText('new points')
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Input is pre-populated with the raw point value (positive, not negated)', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
// The input holds the positive magnitude; the negative display is a UI convention
await expect(page.locator('#custom-value')).toHaveValue(String(PEN_POINTS))
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Cancel does not change the display', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('99')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Penalty displays points negated: -5 Points
await expect(card.locator('.item-points')).toContainText(`-${PEN_POINTS} Points`)
await expect(card.locator('.override-badge')).not.toBeVisible()
})
test('Saving a new value updates card to show negated override and ⭐ badge', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('12')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.locator('.item-points')).toContainText('-12 Points')
await expect(card.locator('.override-badge')).toBeVisible()
})
test('Negative value disables the Save button', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('-1')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = penaltySection(page).locator('.item-card').filter({ hasText: PEN_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('10001')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.locator('#custom-value').fill('10000')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
})

View File

@@ -1,221 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §10
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'PendingResetChild'
const CHORE_NAME = 'PendingResetChore'
const REWARD_NAME = 'PendingResetReward'
const CHORE_POINTS = 10
const REWARD_COST = 20
const INITIAL_CHILD_POINTS = 50
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', {
data: { name, description: 'E2E pending-reset test', cost },
})
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Pending status resets on task/reward modification', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
// Assign chore + reward
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
// Create interval schedule for chore (no deadline)
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
test('10.1 Editing chore points clears pending status', async ({ page, request }) => {
// Create pending via child confirm-chore API
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Verify PENDING stamp is visible
const stamp = card.locator('.chore-stamp')
await expect(stamp).toHaveText('PENDING')
// Edit the parent task's points via API (triggers SSE → clears pending)
await request.put(`/api/task/${choreId}/edit`, { data: { points: CHORE_POINTS + 1 } })
// Wait for SSE to propagate — PENDING should disappear
await expect(stamp).not.toBeVisible({ timeout: 5000 })
})
test('10.2 Changing chore schedule clears pending status', async ({ page, request }) => {
// Create pending again (10.1 cleared the previous one)
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const stamp = card.locator('.chore-stamp')
await expect(stamp).toHaveText('PENDING')
// Change the schedule via API (triggers SSE → clears pending)
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 2,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
await expect(stamp).not.toBeVisible({ timeout: 5000 })
})
test('10.5 Editing task name only does NOT clear pending', async ({ page, request }) => {
// Create pending again
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
const stamp = card.locator('.chore-stamp')
await expect(stamp).toHaveText('PENDING')
// Edit name only (no points/type change) — should NOT clear pending
await request.put(`/api/task/${choreId}/edit`, { data: { name: CHORE_NAME } })
// Wait briefly for SSE to propagate, then verify PENDING is still there
await page.waitForTimeout(2000)
await expect(stamp).toHaveText('PENDING')
// Clean up: cancel the pending confirmation for subsequent tests
await request.post(`/api/child/${childId}/cancel-confirm-chore`, {
data: { task_id: choreId },
})
})
test('10.4 Completed chores remain completed after point edit', async ({ page, request }) => {
// Directly trigger-task (parent approval) → creates approved confirmation for scheduled chore
await request.post(`/api/child/${childId}/trigger-task`, {
data: { task_id: choreId },
})
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
// Verify COMPLETED is visible
await expect(card.getByText('COMPLETED')).toBeVisible()
// Edit points — only status='pending' is cleared, not approved
await request.put(`/api/task/${choreId}/edit`, { data: { points: CHORE_POINTS + 2 } })
// Wait for SSE, then verify COMPLETED stamp is still visible
await page.waitForTimeout(2000)
await expect(card.getByText('COMPLETED')).toBeVisible()
})
test('10.3 Editing reward cost clears pending reward request', async ({ page, request }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
// Create pending reward request via API
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
// Wait for SSE → PENDING on reward (use .pending class to avoid matching reward name)
const pendingBadge = card.locator('.pending')
await expect(pendingBadge).toBeVisible({ timeout: 5000 })
// Edit reward cost via API → clears pending
await request.put(`/api/reward/${rewardId}/edit`, { data: { cost: REWARD_COST + 5 } })
// Wait for SSE — PENDING should disappear
await expect(pendingBadge).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,219 +0,0 @@
// spec: e2e/plans/task-modified.plan copy.md
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ModifyRewardChild'
const REWARD_NAME = 'ModifyTestReward'
const REWARD_COST = 100
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const { children } = (await list.json()) as { children: { name: string; id: string }[] }
return children?.find((c) => c.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'E2E test reward', cost } })
const list = await request.get('/api/reward/list')
const { rewards } = (await list.json()) as { rewards: { name: string; id: string }[] }
return rewards?.find((r) => r.name === name)?.id ?? ''
}
function rewardSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
async function openEditModal(page: Page, card: Locator): Promise<void> {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
test.describe('Reward edit cost', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
test('Edit button appears on first click of a reward card', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByTitle('Edit custom value')).not.toBeVisible()
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await expect(card.getByTitle('Edit custom value')).toBeVisible()
})
test('Edit button opens modal with reward name and "Assign new cost" subtitle', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
await expect(page.locator('.modal-title')).toHaveText(REWARD_NAME)
await expect(page.locator('.modal-subtitle')).toContainText('new cost')
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Input is pre-populated with the current cost', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await expect(page.locator('#custom-value')).toHaveValue(String(REWARD_COST))
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Cancel does not change the points needed display', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('999')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Child has 0 points; reward cost 100 → "100 more points"
await expect(card.getByText(`${REWARD_COST} more points`)).toBeVisible()
await expect(card.locator('.override-badge')).not.toBeVisible()
})
test('Saving a new cost updates the points needed and shows ⭐ badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('50')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
// Child still has 0 points; override cost 50 → "50 more points"
await expect(card.getByText('50 more points')).toBeVisible()
await expect(card.locator('.override-badge')).toBeVisible()
})
test('Setting cost to 0 makes the reward immediately redeemable', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('0')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
await expect(card.getByText('REWARD READY')).toBeVisible()
})
test('Negative value disables the Save button', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('-1')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Value above 10000 disables Save; exactly 10000 enables it', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await openEditModal(page, card)
await page.locator('#custom-value').fill('10001')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await page.locator('#custom-value').fill('10000')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Cancel' }).click()
})
test('Editing a pending reward shows a warning dialog — Cancel aborts the edit', async ({
page,
request,
}) => {
// Give child enough points to satisfy the original reward cost, then create a pending request
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('PENDING')).toBeVisible()
// First click → edit button visible
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
// PendingRewardDialog should appear warning about the pending state
await expect(page.locator('.modal-message')).toContainText('pending')
// Clicking "No" closes the dialog without opening the override modal
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.locator('.modal-message')).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
})
test('Editing a pending reward — confirming the warning opens the override modal', async ({
page,
}) => {
// Pending state was established in the previous test; navigate fresh
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('PENDING')).toBeVisible()
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.getByTitle('Edit custom value').click()
// PendingRewardDialog visible
await expect(page.locator('.modal-message')).toContainText('pending')
// Confirming cancels the pending request and opens the override modal
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 5000 })
await page.getByRole('button', { name: 'Cancel' }).click()
})
})

View File

@@ -1,165 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §6
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const CHILD_NAME = 'ScrollEditChild'
const CHORE_COUNT = 12
const REWARD_COUNT = 6
const CHORE_PREFIX = 'ScrollEditChore'
const REWARD_PREFIX = 'ScrollEditReward'
const NARROW_VIEWPORT = { width: 480, height: 800 }
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'scroll test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function isInViewport(locator: Locator): Promise<boolean> {
return locator.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth)
)
})
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Scroll to edited task after save', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
const choreIds: string[] = []
const rewardIds: string[] = []
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
for (let i = 1; i <= CHORE_COUNT; i++) {
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5 + i))
}
for (let i = 1; i <= REWARD_COUNT; i++) {
rewardIds.push(await createReward(request, `${REWARD_PREFIX}${i}`, 10 + i))
}
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: choreIds, type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: rewardIds },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of choreIds) await request.delete(`/api/task/${id}`)
for (const id of rewardIds) await request.delete(`/api/reward/${id}`)
})
test('Edited chore card scrolls into viewport after override save', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}`)
// Target the last chore — it should be scrolled off-screen in a narrow viewport
const targetName = `${CHORE_PREFIX}${CHORE_COUNT}`
const targetCard = choreSection(page).locator('.item-card').filter({ hasText: targetName })
await choreSection(page).locator('.item-card').first().waitFor({ state: 'visible' })
// Click the target chore to bring up the kebab menu (first click centers + shows menu)
await targetCard.click()
await expect(targetCard).toHaveClass(/item-ready/, { timeout: 3000 })
// Open the kebab menu → "Edit Points"
await targetCard.getByRole('button', { name: 'Options' }).click()
await page.getByText('Edit Points').click()
// Override modal should be visible with input
const input = page.locator('#custom-value')
await expect(input).toBeVisible()
// Set a new value and save
await input.fill('99')
await page.getByRole('button', { name: 'Save' }).click()
// After SSE override event → list refreshes → card scrolls into view
// Use expect.poll to retry, allowing scroll animation to complete
await expect(targetCard).toBeVisible({ timeout: 5000 })
await expect.poll(() => isInViewport(targetCard), { timeout: 3000 }).toBe(true)
})
test('Edited reward card scrolls into viewport after override save', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}`)
// Target the last reward
const targetName = `${REWARD_PREFIX}${REWARD_COUNT}`
const targetCard = rewardSection(page).locator('.item-card').filter({ hasText: targetName })
await rewardSection(page).locator('.item-card').first().waitFor({ state: 'visible' })
// Click the target reward to center it and show the edit button
await targetCard.click()
await expect(targetCard).toHaveClass(/item-ready/, { timeout: 3000 })
// Click the Edit button
await targetCard.locator('.edit-button').click()
// Override modal should be visible
const input = page.locator('#custom-value')
await expect(input).toBeVisible()
// Set a new value and save
await input.fill('77')
await page.getByRole('button', { name: 'Save' }).click()
// After SSE override event → list refreshes → card scrolls into view
// Use expect.poll to retry, allowing scroll animation to complete
await expect(targetCard).toBeVisible({ timeout: 5000 })
await expect.poll(() => isInViewport(targetCard), { timeout: 3000 }).toBe(true)
})
})

View File

@@ -1,240 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §8
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'ChildSortChild'
// Scheduled chores — two with different deadline times
const SCHED_EARLY = 'CSort1SchedEarly'
const SCHED_LATE = 'CSort2SchedLate'
// General chore (no schedule)
const GENERAL_CHORE = 'CSort3General'
// Pending chore (child confirmed)
const PENDING_CHORE = 'CSort4Pending'
// Non-today chore (should be hidden in child view)
const NON_TODAY_CHORE = 'CSortHiddenNonToday'
// Rewards with different costs
const PENDING_REWARD = 'CSortRewardPending'
const CHEAP_REWARD = 'CSortRewardCheap'
const EXPENSIVE_REWARD = 'CSortRewardExpensive'
const POINTS = 5
const INITIAL_CHILD_POINTS = 50
function todayDayIndex(): number {
return new Date().getDay()
}
function nonTodayDayIndex(): number {
return (new Date().getDay() + 1) % 7
}
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points: POINTS, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'sort test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Child mode sort order', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let schedEarlyId = ''
let schedLateId = ''
let generalChoreId = ''
let pendingChoreId = ''
let nonTodayChoreId = ''
let pendingRewardId = ''
let cheapRewardId = ''
let expensiveRewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
schedEarlyId = await createTask(request, SCHED_EARLY)
schedLateId = await createTask(request, SCHED_LATE)
generalChoreId = await createTask(request, GENERAL_CHORE)
pendingChoreId = await createTask(request, PENDING_CHORE)
nonTodayChoreId = await createTask(request, NON_TODAY_CHORE)
// Rewards: different costs so points_needed differs
pendingRewardId = await createReward(request, PENDING_REWARD, 10)
cheapRewardId = await createReward(request, CHEAP_REWARD, 15)
expensiveRewardId = await createReward(request, EXPENSIVE_REWARD, 100)
const allChoreIds = [schedEarlyId, schedLateId, generalChoreId, pendingChoreId, nonTodayChoreId]
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: allChoreIds, type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [pendingRewardId, cheapRewardId, expensiveRewardId] },
})
// Scheduled early: today with deadline at 23:00 (future deadline, low sort key)
await request.put(`/api/child/${childId}/task/${schedEarlyId}/schedule`, {
data: {
mode: 'days',
day_configs: [{ day: todayDayIndex(), hour: 23, minute: 0 }],
default_hour: 23,
default_minute: 0,
default_has_deadline: true,
enabled: true,
},
})
// Scheduled late: today with deadline at 23:59 (future deadline, higher sort key)
await request.put(`/api/child/${childId}/task/${schedLateId}/schedule`, {
data: {
mode: 'days',
day_configs: [{ day: todayDayIndex(), hour: 23, minute: 59 }],
default_hour: 23,
default_minute: 59,
default_has_deadline: true,
enabled: true,
},
})
// Non-today chore: scheduled for a different day only
await request.put(`/api/child/${childId}/task/${nonTodayChoreId}/schedule`, {
data: {
mode: 'days',
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
default_hour: 8,
default_minute: 0,
default_has_deadline: true,
enabled: true,
},
})
// Pending chore: child confirms
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: pendingChoreId },
})
// Pending reward: request it
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: pendingRewardId },
})
// General chore: no schedule needed
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of [schedEarlyId, schedLateId, generalChoreId, pendingChoreId, nonTodayChoreId]) {
if (id) await request.delete(`/api/task/${id}`)
}
for (const id of [pendingRewardId, cheapRewardId, expensiveRewardId]) {
if (id) await request.delete(`/api/reward/${id}`)
}
})
test('Child chore sort: scheduled today (earliest deadline) → general → pending', async ({
page,
}) => {
// Clear parent auth so the router allows /child routes
await page.addInitScript(() => localStorage.removeItem('parentAuth'))
await page.goto(`/child/${childId}`)
const section = choreSection(page)
await section.locator('.item-card').first().waitFor({ state: 'visible' })
const names = await section.locator('.item-card .item-name').allTextContents()
// Scheduled for today (priority 0) sorted by earliest deadline → earliest first
const earlyIdx = names.indexOf(SCHED_EARLY)
const lateIdx = names.indexOf(SCHED_LATE)
// General (priority 1)
const generalIdx = names.indexOf(GENERAL_CHORE)
// Pending (priority 2)
const pendingIdx = names.indexOf(PENDING_CHORE)
expect(earlyIdx).toBeGreaterThanOrEqual(0)
expect(lateIdx).toBeGreaterThanOrEqual(0)
expect(generalIdx).toBeGreaterThanOrEqual(0)
expect(pendingIdx).toBeGreaterThanOrEqual(0)
// Scheduled → earliest first
expect(earlyIdx).toBeLessThan(lateIdx)
// Scheduled before general
expect(lateIdx).toBeLessThan(generalIdx)
// General before pending
expect(generalIdx).toBeLessThan(pendingIdx)
// Non-today chore should be hidden
expect(names).not.toContain(NON_TODAY_CHORE)
})
test('Child reward sort: pending first, then ascending by points needed', async ({ page }) => {
// Clear parent auth so the router allows /child routes
await page.addInitScript(() => localStorage.removeItem('parentAuth'))
await page.goto(`/child/${childId}`)
const section = rewardSection(page)
await section.locator('.item-card').first().waitFor({ state: 'visible' })
const names = await section.locator('.item-card .item-name').allTextContents()
const pendingIdx = names.indexOf(PENDING_REWARD)
const cheapIdx = names.indexOf(CHEAP_REWARD)
const expensiveIdx = names.indexOf(EXPENSIVE_REWARD)
expect(pendingIdx).toBeGreaterThanOrEqual(0)
expect(cheapIdx).toBeGreaterThanOrEqual(0)
expect(expensiveIdx).toBeGreaterThanOrEqual(0)
// Pending first
expect(pendingIdx).toBeLessThan(cheapIdx)
expect(pendingIdx).toBeLessThan(expensiveIdx)
// Then ascending by points_needed (cheap reward costs 15, expensive 100; child has 50 points)
// Points needed: cheap = max(0, 15-50) = 0; expensive = max(0, 100-50) = 50
// So cheap should come before expensive
expect(cheapIdx).toBeLessThan(expensiveIdx)
})
})

View File

@@ -1,224 +0,0 @@
// spec: e2e/plans/bugs-1.0.6-set01.plan.md §7
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'ParentSortChild'
const PENDING_CHORE = 'Sort1Pending'
const GENERAL_CHORE = 'Sort2General'
const EXPIRED_CHORE = 'Sort3Expired'
const COMPLETED_CHORE = 'Sort4Completed'
const NON_TODAY_CHORE = 'Sort5NonToday'
const PENDING_REWARD = 'SortRewardPending'
const NORMAL_REWARD = 'SortRewardNormal'
const POINTS = 5
const REWARD_COST = 10
const INITIAL_CHILD_POINTS = 50
function todayDayIndex(): number {
return new Date().getDay()
}
function nonTodayDayIndex(): number {
return (new Date().getDay() + 1) % 7
}
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points: POINTS, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'sort test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Parent mode sort order', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let pendingChoreId = ''
let generalChoreId = ''
let expiredChoreId = ''
let completedChoreId = ''
let nonTodayChoreId = ''
let pendingRewardId = ''
let normalRewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_CHILD_POINTS)
pendingChoreId = await createTask(request, PENDING_CHORE)
generalChoreId = await createTask(request, GENERAL_CHORE)
expiredChoreId = await createTask(request, EXPIRED_CHORE)
completedChoreId = await createTask(request, COMPLETED_CHORE)
nonTodayChoreId = await createTask(request, NON_TODAY_CHORE)
pendingRewardId = await createReward(request, PENDING_REWARD, REWARD_COST)
normalRewardId = await createReward(request, NORMAL_REWARD, REWARD_COST)
const allChoreIds = [
pendingChoreId,
generalChoreId,
expiredChoreId,
completedChoreId,
nonTodayChoreId,
]
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: allChoreIds, type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [pendingRewardId, normalRewardId] },
})
// Expired chore: scheduled today with deadline already passed (00:01)
await request.put(`/api/child/${childId}/task/${expiredChoreId}/schedule`, {
data: {
mode: 'days',
day_configs: [{ day: todayDayIndex(), hour: 0, minute: 1 }],
default_hour: 0,
default_minute: 1,
default_has_deadline: true,
enabled: true,
},
})
// Completed chore: scheduled today (interval, no deadline), then trigger to approve
await request.put(`/api/child/${childId}/task/${completedChoreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
await request.post(`/api/child/${childId}/trigger-task`, {
data: { task_id: completedChoreId },
})
// Non-today chore: scheduled for a different day only
await request.put(`/api/child/${childId}/task/${nonTodayChoreId}/schedule`, {
data: {
mode: 'days',
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
default_hour: 8,
default_minute: 0,
default_has_deadline: true,
enabled: true,
},
})
// Pending chore: child confirms → creates pending status
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: pendingChoreId },
})
// Pending reward: request it
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: pendingRewardId },
})
// General chore: no schedule, no changes needed
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of [
pendingChoreId,
generalChoreId,
expiredChoreId,
completedChoreId,
nonTodayChoreId,
]) {
if (id) await request.delete(`/api/task/${id}`)
}
for (const id of [pendingRewardId, normalRewardId]) {
if (id) await request.delete(`/api/reward/${id}`)
}
})
test('Parent chore sort: pending → general → expired → completed → unscheduled', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const section = choreSection(page)
await section.locator('.item-card').first().waitFor({ state: 'visible' })
const names = await section.locator('.item-card .item-name').allTextContents()
// Expected order: pending(0) → general(1) → expired(2) → completed(3) → non-today(4)
const pendingIdx = names.indexOf(PENDING_CHORE)
const generalIdx = names.indexOf(GENERAL_CHORE)
const expiredIdx = names.indexOf(EXPIRED_CHORE)
const completedIdx = names.indexOf(COMPLETED_CHORE)
const nonTodayIdx = names.indexOf(NON_TODAY_CHORE)
expect(pendingIdx).toBeGreaterThanOrEqual(0)
expect(generalIdx).toBeGreaterThanOrEqual(0)
expect(expiredIdx).toBeGreaterThanOrEqual(0)
expect(completedIdx).toBeGreaterThanOrEqual(0)
expect(nonTodayIdx).toBeGreaterThanOrEqual(0)
expect(pendingIdx).toBeLessThan(generalIdx)
expect(generalIdx).toBeLessThan(expiredIdx)
expect(expiredIdx).toBeLessThan(completedIdx)
expect(completedIdx).toBeLessThan(nonTodayIdx)
})
test('Parent reward sort: pending requests appear first', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const section = rewardSection(page)
await section.locator('.item-card').first().waitFor({ state: 'visible' })
const names = await section.locator('.item-card .item-name').allTextContents()
const pendingIdx = names.indexOf(PENDING_REWARD)
const normalIdx = names.indexOf(NORMAL_REWARD)
expect(pendingIdx).toBeGreaterThanOrEqual(0)
expect(normalIdx).toBeGreaterThanOrEqual(0)
expect(pendingIdx).toBeLessThan(normalIdx)
})
})

View File

@@ -1,75 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Chore creation/edit cancellation', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test('Cancel chore creation', async ({ page }) => {
// 1. From /parent/tasks/chores, start creating a chore
await page.goto('/parent/tasks/chores')
await page.getByRole('button', { name: 'Create Chore' }).click()
// expect: Create Chore form appears
await expect(page.locator('text=Chore Name')).toBeVisible()
// 2. Fill in 'Test' name and '5' points then cancel
await page.evaluate(() => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Test')
setVal('#points', '5')
})
await page.getByRole('button', { name: 'Cancel' }).click()
// expect: User is returned to the chores list (exact match to avoid custom copy)
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
// expect: No chore named 'Test' exists
await expect(page.locator('text=Test')).toHaveCount(0)
})
test('Cancel chore edit', async ({ page }) => {
// 1. Locate a chore and open its edit form
await page.goto('/parent/tasks/chores')
// ensure item exists
if (!(await page.getByText('Wash dishes', { exact: true }).count())) {
await page.getByRole('button', { name: 'Create Chore' }).click()
await page.evaluate(() => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Wash dishes')
setVal('#points', '10')
})
await page.getByRole('button', { name: 'Create' }).click()
}
await page.getByText('Wash dishes', { exact: true }).click()
// 2. Modify the name then cancel
await page.evaluate(() => {
const el = document.querySelector('#name')
if (el) {
el.value = 'Should not save'
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
})
await page.getByRole('button', { name: 'Cancel' }).click()
// expect: Navigation returns to list (exact match)
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
// expect: Original values remain unchanged
await expect(page.getByText('Wash dishes', { exact: true })).toBeVisible()
})
})

View File

@@ -1,43 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test('Convert a default chore to a user item by editing', async ({ page }) => {
// 1. Locate a default chore such as 'Clean your mess' in the chores list
await page.goto('/parent/tasks/chores')
await expect(page.locator('text=Clean your mess')).toBeVisible()
// expect: Item is visible and has no delete button
await expect(
page.locator('text=Clean your mess >> .. >> button[aria-label="Delete item"]'),
).toHaveCount(0)
// 2. Click the default chore itself to edit
await page.click('text=Clean your mess')
// expect: Edit form opens with the default values
await expect(page.locator('input#name').inputValue()).resolves.toBe('Clean your mess')
// 3. Change some properties (name and points) so Save becomes enabled
await page.evaluate(() => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Clean your mess (custom)')
setVal('#points', '20')
})
// expect: Save button is enabled because form is dirty
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
// 4. Save the update
await page.click('button:has-text("Save")')
// expect: The chore now shows as editable and includes a delete option
await expect(
page.locator('text=Clean your mess >> .. >> button[aria-label="Delete item"]'),
).toBeVisible()
// expect: Item behaves like a custom chore
})

View File

@@ -1,141 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Chore management', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test.describe.configure({ mode: 'serial' })
test('Create a new chore (parent mode)', async ({ page }) => {
const suffix = Date.now()
const name = `Wash dishes ${suffix}`
// 1. Navigate to /parent/tasks/chores
await page.goto('/parent/tasks/chores')
// expect: The parent dashboard loads and shows the chores list
await expect(page.getByText('Clean your mess', { exact: true })).toBeVisible()
// 2. Click the 'Create Chore' FAB
await page.getByRole('button', { name: 'Create Chore' }).click()
// expect: The Create Chore form is displayed with fields for name and points
await expect(page.locator('text=Chore Name')).toBeVisible()
// 3. Enter the chore name and points using DOM events
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '10')
}, name)
// expect: Submit/Create button becomes enabled
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
// 5. Click the Create button
await page.click('button:has-text("Create")')
// expect: No validation errors are shown
await expect(page.locator('.error')).toHaveCount(0)
// expect: Navigation returns to /parent/chores (or dashboard)
await expect(page).toHaveURL(/\/parent/)
// expect: The new chore appears in the chores list
await expect(page.locator(`text=${name}`)).toBeVisible()
})
test('Edit an existing chore', async ({ page }) => {
const suffix = Date.now()
const original = `Wash dishes ${suffix}`
const updated = `Wash car ${suffix}`
// 1. Ensure there is at least one chore in the list (create one if necessary)
await page.goto('/parent/tasks/chores')
if (!(await page.locator(`text=${original}`).count())) {
await page.getByRole('button', { name: 'Create Chore' }).click()
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '10')
}, original)
await page.getByRole('button', { name: 'Create' }).click()
}
// expect: The chore appears in the list
await expect(page.locator(`text=${original}`)).toBeVisible()
// 2. Click the chore row to edit
await page.click(`text=${original}`)
// expect: Edit Chore form appears (loaded with current values)
await expect(page.locator('text=Chore Name')).toBeVisible()
// 3. Change the Name to 'Wash car' and Points to '15' via DOM events
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '15')
}, updated)
// expect: Fields are updated with the new values
// 4. Click Save/Update
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.click('button:has-text("Save")')
// expect: No errors are displayed
await expect(page.locator('.error')).toHaveCount(0)
// expect: Navigation returns to chores list
await expect(page).toHaveURL(/\/parent/)
// expect: The chore now reads updated name with 15 points
await expect(page.locator(`text=${updated}`)).toBeVisible()
})
test('Delete a chore', async ({ page }) => {
const suffix = Date.now()
const name = `Wash car ${suffix}`
await page.goto('/parent/tasks/chores')
await page.getByRole('button', { name: 'Create Chore' }).click()
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '15')
}, name)
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${name}`)).toBeVisible()
// delete using row-local button then confirm via modal
await page
.locator(`text=${name}`)
.first()
.locator('..')
.getByRole('button', { name: 'Delete' })
.click()
// modal appears with a warning message
await expect(page.locator('text=Are you sure you want to delete')).toBeVisible()
// click the red danger button inside the modal (labelled Delete)
await page.locator('button.btn-danger:has-text("Delete")').click()
await expect(page.locator(`text=${name}`)).toHaveCount(0)
})
})

View File

@@ -1,92 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test('Edit default chore "Take out trash" and verify system chore restoration on delete', async ({
page,
}) => {
await page.goto('/parent/tasks/chores')
// Cleanup: if a previous run left a modified 'Take out trash' (with delete icon), remove it first
while (
(await page
.getByText('Take out trash', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.count()) > 0
) {
await page
.getByText('Take out trash', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.first()
.click()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
}
// 1. Verify 'Take out trash' is the system default (visible, no delete icon)
await expect(page.getByText('Take out trash', { exact: true })).toBeVisible()
await expect(
page
.getByText('Take out trash', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// 2. Click 'Take out trash' to open the edit form and change points
await page.getByText('Take out trash', { exact: true }).click()
await expect(page.locator('input#name').inputValue()).resolves.toBe('Take out trash')
await page.evaluate(() => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#points', '5')
})
// expect: Save button becomes enabled because the form is dirty
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
// 3. Verify exactly one 'Take out trash' is in the list and it now has a delete icon
await expect(page.getByText('Take out trash', { exact: true })).toHaveCount(1)
await expect(
page
.getByText('Take out trash', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toBeVisible()
// expect: points display reflects the updated value
await expect(
page.getByText('Take out trash', { exact: true }).locator('..').locator('.value'),
).toHaveText('5 pts')
// 4. Delete the modified 'Take out trash' and verify the system default is restored
await page
.getByText('Take out trash', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.click()
await expect(page.locator('text=Are you sure')).toBeVisible()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
// expect: 'Take out trash' is still on the list (system default restored)
await expect(page.getByText('Take out trash', { exact: true })).toBeVisible()
// expect: no delete icon (it's a system default again)
await expect(
page
.getByText('Take out trash', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// expect: original points (20 pts) are restored
await expect(
page.getByText('Take out trash', { exact: true }).locator('..').locator('.value'),
).toHaveText('20 pts')
})

View File

@@ -1,63 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Kindness act cancellation', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test('Cancel kindness act creation', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
await page.evaluate(() => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Test')
setVal('#points', '5')
})
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.locator('text=Should not save')).toHaveCount(0)
})
test('Cancel kindness act edit', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
if (!(await page.getByText('Share toys', { exact: true }).count())) {
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
await page.evaluate(() => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Share toys')
setVal('#points', '5')
})
await page.getByRole('button', { name: 'Create' }).click()
}
// open edit by clicking row
await page.getByText('Share toys', { exact: true }).click()
await page.evaluate(() => {
const el = document.querySelector('#name')
if (el) {
el.value = 'Never saved'
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
})
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByText('Share toys', { exact: true })).toBeVisible()
})
})

View File

@@ -1,136 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Kindness act management', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
// avoid parallel execution within this file; shared backend state and SSE events
test.describe.configure({ mode: 'serial' })
test('Create a new kindness act (parent mode)', async ({ page }) => {
// use a unique name so repeated runs don't collide
const suffix = Date.now()
const name = `Share toys ${suffix}`
// 1. navigate once and switch to kindness tab
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
await expect(page.locator('text=Kindness Acts')).toBeVisible()
// 2. open form
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
await expect(page.locator('text=Name')).toBeVisible()
// 34. fill using evaluate helper
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '5')
}, name)
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
// 5. submit and assert the new row is visible
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator('.error')).toHaveCount(0)
await expect(page.locator(`text=${name}`).first()).toBeVisible()
})
test('Edit an existing kindness act', async ({ page }) => {
const suffix = Date.now()
const original = `Share toys ${suffix}`
const updated = `Help with homework ${suffix}`
// navigate and create fresh item
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '5')
}, original)
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${original}`).first()).toBeVisible()
// 2. open edit by clicking first matching row
await page.locator(`text=${original}`).first().click()
// wait for edit form to appear
await expect(page.locator('text=Name')).toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
// 3. update values once form is ready
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '8')
}, updated)
// 4. save and verify (wait until button is enabled)
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.locator(`text=${updated}`).first()).toBeVisible()
})
test('Delete a kindness act', async ({ page }) => {
const suffix = Date.now()
const name = `Help with homework ${suffix}`
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
// create fresh item
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
await page.evaluate((name) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', name)
setVal('#points', '8')
}, name)
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${name}`).first()).toBeVisible()
// click the delete button on the first matching row
await page
.locator(`text=${name}`)
.first()
.locator('..')
.getByRole('button', { name: 'Delete' })
.click()
// confirmation modal should appear
await expect(
page.locator('text=Are you sure you want to delete this kindness act?'),
).toBeVisible()
// click the red danger button inside the modal
await page.locator('button.btn-danger:has-text("Delete")').click()
await expect(page.locator(`text=${name}`)).toHaveCount(0)
})
})

View File

@@ -1,134 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// Merged from kindness-convert-default.spec.ts and kindness-delete-default.spec.ts.
// Both tests touch "Be good for the day" so they MUST run serially.
import { test, expect, type APIRequestContext } from '@playwright/test'
const BACKEND = 'http://localhost:5000'
async function cleanupBeGoodTasks(request: APIRequestContext): Promise<void> {
const res = await request.get(`${BACKEND}/task/list`)
if (!res.ok()) return
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
await res.json()
for (const task of tasks) {
if (
task.user_id !== null &&
(task.name === 'Be good today (edited)' || task.name === 'Be good for the day')
) {
await request.delete(`${BACKEND}/task/${task.id}`)
}
}
}
test.describe('Default kindness act management', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ request }) => {
await cleanupBeGoodTasks(request)
})
test('Convert a default kindness act to a user item by editing', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
// find a default act
await expect(page.locator('text=Be good for the day')).toBeVisible()
await expect(
page.locator('text=Be good for the day >> .. >> button[aria-label="Delete item"]'),
).toHaveCount(0)
// edit it — rename so there is no name collision with the delete-default test
await page.click('text=Be good for the day')
await page.locator('#name').fill('Be good today (edited)')
await page.locator('#points').fill('7')
await page.getByRole('button', { name: 'Save' }).click()
// renamed item should now be deletable
await expect(
page
.getByText('Be good today (edited)', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toBeVisible()
// clean up: delete the user item so the next test sees "Be good for the day" as a system default
await page
.getByText('Be good today (edited)', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.click()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
})
test('Edit default kindness act "Be good for the day" and verify system act restoration on delete', async ({
page,
}) => {
await page.goto('/parent/tasks/chores')
await page.getByText('Kindness Acts').click()
// 1. Verify 'Be good for the day' is the system default (visible, no delete icon)
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
await expect(
page
.getByText('Be good for the day', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// 2. Click 'Be good for the day' to open the edit form and change points
await page.getByText('Be good for the day', { exact: true }).click()
await expect(page.locator('input#name').inputValue()).resolves.toBe('Be good for the day')
await page.evaluate(() => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#points', '3')
})
// expect: Save button becomes enabled because the form is dirty
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
// 3. Verify exactly one 'Be good for the day' is in the list and it now has a delete icon
await expect(page.getByText('Be good for the day', { exact: true })).toHaveCount(1)
await expect(
page
.getByText('Be good for the day', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toBeVisible()
// expect: points display reflects the updated value
await expect(
page.getByText('Be good for the day', { exact: true }).locator('..').locator('.value'),
).toHaveText('3 pts')
// 4. Delete the modified 'Be good for the day' and verify the system default is restored
await page
.getByText('Be good for the day', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.click()
await expect(page.locator('text=Are you sure')).toBeVisible()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
// expect: 'Be good for the day' is still on the list (system default restored)
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
// expect: no delete icon (it's a system default again)
await expect(
page
.getByText('Be good for the day', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// expect: original points (15 pts) are restored
await expect(
page.getByText('Be good for the day', { exact: true }).locator('..').locator('.value'),
).toHaveText('15 pts')
})
})

View File

@@ -1,55 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Penalty creation/edit cancellation', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test('Cancel penalty creation', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
await page.getByRole('button', { name: 'Create Penalty' }).click()
await page.evaluate(() => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', 'Should not save')
setVal('#points', '10')
})
// cancel before submitting the form
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.locator('text=Should not save')).toHaveCount(0)
})
test('Cancel penalty edit', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
if (!(await page.getByText('No screen time', { exact: true }).count())) {
await page.getByRole('button', { name: 'Create Penalty' }).click()
await page.locator('#name').fill('No screen time')
await page.locator('#points').fill('10')
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText('No screen time', { exact: true })).toBeVisible()
}
// open edit by clicking row
await page.getByText('No screen time', { exact: true }).click()
await page.evaluate(() => {
const el = document.querySelector('#name')
if (el) {
el.value = 'Never saved'
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
})
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByText('No screen time', { exact: true })).toBeVisible()
})
})

View File

@@ -1,114 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// seed: e2e/seed.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Penalty management', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test('Create a new penalty (parent mode)', async ({ page }) => {
const name = `No screen time ${Date.now()}`
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
await expect(page.locator('text=Penalties')).toBeVisible()
await page.getByRole('button', { name: 'Create Penalty' }).click()
await page.evaluate((n) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', n)
setVal('#points', '30')
}, name)
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${name}`)).toBeVisible()
})
test('Edit an existing penalty', async ({ page }) => {
const suffix = Date.now()
const original = `No screen time ${suffix}`
const updated = `No dessert ${suffix}`
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
// create the item to edit
await page.getByRole('button', { name: 'Create Penalty' }).click()
await page.evaluate((n) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', n)
setVal('#points', '30')
}, original)
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${original}`)).toBeVisible()
// open edit by clicking the row itself
await page.click(`text=${original}`)
// wait for the edit form to finish loading data from the API
await expect(page.locator('#name')).toHaveValue(original)
await page.evaluate((n) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', n)
setVal('#points', '20')
}, updated)
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.locator(`text=${updated}`)).toBeVisible()
})
test('Delete a penalty', async ({ page }) => {
const name = `No dessert ${Date.now()}`
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
await page.getByRole('button', { name: 'Create Penalty' }).click()
await page.evaluate((n) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel)
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#name', n)
setVal('#points', '20')
}, name)
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator(`text=${name}`)).toBeVisible()
await page
.locator(`text=${name}`)
.locator('..')
.getByRole('button', { name: 'Delete item' })
.click()
await page.locator('button.btn-danger:has-text("Delete")').click()
await expect(page.locator(`text=${name}`)).toHaveCount(0)
})
})

View File

@@ -1,136 +0,0 @@
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
// Merged from penalty-convert-default.spec.ts and penalty-delete-default.spec.ts.
// Both tests touch "Fighting" so they MUST run serially.
import { test, expect, type APIRequestContext } from '@playwright/test'
const BACKEND = 'http://localhost:5000'
async function cleanupFightingTasks(request: APIRequestContext): Promise<void> {
const res = await request.get(`${BACKEND}/task/list`)
if (!res.ok()) return
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
await res.json()
for (const task of tasks) {
if (task.user_id !== null && (task.name === 'Fighting (custom)' || task.name === 'Fighting')) {
await request.delete(`${BACKEND}/task/${task.id}`)
}
}
}
test.describe('Default penalty management', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ request }) => {
await cleanupFightingTasks(request)
})
test('Convert a default penalty to a user item by editing', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Penalties')
// locate default penalty
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
await expect(
page
.getByText('Fighting', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// edit it (click the item itself)
await page.getByText('Fighting', { exact: true }).click()
await page.locator('#name').fill('Fighting (custom)')
await page.locator('#points').fill('15')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
// now should have delete option
await expect(
page
.getByText('Fighting (custom)', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toBeVisible()
// clean up: delete so the next test sees 'Fighting' as a system default again
await page
.getByText('Fighting (custom)', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.click()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
})
test('Edit default penalty "Fighting" and verify system penalty restoration on delete', async ({
page,
}) => {
await page.goto('/parent/tasks/chores')
await page.getByText('Penalties').click()
// 1. Verify 'Fighting' is the system default (visible, no delete icon)
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
await expect(
page
.getByText('Fighting', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// 2. Click 'Fighting' to open the edit form and change points
await page.getByText('Fighting', { exact: true }).click()
await expect(page.locator('input#name').inputValue()).resolves.toBe('Fighting')
await page.evaluate(() => {
const setVal = (sel: string, val: string) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (el) {
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}
}
setVal('#points', '3')
})
// expect: Save button becomes enabled because the form is dirty
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
// 3. Verify exactly one 'Fighting' is in the list and it now has a delete icon
await expect(page.getByText('Fighting', { exact: true })).toHaveCount(1)
await expect(
page
.getByText('Fighting', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toBeVisible()
// expect: points display reflects the updated value
await expect(
page.getByText('Fighting', { exact: true }).locator('..').locator('.value'),
).toHaveText('3 pts')
// 4. Delete the modified 'Fighting' and verify the system default is restored
await page
.getByText('Fighting', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]')
.click()
await expect(page.locator('text=Are you sure')).toBeVisible()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
// expect: 'Fighting' is still on the list (system default restored)
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
// expect: no delete icon (it's a system default again)
await expect(
page
.getByText('Fighting', { exact: true })
.locator('..')
.locator('button[aria-label="Delete item"]'),
).toHaveCount(0)
// expect: original points (10 pts) are restored
await expect(
page.getByText('Fighting', { exact: true }).locator('..').locator('.value'),
).toHaveText('10 pts')
})
})

View File

@@ -1,51 +0,0 @@
import { test, expect } from '@playwright/test'
test('Simple chore creation test', async ({ page }) => {
// Navigate to chores
await page.goto('/parent/tasks/chores')
// Click the Create Chore FAB
await page.getByRole('button', { name: 'Create Chore' }).click()
// Wait for form to load
await expect(page.locator('text=Chore Name')).toBeVisible()
// Fill form using custom evaluation
await page.evaluate(() => {
// Fill name
const nameInput = document.querySelector('input[type="text"], input:not([type])')
if (nameInput) {
nameInput.value = 'Simple Chore Test'
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
nameInput.dispatchEvent(new Event('change', { bubbles: true }))
}
// Fill points
const pointsInput = document.querySelector('input[type="number"]')
if (pointsInput) {
pointsInput.value = '5'
pointsInput.dispatchEvent(new Event('input', { bubbles: true }))
pointsInput.dispatchEvent(new Event('change', { bubbles: true }))
}
// Click first image to select it
const firstImage = document.querySelector('img[alt*="Image"]')
if (firstImage) {
firstImage.click()
}
})
// Wait a moment for validation to trigger
await page.waitForTimeout(500)
// Click Create button
await page.getByRole('button', { name: 'Create' }).click()
// Verify we're back on the list page and item was created
// locate the name element, then move up to the row container
const choreName = page.locator('text=Simple Chore Test').first()
const choreRow = choreName.locator('..')
await expect(choreRow).toBeVisible()
// the row container should display the correct points value
await expect(choreRow.locator('text=5 pts')).toBeVisible()
})

View File

@@ -1,181 +0,0 @@
// 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()
})
})

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