// 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 { 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 { 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 { 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) }) })