diff --git a/frontend/vue-app/e2e/.auth/user-cc.json b/frontend/vue-app/e2e/.auth/user-cc.json index d1644de..e70c63c 100644 --- a/frontend/vue-app/e2e/.auth/user-cc.json +++ b/frontend/vue-app/e2e/.auth/user-cc.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "8p4HLWbALh9Qwg9Znl1q8Z0Ohteik28-6H8Z5qWnaZo", + "value": "oQxjeMmAW3v4aoI_hBdjpMXThVDzKZTG_6bQGPJBokc", "domain": "localhost", "path": "/api/auth", - "expires": 1782241430.50454, + "expires": 1782271329.747941, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJiZWM5NTZiMi01MTZlLTQzZmQtYWUzYS1iZjNjN2RmNzA4M2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMzB9.5TdKuDgcuTmwvA7lQ4VOKXMwkviRNn24utyuWqD8IPA", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIyYjQ1MmRiNi1lYTRmLTQyMDItODIzYi0zNjllN2E3YTQ5MjYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0OTYyMjl9.docokOZYHgPtNNqirbfLSRABQHHsmTj-9_txuR8NHOM", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1774465430181}" + "value": "{\"type\":\"logout\",\"at\":1774495329388}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1774638230733}" + "value": "{\"expiresAt\":1774668130069}" } ] } diff --git a/frontend/vue-app/e2e/.auth/user-delete.json b/frontend/vue-app/e2e/.auth/user-delete.json index 2e6f91e..8848650 100644 --- a/frontend/vue-app/e2e/.auth/user-delete.json +++ b/frontend/vue-app/e2e/.auth/user-delete.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "TagN0dCg26ZQ9TEQxpQARign6bxZQxrSTZmDtNi288U", + "value": "PcQvgm3e7SXL3CHGNXYGXhRrU7erXomAQDbOzVAUrCQ", "domain": "localhost", "path": "/api/auth", - "expires": 1782241430.203752, + "expires": 1782271328.966396, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjI5NDNjZWMtYzNmNS00N2MyLTg0YTItZTI2ODI3NDZlNDY5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0NDY2MzMwfQ.z0nN78IK3DqAepk20FS5xUzIIt7mZeGtdFhYePAOrxc", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiOWM4ODk0MWUtMjhiMy00YzFmLWJhNDctMDNkMTU0MTc2ODA5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0NDk2MjI4fQ.xxzCNQEX6y07hFJzEdumbYbhm_52VWM90tHsTPDfSKs", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1774465429926}" + "value": "{\"type\":\"logout\",\"at\":1774495328432}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1774638230517}" + "value": "{\"expiresAt\":1774668129476}" } ] } diff --git a/frontend/vue-app/e2e/.auth/user.json b/frontend/vue-app/e2e/.auth/user.json index d1322f3..4d8f132 100644 --- a/frontend/vue-app/e2e/.auth/user.json +++ b/frontend/vue-app/e2e/.auth/user.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "GiDwb1pZ1oqvwzO16LokRkqIyCZ8nPJLzpzowwZRW-I", + "value": "K9dRbu3607bZL23pmoMCD8qGM697tnpfY-dYVJ7qXPU", "domain": "localhost", "path": "/api/auth", - "expires": 1782241425.893418, + "expires": 1782271324.066253, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI0MzEwZGI1MC04NDJjLTQ2OWQtOTg4YS0wMzFjMTliMWMwM2YiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMjV9.5fn4UtlACnDCk2-O_n5f3HO9y1ksXDEnQjJl4TdJ-F4", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiMGFhNDczMC05ZDI4LTQ3NzQtODE3NC1kZTZmZWJkMjZjMmIiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0OTYyMjR9.3RoSBnOU30YFn61lbfKo_ZMF2LQcsKb7qO31CK4AOl8", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1774465425688}" + "value": "{\"type\":\"logout\",\"at\":1774495323817}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1774638226066}" + "value": "{\"expiresAt\":1774668124255}" } ] } diff --git a/frontend/vue-app/e2e/mode_parent/notifications/notification-card-overflow.spec.ts b/frontend/vue-app/e2e/mode_parent/notifications/notification-card-overflow.spec.ts new file mode 100644 index 0000000..6cde29b --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/notifications/notification-card-overflow.spec.ts @@ -0,0 +1,88 @@ +// spec: e2e/plans/bugs-1.0.6-set01.plan.md §4 + +import { test, expect, type APIRequestContext } from '@playwright/test' + +const CHILD_NAME = 'NotifOverflowChild' +const LONG_CHORE_NAME = 'This Is A Very Long Chore Name That Should Not Overflow The Card Boundary' +const CHORE_POINTS = 5 + +async function createChild(request: APIRequestContext, name: string): Promise { + 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 { + 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) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/notifications/notification-click-scroll.spec.ts b/frontend/vue-app/e2e/mode_parent/notifications/notification-click-scroll.spec.ts new file mode 100644 index 0000000..afe79ab --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/notifications/notification-click-scroll.spec.ts @@ -0,0 +1,169 @@ +// 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 { + 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 { + 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 { + 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 { + 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) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts b/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts index bf6f430..b709fe6 100644 --- a/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts @@ -79,7 +79,7 @@ test.describe('Chore activation', () => { data: { mode: 'interval', interval_days: 1, - anchor_date: new Date().toISOString().slice(0, 10), + anchor_date: new Date().toLocaleDateString('en-CA'), interval_has_deadline: false, }, }) diff --git a/frontend/vue-app/e2e/mode_parent/task-activation/general-chore-no-completed.spec.ts b/frontend/vue-app/e2e/mode_parent/task-activation/general-chore-no-completed.spec.ts new file mode 100644 index 0000000..0687cca --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-activation/general-chore-no-completed.spec.ts @@ -0,0 +1,128 @@ +// spec: e2e/plans/bugs-1.0.6-set01.plan.md §2 + +import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' + +const CHILD_NAME = 'GeneralChoreChild' +const GENERAL_CHORE_NAME = 'GeneralTestChore' +const SCHEDULED_CHORE_NAME = 'ScheduledTestChore' +const CHORE_POINTS = 10 + +async function createChild(request: APIRequestContext, name: string): Promise { + 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 { + 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 { + const text = await page.locator('.points .value').textContent() + return parseInt(text?.trim() ?? '0', 10) +} + +async function activateItem(itemCard: Locator): Promise { + 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() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-assignment/assign-title-shows-child-name.spec.ts b/frontend/vue-app/e2e/mode_parent/task-assignment/assign-title-shows-child-name.spec.ts new file mode 100644 index 0000000..8da612f --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-assignment/assign-title-shows-child-name.spec.ts @@ -0,0 +1,55 @@ +// spec: e2e/plans/bugs-1.0.6-set01.plan.md §1 + +import { test, expect, type APIRequestContext } from '@playwright/test' + +const CHILD_NAME = 'AssignTitleChild' + +async function createTestChild(request: APIRequestContext, name: string): Promise { + 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() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts b/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts index b7799c0..2700cc4 100644 --- a/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts @@ -75,7 +75,7 @@ test.describe('Chore reset', () => { data: { mode: 'interval', interval_days: 1, - anchor_date: new Date().toISOString().slice(0, 10), + anchor_date: new Date().toLocaleDateString('en-CA'), interval_has_deadline: false, }, }) diff --git a/frontend/vue-app/e2e/mode_parent/task-modification/override-sse-child-update.spec.ts b/frontend/vue-app/e2e/mode_parent/task-modification/override-sse-child-update.spec.ts new file mode 100644 index 0000000..020b959 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-modification/override-sse-child-update.spec.ts @@ -0,0 +1,151 @@ +// spec: e2e/plans/bugs-1.0.6-set01.plan.md §5 + +import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' + +const CHILD_NAME = 'OverrideSSEChild' +const CHORE_NAME = 'OverrideSSEChore' +const KIND_NAME = 'OverrideSSEKindness' +const PEN_NAME = 'OverrideSSEPenalty' + +const CHORE_POINTS = 10 +const KIND_POINTS = 15 +const PEN_POINTS = 8 + +async function createChild(request: APIRequestContext, name: string): Promise { + 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 { + 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() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-modification/pending-reset-on-edit.spec.ts b/frontend/vue-app/e2e/mode_parent/task-modification/pending-reset-on-edit.spec.ts new file mode 100644 index 0000000..a4a0e93 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-modification/pending-reset-on-edit.spec.ts @@ -0,0 +1,221 @@ +// spec: e2e/plans/bugs-1.0.6-set01.plan.md §10 + +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' + +const CHILD_NAME = 'PendingResetChild' +const CHORE_NAME = 'PendingResetChore' +const REWARD_NAME = 'PendingResetReward' +const CHORE_POINTS = 10 +const REWARD_COST = 20 +const INITIAL_CHILD_POINTS = 50 + +async function createChild( + request: APIRequestContext, + name: string, + points: number, +): Promise { + 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 { + 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 { + 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 }) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-modification/scroll-to-edited-task.spec.ts b/frontend/vue-app/e2e/mode_parent/task-modification/scroll-to-edited-task.spec.ts new file mode 100644 index 0000000..fc0db03 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-modification/scroll-to-edited-task.spec.ts @@ -0,0 +1,163 @@ +// 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 { + 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 { + 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 { + 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 { + 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 + await expect(targetCard).toBeVisible({ timeout: 5000 }) + expect(await isInViewport(targetCard)).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 + await expect(targetCard).toBeVisible({ timeout: 5000 }) + expect(await isInViewport(targetCard)).toBe(true) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-sorting/child-sort-order.spec.ts b/frontend/vue-app/e2e/mode_parent/task-sorting/child-sort-order.spec.ts new file mode 100644 index 0000000..d5c1e6d --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-sorting/child-sort-order.spec.ts @@ -0,0 +1,252 @@ +// 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 { + 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 { + 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 { + 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) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts b/frontend/vue-app/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts new file mode 100644 index 0000000..63c3264 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/task-sorting/parent-sort-order.spec.ts @@ -0,0 +1,224 @@ +// spec: e2e/plans/bugs-1.0.6-set01.plan.md §7 + +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' + +const CHILD_NAME = 'ParentSortChild' +const PENDING_CHORE = 'Sort1Pending' +const GENERAL_CHORE = 'Sort2General' +const EXPIRED_CHORE = 'Sort3Expired' +const COMPLETED_CHORE = 'Sort4Completed' +const NON_TODAY_CHORE = 'Sort5NonToday' + +const PENDING_REWARD = 'SortRewardPending' +const NORMAL_REWARD = 'SortRewardNormal' + +const POINTS = 5 +const REWARD_COST = 10 +const INITIAL_CHILD_POINTS = 50 + +function todayDayIndex(): number { + return new Date().getDay() +} + +function nonTodayDayIndex(): number { + return (new Date().getDay() + 1) % 7 +} + +async function createChild( + request: APIRequestContext, + name: string, + points: number, +): Promise { + 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 { + 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 { + 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) + }) +})