From cc1189cd9b039baf281e4d0c7b4b69c69774832b Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Thu, 23 Jul 2026 01:02:21 -0400 Subject: [PATCH] feat(tutorial): enhance help button visibility and add dialog tests - Introduced a mechanism to hide the help button when modal dialogs are open by adding `helpButtonHidden` state in the tutorial controller. - Updated various components to set the help button visibility based on dialog states. - Added tests to verify help button visibility during reward and task confirmation dialogs. - Created new E2E tests for dialog help button functionality across different assignment views. - Refactored existing dialog components to utilize the new help button visibility logic. - Added unit tests for `RewardConfirmDialog` and `TaskConfirmDialog` to ensure correct titles are rendered based on task type. - Enhanced `HelpButton` component tests to validate visibility based on tutorial state. --- .../task-assignment/assign-create-fab.spec.ts | 133 ++++++++ .../tutorial/dialog-help-button.spec.ts | 288 ++++++++++++++++++ frontend/playwright.config.ts | 10 + frontend/src/__tests__/ScheduleModal.spec.ts | 7 + frontend/src/components/child/ChildView.vue | 5 +- .../src/components/child/ChoreAssignView.vue | 3 + .../components/child/KindnessAssignView.vue | 3 + frontend/src/components/child/ParentView.vue | 5 + .../components/child/PenaltyAssignView.vue | 3 + .../src/components/child/RewardAssignView.vue | 5 +- .../components/child/RewardConfirmDialog.vue | 89 ++---- .../components/child/RoutineAssignView.vue | 5 +- .../components/child/TaskConfirmDialog.vue | 9 +- .../child/__tests__/ChildView.spec.ts | 18 ++ .../child/__tests__/ParentView.spec.ts | 38 +++ .../__tests__/RewardConfirmDialog.spec.ts | 30 ++ .../child/__tests__/TaskConfirmDialog.spec.ts | 43 +++ .../src/components/shared/ScheduleModal.vue | 2 +- frontend/src/tutorial/HelpButton.vue | 3 +- .../src/tutorial/__tests__/HelpButton.spec.ts | 41 +++ frontend/src/tutorial/controller.ts | 6 + opencode.json | 6 +- 22 files changed, 677 insertions(+), 75 deletions(-) create mode 100644 frontend/e2e/mode_parent/task-assignment/assign-create-fab.spec.ts create mode 100644 frontend/e2e/mode_parent/tutorial/dialog-help-button.spec.ts create mode 100644 frontend/src/components/child/__tests__/RewardConfirmDialog.spec.ts create mode 100644 frontend/src/components/child/__tests__/TaskConfirmDialog.spec.ts create mode 100644 frontend/src/tutorial/__tests__/HelpButton.spec.ts diff --git a/frontend/e2e/mode_parent/task-assignment/assign-create-fab.spec.ts b/frontend/e2e/mode_parent/task-assignment/assign-create-fab.spec.ts new file mode 100644 index 0000000..c3e54e4 --- /dev/null +++ b/frontend/e2e/mode_parent/task-assignment/assign-create-fab.spec.ts @@ -0,0 +1,133 @@ +import { test, expect, type APIRequestContext } from '@playwright/test' + +const CHILD_NAME = 'AssignFabChild' +const CHORE_NAME = 'AssignFabChore' +const KINDNESS_NAME = 'AssignFabKindness' +const PENALTY_NAME = 'AssignFabPenalty' +const REWARD_NAME = 'AssignFabReward' +const ROUTINE_NAME = 'AssignFabRoutine' + +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: { name: string; id: string }) => c.name === name)?.id ?? + '' + ) +} + +async function createTask( + request: APIRequestContext, + name: string, + type: 'chore' | 'kindness' | 'penalty', +): 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 } }) + const list = await request.get('/api/task/list') + return ( + (await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? '' + ) +} + +async function createReward(request: APIRequestContext, name: string): 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 fab reward', cost: 10 } }) + const list = await request.get('/api/reward/list') + return ( + (await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ?? + '' + ) +} + +async function createRoutine(request: APIRequestContext, name: string): Promise { + 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: 5 } }) + return (await res.json()).routine?.id ?? '' +} + +test.describe('Assignment views create FAB', () => { + test.describe.configure({ mode: 'serial' }) + + let childId = '' + let choreId = '' + let kindnessId = '' + let penaltyId = '' + let rewardId = '' + let routineId = '' + + test.beforeAll(async ({ request }) => { + childId = await createChild(request, CHILD_NAME) + choreId = await createTask(request, CHORE_NAME, 'chore') + kindnessId = await createTask(request, KINDNESS_NAME, 'kindness') + penaltyId = await createTask(request, PENALTY_NAME, 'penalty') + rewardId = await createReward(request, REWARD_NAME) + routineId = await createRoutine(request, ROUTINE_NAME) + }) + + test.afterAll(async ({ request }) => { + if (childId) await request.delete(`/api/child/${childId}`) + if (choreId) await request.delete(`/api/task/${choreId}`) + if (kindnessId) await request.delete(`/api/task/${kindnessId}`) + if (penaltyId) await request.delete(`/api/task/${penaltyId}`) + if (rewardId) await request.delete(`/api/reward/${rewardId}`) + if (routineId) await request.delete(`/api/routine/${routineId}`) + }) + + test('Chore assign view FAB navigates to chore creator', async ({ page }) => { + await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`) + await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible() + + await page.getByRole('button', { name: 'Create Chore' }).click() + await page.waitForURL(/\/parent\/tasks\/chores\/create$/) + await expect(page.getByRole('heading', { name: 'Create Chore' })).toBeVisible() + }) + + test('Kindness assign view FAB navigates to kindness creator', async ({ page }) => { + await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`) + await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible() + + await page.getByRole('button', { name: 'Create Kindness Act' }).click() + await page.waitForURL(/\/parent\/tasks\/kindness\/create$/) + await expect(page.getByRole('heading', { name: 'Create Kindness Act' })).toBeVisible() + }) + + test('Penalty assign view FAB navigates to penalty creator', async ({ page }) => { + await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`) + await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible() + + await page.getByRole('button', { name: 'Create Penalty' }).click() + await page.waitForURL(/\/parent\/tasks\/penalties\/create$/) + await expect(page.getByRole('heading', { name: 'Create Penalty' })).toBeVisible() + }) + + test('Reward assign view FAB navigates to reward creator', async ({ page }) => { + await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`) + await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible() + + await page.getByRole('button', { name: 'Create Reward' }).click() + await page.waitForURL(/\/parent\/rewards\/create$/) + await expect(page.getByRole('heading', { name: 'Create Reward' })).toBeVisible() + }) + + test('Routine assign view FAB navigates to routine creator', async ({ page }) => { + await page.goto(`/parent/${childId}/assign-routines?name=${CHILD_NAME}`) + await expect(page.getByRole('heading', { name: 'Assign Routines' })).toBeVisible() + + await page.getByRole('button', { name: 'Create Routine' }).click() + await page.waitForURL(/\/parent\/tasks\/routines\/create$/) + await expect(page.getByRole('heading', { name: 'Create Routine' })).toBeVisible() + }) +}) diff --git a/frontend/e2e/mode_parent/tutorial/dialog-help-button.spec.ts b/frontend/e2e/mode_parent/tutorial/dialog-help-button.spec.ts new file mode 100644 index 0000000..b037cd0 --- /dev/null +++ b/frontend/e2e/mode_parent/tutorial/dialog-help-button.spec.ts @@ -0,0 +1,288 @@ +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' + +const BACKEND = 'http://localhost:5000' + +const CHILD_NAME = 'DialogHelpChild' +const CHORE_NAME = 'DialogHelpChore' +const KINDNESS_NAME = 'DialogHelpKindness' +const PENALTY_NAME = 'DialogHelpPenalty' +const REWARD_NAME = 'DialogHelpReward' +const REWARD_COST = 10 +const ROUTINE_NAME = 'DialogHelpRoutine' + +async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise { + const res = await request.patch(`${BACKEND}/user/tutorial-progress`, { + data: { enabled }, + }) + if (!res.ok()) { + throw new Error( + `Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`, + ) + } +} + +async function resetTutorialProgress(request: APIRequestContext): Promise { + const res = await request.patch(`${BACKEND}/user/tutorial-progress`, { + data: { reset: true }, + }) + if (!res.ok()) { + throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`) + } +} + +async function createChild(request: APIRequestContext, name: string): Promise { + const pre = await request.get(`${BACKEND}/child/list`) + for (const c of (await pre.json()).children ?? []) { + if (c.name === name) await request.delete(`${BACKEND}/child/${c.id}`) + } + await request.put(`${BACKEND}/child/add`, { data: { name, age: 8 } }) + const list = await request.get(`${BACKEND}/child/list`) + return ( + (await list.json()).children?.find( + (c: { name: string; id: string }) => c.name === name, + )?.id ?? '' + ) +} + +async function createTask( + request: APIRequestContext, + name: string, + type: 'chore' | 'kindness' | 'penalty', +): Promise { + const pre = await request.get(`${BACKEND}/task/list`) + for (const t of (await pre.json()).tasks ?? []) { + if (t.name === name) await request.delete(`${BACKEND}/task/${t.id}`) + } + await request.put(`${BACKEND}/task/add`, { data: { name, points: 5, type } }) + const list = await request.get(`${BACKEND}/task/list`) + return ( + (await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? '' + ) +} + +async function createReward(request: APIRequestContext, name: string): Promise { + const pre = await request.get(`${BACKEND}/reward/list`) + for (const r of (await pre.json()).rewards ?? []) { + if (r.name === name) await request.delete(`${BACKEND}/reward/${r.id}`) + } + await request.put(`${BACKEND}/reward/add`, { + data: { name, description: 'E2E dialog reward', cost: REWARD_COST }, + }) + const list = await request.get(`${BACKEND}/reward/list`) + return ( + (await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ?? + '' + ) +} + +async function createRoutine(request: APIRequestContext, name: string): Promise { + const pre = await request.get(`${BACKEND}/routine/list`) + for (const r of (await pre.json()).routines ?? []) { + if (r.name === name) await request.delete(`${BACKEND}/routine/${r.id}`) + } + const res = await request.put(`${BACKEND}/routine/add`, { data: { name, points: 5 } }) + return (await res.json()).routine?.id ?? '' +} + +async function assignTask( + request: APIRequestContext, + childId: string, + taskId: string, + type: 'chore' | 'kindness' | 'penalty', +): Promise { + const res = await request.put(`${BACKEND}/child/${childId}/set-tasks`, { + data: { task_ids: [taskId], type }, + }) + if (!res.ok()) { + throw new Error(`Failed to assign ${type}: ${res.status()} ${await res.text()}`) + } +} + +async function assignReward( + request: APIRequestContext, + childId: string, + rewardId: string, +): Promise { + const res = await request.put(`${BACKEND}/child/${childId}/set-rewards`, { + data: { reward_ids: [rewardId] }, + }) + if (!res.ok()) { + throw new Error(`Failed to assign reward: ${res.status()} ${await res.text()}`) + } +} + +async function assignRoutine( + request: APIRequestContext, + childId: string, + routineId: string, +): Promise { + const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, { + data: { routine_id: routineId }, + }) + if (!res.ok()) { + throw new Error(`Failed to assign routine: ${res.status()} ${await res.text()}`) + } +} + +function helpButton(page: Page) { + return page.getByRole('button', { name: 'Show help for this screen' }) +} + +function modalBackdrop(page: Page) { + return page.locator('.modal-backdrop') +} + +async function dismissAutoTutorialChain(page: Page): Promise { + const card = page.locator('.tutorial-root .card') + for (let i = 0; i < 10; i++) { + if (!(await card.isVisible().catch(() => false))) return + await page.locator('.tutorial-root .btn-primary').click() + await page.waitForTimeout(200) + } +} + +function sectionByHeading(page: Page, heading: string) { + return page.locator('.child-list-container').filter({ + has: page.locator('h3', { hasText: heading }), + }) +} + +test.describe('Dialog help button and titles', () => { + test.describe.configure({ mode: 'serial' }) + + let childId = '' + let choreId = '' + let kindnessId = '' + let penaltyId = '' + let rewardId = '' + let routineId = '' + + test.beforeAll(async ({ request }) => { + await setTutorialEnabled(request, true) + await resetTutorialProgress(request) + + childId = await createChild(request, CHILD_NAME) + choreId = await createTask(request, CHORE_NAME, 'chore') + kindnessId = await createTask(request, KINDNESS_NAME, 'kindness') + penaltyId = await createTask(request, PENALTY_NAME, 'penalty') + rewardId = await createReward(request, REWARD_NAME) + routineId = await createRoutine(request, ROUTINE_NAME) + + await assignTask(request, childId, choreId, 'chore') + await assignTask(request, childId, kindnessId, 'kindness') + await assignTask(request, childId, penaltyId, 'penalty') + await assignReward(request, childId, rewardId) + await assignRoutine(request, childId, routineId) + + // Give the child enough points so the reward is ready to redeem. + await request.put(`${BACKEND}/child/${childId}/edit`, { data: { points: REWARD_COST } }) + }) + + test.afterAll(async ({ request }) => { + if (childId) await request.delete(`${BACKEND}/child/${childId}`) + if (choreId) await request.delete(`${BACKEND}/task/${choreId}`) + if (kindnessId) await request.delete(`${BACKEND}/task/${kindnessId}`) + if (penaltyId) await request.delete(`${BACKEND}/task/${penaltyId}`) + if (rewardId) await request.delete(`${BACKEND}/reward/${rewardId}`) + if (routineId) await request.delete(`${BACKEND}/routine/${routineId}`) + await setTutorialEnabled(request, false) + await resetTutorialProgress(request) + }) + + test.beforeEach(async ({ page }) => { + await page.goto(`/parent/${childId}`) + await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 }) + await dismissAutoTutorialChain(page) + await expect(helpButton(page)).toBeVisible({ timeout: 5000 }) + }) + + test('Task confirm dialog hides help button and shows "Confirm Task" for chores', async ({ + page, + }) => { + const card = sectionByHeading(page, 'Chores').locator('.item-card').filter({ hasText: CHORE_NAME }) + await card.waitFor({ state: 'visible' }) + await card.click() + await expect(card).toHaveClass(/item-ready/, { timeout: 3000 }) + await card.click() + + await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 }) + await expect(page.locator('.modal-title')).toHaveText('Confirm Task') + await expect(helpButton(page)).not.toBeVisible() + + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 }) + await expect(helpButton(page)).toBeVisible({ timeout: 5000 }) + }) + + test('Task confirm dialog hides help button and shows "Confirm Act" for kindness acts', async ({ + page, + }) => { + const card = sectionByHeading(page, 'Kindness Acts') + .locator('.item-card') + .filter({ hasText: KINDNESS_NAME }) + await card.waitFor({ state: 'visible' }) + await card.click() + await expect(card).toHaveClass(/item-ready/, { timeout: 3000 }) + await card.click() + + await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 }) + await expect(page.locator('.modal-title')).toHaveText('Confirm Act') + await expect(helpButton(page)).not.toBeVisible() + + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 }) + await expect(helpButton(page)).toBeVisible({ timeout: 5000 }) + }) + + test('Task confirm dialog hides help button for penalties', async ({ page }) => { + const card = sectionByHeading(page, 'Penalties') + .locator('.item-card') + .filter({ hasText: PENALTY_NAME }) + await card.waitFor({ state: 'visible' }) + await card.click() + await expect(card).toHaveClass(/item-ready/, { timeout: 3000 }) + await card.click() + + await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 }) + await expect(page.locator('.modal-title')).toHaveText('Confirm Task') + await expect(helpButton(page)).not.toBeVisible() + + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 }) + await expect(helpButton(page)).toBeVisible({ timeout: 5000 }) + }) + + test('Reward confirm dialog hides help button and shows "Grant Reward"', async ({ page }) => { + const card = sectionByHeading(page, 'Rewards').locator('.item-card').filter({ hasText: REWARD_NAME }) + await card.waitFor({ state: 'visible' }) + await expect(card.getByText('REWARD READY')).toBeVisible() + + await card.click() + await expect(card).toHaveClass(/item-ready/, { timeout: 3000 }) + await card.click() + + await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 }) + await expect(page.locator('.modal-title')).toHaveText('Grant Reward') + await expect(helpButton(page)).not.toBeVisible() + + await page.getByRole('button', { name: 'No', exact: true }).click() + await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 }) + await expect(helpButton(page)).toBeVisible({ timeout: 5000 }) + }) + + test('Routine confirm dialog hides help button and shows "Confirm Routine"', async ({ page }) => { + const card = sectionByHeading(page, 'Routines').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() + + await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 }) + await expect(page.locator('.modal-title')).toHaveText('Confirm Routine') + await expect(helpButton(page)).not.toBeVisible() + + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 }) + await expect(helpButton(page)).toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index ab02cff..fbcc78a 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -184,6 +184,16 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL }, dependencies: ['setup-tutorial'], testMatch: [/mode_parent\/tutorial\/.+\.spec\.ts/], + testIgnore: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/], + }, + + { + // Bucket: dialog help-button/title tests — depends on the tutorial bucket + // so it reuses the isolated tutorial user without running concurrently. + name: 'chromium-dialog', + use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL }, + dependencies: ['chromium-tutorial'], + testMatch: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/], }, { diff --git a/frontend/src/__tests__/ScheduleModal.spec.ts b/frontend/src/__tests__/ScheduleModal.spec.ts index 4f972f6..3d1dace 100644 --- a/frontend/src/__tests__/ScheduleModal.spec.ts +++ b/frontend/src/__tests__/ScheduleModal.spec.ts @@ -146,6 +146,13 @@ describe('ScheduleModal Specific Days form', () => { expect(w.find('.default-deadline-row').exists()).toBe(true) }) + it('exposes the enable-toggle row for the tutorial anchor', () => { + const w = mountModal() + const toggleRow = w.find('.schedule-toggle-row') + expect(toggleRow.exists()).toBe(true) + expect(toggleRow.attributes('data-tutorial')).toBe('schedule-enable-toggle') + }) + it('Save is disabled when no days selected (isDirty is false)', () => { const w = mountModal() const saveBtn = w.find('.btn-primary') diff --git a/frontend/src/components/child/ChildView.vue b/frontend/src/components/child/ChildView.vue index b92c0fd..f3448f6 100644 --- a/frontend/src/components/child/ChildView.vue +++ b/frontend/src/components/child/ChildView.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/child/RoutineAssignView.vue b/frontend/src/components/child/RoutineAssignView.vue index c1182ab..efb89bd 100644 --- a/frontend/src/components/child/RoutineAssignView.vue +++ b/frontend/src/components/child/RoutineAssignView.vue @@ -31,6 +31,8 @@ {{ isLoading ? 'Saving...' : 'Submit' }} + + @@ -39,6 +41,7 @@ import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { setChildRoutines } from '@/common/api' import MessageBlock from '../shared/MessageBlock.vue' +import FloatingActionButton from '../shared/FloatingActionButton.vue' import { getCachedImageUrl } from '@/common/imageCache' import '@/assets/styles.css' import type { Routine } from '@/common/models' @@ -69,7 +72,7 @@ async function fetchRoutines() { const routinesData = await routinesResp.json() const rawRoutines: Routine[] = routinesData.routines || [] await Promise.all( - rawRoutines.map(async (r: any) => { + rawRoutines.map(async (r: Routine) => { if (r.image_id) { try { r.image_url = await getCachedImageUrl(r.image_id) diff --git a/frontend/src/components/child/TaskConfirmDialog.vue b/frontend/src/components/child/TaskConfirmDialog.vue index 2a37b92..cc5d776 100644 --- a/frontend/src/components/child/TaskConfirmDialog.vue +++ b/frontend/src/components/child/TaskConfirmDialog.vue @@ -1,7 +1,7 @@