Add routine management features for child and parent views
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s

- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion.
- Created notification tests for parent view to verify routine completion notifications for children.
- Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions.
- Added RoutineApproveDialog component for approving or rejecting completed routines.
- Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering.
- Enhanced RoutineEditView with proper handling of task addition and form submission.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-17 23:47:12 -04:00
parent eb775ba7d8
commit 5392e5af70
31 changed files with 3859 additions and 265 deletions

View File

@@ -0,0 +1,213 @@
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'RoutineChildModeChild'
const ROUTINE_NAME = 'RoutineChildModeTest'
const ROUTINE_POINTS = 10
const TASK_NAME_1 = 'Brush Teeth'
const TASK_NAME_2 = 'Wash Face'
// ── 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 createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points } })
return (await res.json()).routine?.id ?? ''
}
function routineSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Routines' }),
})
}
// ── suite ─────────────────────────────────────────────────────────────────────
test.describe('Routine child-mode flow', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let routineId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: TASK_NAME_1 } })
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: TASK_NAME_2 } })
await request.post(`/api/child/${childId}/assign-routine`, {
data: { routine_id: routineId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
// Intercept parentAuth so the router treats this session as child-mode (not parent-authenticated).
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const orig = Storage.prototype.getItem
Storage.prototype.getItem = function (key: string) {
if (key === 'parentAuth') return null
return orig.call(this, key)
}
})
})
// ── 1. Routine section visible ────────────────────────────────────────────
test('Routine section appears in child view', async ({ page }) => {
await page.goto(`/child/${childId}`)
const section = routineSection(page)
await expect(section).toBeVisible({ timeout: 5000 })
await expect(section.locator('.item-card').filter({ hasText: ROUTINE_NAME })).toBeVisible()
})
// ── 2. Overlay opens on two-click ─────────────────────────────────────────
test('Clicking routine twice opens overlay with correct header', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
// First click enters ready state
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
// Second click opens overlay
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog).toContainText('Did you complete')
await expect(dialog).toContainText(ROUTINE_NAME)
})
// ── 3. Task cards inside overlay ──────────────────────────────────────────
test('Overlay shows task cards for each routine item', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog.getByText(TASK_NAME_1)).toBeVisible()
await expect(dialog.getByText(TASK_NAME_2)).toBeVisible()
})
// ── 4. Closing overlay with No ────────────────────────────────────────────
test('Clicking No closes the overlay without changing state', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await dialog.getByRole('button', { name: 'No' }).click()
await expect(dialog).not.toBeVisible({ timeout: 3000 })
await expect(card.locator('.pending-stamp')).not.toBeVisible()
})
// ── 5. Yes! confirms routine → PENDING stamp ──────────────────────────────
test('Clicking Yes! marks routine as PENDING', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await dialog.getByRole('button', { name: 'Yes!' }).click()
// Dialog closes
await expect(dialog).not.toBeVisible({ timeout: 5000 })
// Reload to pick up the refreshed state
await page.reload()
const reloadedCard = routineSection(page)
.locator('.item-card')
.filter({ hasText: ROUTINE_NAME })
await expect(reloadedCard.locator('.pending-stamp')).toBeVisible({ timeout: 5000 })
})
// ── 6. Cancel dialog for pending routine ─────────────────────────────────
test('Clicking a pending routine shows the cancel dialog', async ({ page }) => {
await page.goto(`/child/${childId}`)
// Routine should still be pending from the previous test
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.locator('.pending-stamp')).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog).toContainText('This routine is pending')
})
// ── 7. Canceling pending routine removes PENDING stamp ────────────────────
test('Confirming cancel removes the PENDING stamp', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog).toContainText('This routine is pending')
// "Yes" cancels the pending confirmation
await dialog.getByRole('button', { name: 'Yes' }).click()
await expect(dialog).not.toBeVisible({ timeout: 5000 })
await page.reload()
const reloadedCard = routineSection(page)
.locator('.item-card')
.filter({ hasText: ROUTINE_NAME })
await expect(reloadedCard.locator('.pending-stamp')).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -0,0 +1,98 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'RoutineNotifChild'
const ROUTINE_NAME = 'RoutineNotifTest'
const ROUTINE_POINTS = 8
// ── 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 createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points } })
return (await res.json()).routine?.id ?? ''
}
// ── suite ─────────────────────────────────────────────────────────────────────
test.describe('Routine parent notification', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let routineId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
await request.post(`/api/child/${childId}/assign-routine`, {
data: { routine_id: routineId },
})
// Simulate the child confirming the routine
await request.post(`/api/child/${childId}/confirm-routine`, {
data: { routine_id: routineId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
// ── 1. Notification appears ───────────────────────────────────────────────
test('Confirmed routine appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
})
// ── 2. Notification shows "completed" ────────────────────────────────────
test('Notification shows "completed" for routine', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText('completed')
})
// ── 3. Notification shows child name ─────────────────────────────────────
test('Notification shows the child name', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText(CHILD_NAME)
})
// ── 4. Clicking notification navigates to parent view ────────────────────
test('Clicking notification navigates to parent view for the child', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(`/parent/${childId}**`, { timeout: 5000 })
const url = new URL(page.url())
expect(url.searchParams.get('scrollTo')).toBe(routineId)
expect(url.searchParams.get('entityType')).toBe('routine')
})
})