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 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() } }) }) })