Files
chore/frontend/vue-app/e2e/mode_parent/task-activation/scroll-and-flow.spec.ts
Ryan Kegel a9131242a7
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m33s
Add end-to-end tests for task modification and assignment features
- Implemented tests for editing penalty points and reward costs in `penalty-edit-points.spec.ts` and `reward-edit-cost.spec.ts`.
- Created detailed plans for task activation and assignment scenarios in `task-activated.plan.md` and `task-assignment.plan.md`.
- Added comprehensive test cases for modifying tasks, including editing points for chores, kindness acts, penalties, and rewards in `task-modified.plan.md`.
- Ensured all tests are isolated and run in serial mode to maintain state integrity.
2026-03-17 22:46:27 -04:00

195 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
})
})