Files
chore/frontend/src/components/child/__tests__/ChildView.spec.ts
T
ryan cc1189cd9b
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m56s
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.
2026-07-23 01:02:21 -04:00

721 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ChildView from '../ChildView.vue'
import { eventBus } from '@/common/eventBus'
import { helpButtonHidden } from '@/tutorial/controller'
// Mock dependencies
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: 'child-123' },
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
})),
}))
global.fetch = vi.fn()
describe('ChildView', () => {
let wrapper: VueWrapper<any>
const mockChild = {
id: 'child-123',
name: 'Test Child',
age: 8,
points: 50,
tasks: ['task-1', 'task-2'],
rewards: ['reward-1'],
image_id: 'boy01',
}
const mockChore = {
id: 'task-1',
name: 'Clean Room',
points: 10,
type: 'chore' as const,
image_url: '/images/task.png',
custom_value: null,
}
const mockChoreWithOverride = {
id: 'task-1',
name: 'Clean Room',
points: 10,
type: 'chore' as const,
image_url: '/images/task.png',
custom_value: 15,
}
const mockPenalty = {
id: 'task-2',
name: 'Hit Sibling',
points: 5,
type: 'penalty' as const,
image_url: '/images/penalty.png',
custom_value: null,
}
const mockPenaltyWithOverride = {
id: 'task-2',
name: 'Hit Sibling',
points: 5,
type: 'penalty' as const,
image_url: '/images/penalty.png',
custom_value: 8,
}
const mockRoutine = {
id: 'routine-1',
name: 'Morning Routine',
points: 12,
image_id: 'routine-image',
image_url: '/images/routine.png',
items: [
{
id: 'routine-item-1',
routine_id: 'routine-1',
name: 'Make Bed',
image_id: null,
order: 0,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
// Mock fetch responses
;(global.fetch as any).mockImplementation((url: string) => {
if (url.includes('/child/child-123')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockChild),
})
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ tasks: [], reward_status: [] }),
})
})
// Mock speech synthesis
global.window.speechSynthesis = {
speak: vi.fn(),
cancel: vi.fn(),
} as any
global.window.SpeechSynthesisUtterance = vi.fn() as any
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Component Mounting', () => {
it('loads and displays child data on mount', async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(global.fetch).toHaveBeenCalledWith('/api/child/child-123')
})
it('registers SSE event listeners on mount', async () => {
const onSpy = vi.spyOn(eventBus, 'on')
wrapper = mount(ChildView)
await nextTick()
expect(onSpy).toHaveBeenCalledWith('child_task_triggered', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_reward_triggered', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_reward_request', expect.any(Function))
})
it('sets up inactivity timer on mount', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout')
wrapper = mount(ChildView)
await nextTick()
// Should set up inactivity timer (60 seconds)
expect(setTimeoutSpy).toHaveBeenCalled()
})
it('cleans up inactivity timer on unmount', async () => {
wrapper = mount(ChildView)
await nextTick()
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
wrapper.unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe('Custom Value Display - Chores', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('displays default points for chore without override', () => {
// The template should display mockChore.points (10) when custom_value is null
// Template logic: item.custom_value !== undefined && item.custom_value !== null ? item.custom_value : item.points
const expectedValue = mockChore.points
expect(expectedValue).toBe(10)
})
it('displays custom_value for chore with override', () => {
// The template should display mockChoreWithOverride.custom_value (15)
const expectedValue = mockChoreWithOverride.custom_value
expect(expectedValue).toBe(15)
})
})
describe('Custom Value Display - Penalties', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('displays negative default points for penalty without override', () => {
// The template should display -mockPenalty.points (-5)
// Template logic: item.custom_value !== undefined && item.custom_value !== null ? -item.custom_value : -item.points
const expectedValue = -mockPenalty.points
expect(expectedValue).toBe(-5)
})
it('displays negative custom_value for penalty with override', () => {
// The template should display -mockPenaltyWithOverride.custom_value (-8)
const expectedValue = -mockPenaltyWithOverride.custom_value!
expect(expectedValue).toBe(-8)
})
})
describe('Task Triggering', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('speaks task name when triggered', () => {
wrapper.vm.triggerTask(mockChore)
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not call trigger-task API in child mode', async () => {
await wrapper.vm.triggerTask(mockChore)
expect(
(global.fetch as any).mock.calls.some((call: [string]) =>
call[0].includes('/trigger-task'),
),
).toBe(false)
})
it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).speechSynthesis
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
// Restore for other tests
global.window.speechSynthesis = originalSpeechSynthesis
})
})
describe('Routine Overlay', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('opens the routine overlay when a routine is clicked', async () => {
vi.useFakeTimers()
wrapper.vm.handleRoutineClick(mockRoutine)
vi.advanceTimersByTime(200)
await nextTick()
expect(wrapper.vm.showRoutineOverlay).toBe(true)
expect(wrapper.vm.dialogRoutine?.id).toBe('routine-1')
vi.useRealTimers()
})
it('speaks the routine task name when a routine task is clicked', () => {
wrapper.vm.handleRoutineTaskClick(mockRoutine.items[0])
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
})
describe('Reward Triggering', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('speaks reward text when triggered', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 10,
redeeming: false,
})
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not call reward request/cancel APIs in child mode', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
})
const requestCalls = (global.fetch as any).mock.calls.filter(
(call: [string]) =>
call[0].includes('/request-reward') || call[0].includes('/cancel-request-reward'),
)
expect(requestCalls.length).toBe(0)
})
it('opens redeem dialog when reward is ready and not pending', async () => {
vi.useFakeTimers()
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
})
vi.advanceTimersByTime(200)
await nextTick()
expect(wrapper.vm.showRewardDialog).toBe(true)
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
vi.useRealTimers()
})
it('does not open redeem dialog when reward is not yet ready', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 10,
redeeming: false,
})
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.showCancelDialog).toBe(false)
})
it('opens cancel dialog when reward is already pending', async () => {
vi.useFakeTimers()
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: true,
})
vi.advanceTimersByTime(200)
await nextTick()
expect(wrapper.vm.showCancelDialog).toBe(true)
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
vi.useRealTimers()
})
})
describe('Reward Redeem Dialog', () => {
const readyReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
}
beforeEach(async () => {
vi.useFakeTimers()
wrapper = mount(ChildView)
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
wrapper.vm.triggerReward(readyReward)
vi.advanceTimersByTime(200)
await nextTick()
vi.useRealTimers()
})
it('closes redeem dialog on cancelRedeemReward', async () => {
expect(wrapper.vm.showRewardDialog).toBe(true)
wrapper.vm.cancelRedeemReward()
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
it('calls request-reward API on confirmRedeemReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.confirmRedeemReward()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
})
it('closes redeem dialog after confirmRedeemReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.confirmRedeemReward()
await nextTick()
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
})
describe('Help FAB visibility during reward redeem dialog', () => {
beforeEach(() => {
helpButtonHidden.value = false
wrapper = mount(ChildView)
})
it('hides the help button while the reward redeem dialog is open', async () => {
wrapper.vm.showRewardDialog = true
await nextTick()
expect(helpButtonHidden.value).toBe(true)
wrapper.vm.showRewardDialog = false
await nextTick()
expect(helpButtonHidden.value).toBe(false)
})
})
describe('Cancel Pending Reward Dialog', () => {
const pendingReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: true,
}
beforeEach(async () => {
vi.useFakeTimers()
wrapper = mount(ChildView)
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
wrapper.vm.triggerReward(pendingReward)
vi.advanceTimersByTime(200)
await nextTick()
vi.useRealTimers()
})
it('closes cancel dialog on closeCancelDialog', async () => {
expect(wrapper.vm.showCancelDialog).toBe(true)
wrapper.vm.closeCancelDialog()
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
it('calls cancel-request-reward API on cancelPendingReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.cancelPendingReward()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/cancel-request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
})
it('closes cancel dialog after cancelPendingReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.cancelPendingReward()
await nextTick()
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
})
describe('SSE Event Handlers', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('handles child_task_triggered event and refreshes reward list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleTaskTriggered({
type: 'child_task_triggered',
payload: { child_id: 'child-123', points: 60, task_id: 'task-1' },
})
expect(wrapper.vm.child.points).toBe(60)
expect(mockRefresh).toHaveBeenCalled()
})
it('handles child_reward_triggered event', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardTriggered({
type: 'child_reward_triggered',
payload: { child_id: 'child-123', points: 40, reward_id: 'reward-1' },
})
expect(wrapper.vm.child.points).toBe(40)
expect(mockRefresh).toHaveBeenCalled()
})
it('handles child_tasks_set event', () => {
wrapper.vm.handleChildTaskSet({
type: 'child_tasks_set',
payload: { child_id: 'child-123', task_ids: ['task-1', 'task-3'] },
})
expect(wrapper.vm.tasks).toEqual(['task-1', 'task-3'])
})
it('handles child_rewards_set event', () => {
wrapper.vm.handleChildRewardSet({
type: 'child_rewards_set',
payload: { child_id: 'child-123', reward_ids: ['reward-1', 'reward-2'] },
})
expect(wrapper.vm.rewards).toEqual(['reward-1', 'reward-2'])
})
it('handles reward_modified event and refreshes reward list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardModified({
type: 'reward_modified',
payload: { reward_id: 'reward-1', operation: 'EDIT' },
})
expect(mockRefresh).toHaveBeenCalled()
})
})
describe('Inactivity Timer', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('resets timer on user interaction', () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout')
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
wrapper.vm.resetInactivityTimer()
expect(clearTimeoutSpy).toHaveBeenCalled()
expect(setTimeoutSpy).toHaveBeenCalled()
})
})
describe('Reward Request Handling', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('handles reward request event and refreshes list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardRequest({
type: 'child_reward_request',
payload: { child_id: 'child-123', reward_id: 'reward-1' },
})
expect(mockRefresh).toHaveBeenCalled()
})
it('does not refresh if reward not in child rewards', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardRequest({
type: 'child_reward_request',
payload: { child_id: 'child-123', reward_id: 'reward-999' },
})
expect(mockRefresh).not.toHaveBeenCalled()
})
})
describe('Item Ready State Management', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('initializes readyItemId to null', () => {
expect(wrapper.vm.readyItemId).toBe(null)
})
it('updates readyItemId when handleItemReady is called with an item ID', () => {
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
wrapper.vm.handleItemReady('reward-2')
expect(wrapper.vm.readyItemId).toBe('reward-2')
})
it('clears readyItemId when handleItemReady is called with empty string', () => {
wrapper.vm.readyItemId = 'task-1'
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
it('passes readyItemId prop to Chores ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-1'
await nextTick()
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
expect(choresScrollingList.props('readyItemId')).toBe('task-1')
})
it('passes readyItemId prop to Penalties ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-2'
await nextTick()
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2')
})
it('passes readyItemId prop to Rewards ScrollingList', async () => {
wrapper.vm.readyItemId = 'reward-1'
await nextTick()
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1')
})
it('handles item-ready event from Chores ScrollingList', async () => {
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
choresScrollingList.vm.$emit('item-ready', 'task-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-1')
})
it('handles item-ready event from Penalties ScrollingList', async () => {
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
penaltiesScrollingList.vm.$emit('item-ready', 'task-2')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-2')
})
it('handles item-ready event from Rewards ScrollingList', async () => {
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
rewardsScrollingList.vm.$emit('item-ready', 'reward-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('reward-1')
})
it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => {
// Initial state
expect(wrapper.vm.readyItemId).toBe(null)
// First click - item should become ready
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
// Second click would trigger the item (tested via ScrollingList component)
// After trigger, ready state should be cleared
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
})
describe('choreDueLabel', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('returns null when item has no schedule', () => {
const result = wrapper.vm.choreDueLabel({
id: 'task-1',
name: 'Test',
points: 5,
type: 'chore' as const,
schedule: null,
})
expect(result).toBe(null)
})
it('returns "Due by ..." prefix format when chore has a future due time today', () => {
vi.useFakeTimers()
// Feb 24 2026 is a Tuesday (day index 2) at 10:00am
vi.setSystemTime(new Date('2026-02-24T10:00:00'))
try {
const item = {
id: 'task-1',
name: 'Test',
points: 5,
type: 'chore' as const,
schedule: {
mode: 'days' as const,
day_configs: [{ day: 2, hour: 14, minute: 30 }], // Tuesday 2:30pm — future
interval_days: null,
anchor_weekday: null,
interval_hour: null,
interval_minute: null,
},
extension_date: null,
}
const result = wrapper.vm.choreDueLabel(item)
expect(result).toMatch(/^Due by /)
} finally {
vi.useRealTimers()
}
})
})
})