diff --git a/src/screens/__tests__/PlannerScreen.test.tsx b/src/screens/__tests__/PlannerScreen.test.tsx index 2604e80..5437768 100644 --- a/src/screens/__tests__/PlannerScreen.test.tsx +++ b/src/screens/__tests__/PlannerScreen.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Alert } from 'react-native'; +import { Alert, Platform } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; import PlannerScreen from '../PlannerScreen'; import { usePlanner } from '../../store/PlannerContext'; @@ -8,6 +8,13 @@ import { useThemeColors } from '../../utils/useThemeColors'; import { Colors } from '../../utils/theme'; import { t } from '../../utils/i18n'; import type { Category, BlockTemplate, ScheduledBlock, WeeklyPlan } from '../../types'; +import * as weekUtils from '../../types/weekUtils'; + +jest.mock('@react-native-community/datetimepicker', () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return jest.fn((props) => React.createElement(View, { testID: 'datetimepicker', ...props })); +}); jest.mock('../../store/PlannerContext', () => ({ usePlanner: jest.fn(), @@ -25,7 +32,7 @@ const mockUsePlanner = usePlanner as jest.Mock; const mockUsePeaks = usePeaks as jest.Mock; const mockUseThemeColors = useThemeColors as jest.Mock; -describe('PlannerScreen - Clear Week', () => { +describe('PlannerScreen', () => { jest.setTimeout(15000); const mockNavigation = { @@ -41,6 +48,14 @@ describe('PlannerScreen - Clear Week', () => { targetHoursPerWeek: 10, }; + const emptyCategory: Category = { + id: 'cat2', + name: 'Free Time', + color: '#10B981', + emoji: 'controller-classic', + targetHoursPerWeek: 0, + }; + const defaultTemplate: BlockTemplate = { id: 'tpl1', name: 'Reading', @@ -48,17 +63,57 @@ describe('PlannerScreen - Clear Week', () => { durationHours: 1, }; + const secondTemplate: BlockTemplate = { + id: 'tpl2', + name: 'Workout', + categoryId: 'cat1', + durationHours: 2, + }; + + const newTemplate: BlockTemplate = { + id: 'tpl3', + name: 'Coding', + categoryId: 'cat1', + durationHours: 1, + }; + const scheduledBlock: ScheduledBlock = { id: 'b1', templateId: 'tpl1', day: 'lun', startTime: '09:00', done: false, + description: 'Chapter 1 notes', + }; + + const doneBlock: ScheduledBlock = { + id: 'b2', + templateId: 'tpl2', + day: 'mar', + startTime: '10:00', + done: true, + customDuration: 2, + }; + + const oneOffBlock: ScheduledBlock = { + id: 'b3', + day: 'mer', + startTime: '14:00', + done: false, + isOneOff: true, + oneOffName: 'Meeting', + oneOffCategoryId: 'cat1', + oneOffDuration: 1.5, }; const mockClearCurrentWeek = jest.fn(); const mockCopyPreviousWeek = jest.fn(); const mockChangeWeek = jest.fn(); + const mockScheduleBlock = jest.fn(); + const mockUnscheduleBlock = jest.fn(); + const mockToggleBlockDone = jest.fn(); + const mockUpdateBlockDescription = jest.fn(); + const mockAddCompletedHours = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -69,193 +124,461 @@ describe('PlannerScreen - Clear Week', () => { }); mockUsePeaks.mockReturnValue({ - addCompletedHours: jest.fn(), + addCompletedHours: mockAddCompletedHours, }); }); - it('does not display Clear Week button when current week has no blocks', async () => { + const setupDefaultPlannerMock = (overrides = {}) => { mockUsePlanner.mockReturnValue({ currentWeekId: '2026-10-12', - currentPlan: { weekId: '2026-10-12', blocks: [] } as WeeklyPlan, + currentPlan: { + weekId: '2026-10-12', + blocks: [scheduledBlock, doneBlock, oneOffBlock], + } as WeeklyPlan, hasPreviousWeekBlocks: false, - categories: [defaultCategory], - templates: [defaultTemplate], - scheduleBlock: jest.fn(), - unscheduleBlock: jest.fn(), - toggleBlockDone: jest.fn(), - updateBlockDescription: jest.fn(), - getTemplateById: jest.fn().mockReturnValue(defaultTemplate), - getCategoryById: jest.fn().mockReturnValue(defaultCategory), - getCategoryHours: jest.fn().mockReturnValue({ scheduled: 0, completed: 0, target: 10 }), + categories: [defaultCategory, emptyCategory], + templates: [defaultTemplate, secondTemplate, newTemplate], + scheduleBlock: mockScheduleBlock, + unscheduleBlock: mockUnscheduleBlock, + toggleBlockDone: mockToggleBlockDone, + updateBlockDescription: mockUpdateBlockDescription, + getTemplateById: jest.fn((id) => { + if (id === 'tpl1') return defaultTemplate; + if (id === 'tpl2') return secondTemplate; + if (id === 'tpl3') return newTemplate; + return undefined; + }), + getCategoryById: jest.fn((id) => (id === 'cat1' ? defaultCategory : undefined)), + getCategoryHours: jest.fn((id) => + id === 'cat1' + ? { scheduled: 4.5, completed: 2, target: 10 } + : { scheduled: 0, completed: 0, target: 0 } + ), changeWeek: mockChangeWeek, copyPreviousWeek: mockCopyPreviousWeek, clearCurrentWeek: mockClearCurrentWeek, + ...overrides, }); + }; - const { queryByText, queryByLabelText, getByText } = await render( - - ); + describe('header & navigation', () => { + it('sets screen header options and navigates to WeeklyReview on headerRight press', async () => { + setupDefaultPlannerMock(); - expect(getByText(t('planner.emptyWeek'))).toBeTruthy(); - expect(queryByText(t('planner.clearWeekBtn'))).toBeNull(); - expect(queryByLabelText(t('planner.clearWeekBtn'))).toBeNull(); + await render(); + + expect(mockNavigation.setOptions).toHaveBeenCalledWith( + expect.objectContaining({ + title: t('tabs.planner'), + }) + ); + + const headerRightFn = mockNavigation.setOptions.mock.calls[0][0].headerRight; + const { getByLabelText } = await render(headerRightFn()); + const reviewButton = getByLabelText(t('review.title')); + await fireEvent.press(reviewButton); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('WeeklyReview'); + }); }); - it('displays Clear Week button when current week has scheduled blocks', async () => { - mockUsePlanner.mockReturnValue({ - currentWeekId: '2026-10-12', - currentPlan: { weekId: '2026-10-12', blocks: [scheduledBlock] } as WeeklyPlan, - hasPreviousWeekBlocks: false, - categories: [defaultCategory], - templates: [defaultTemplate], - scheduleBlock: jest.fn(), - unscheduleBlock: jest.fn(), - toggleBlockDone: jest.fn(), - updateBlockDescription: jest.fn(), - getTemplateById: jest.fn().mockReturnValue(defaultTemplate), - getCategoryById: jest.fn().mockReturnValue(defaultCategory), - getCategoryHours: jest.fn().mockReturnValue({ scheduled: 1, completed: 0, target: 10 }), - changeWeek: mockChangeWeek, - copyPreviousWeek: mockCopyPreviousWeek, - clearCurrentWeek: mockClearCurrentWeek, + describe('week navigation & summary', () => { + it('navigates to previous and next weeks', async () => { + setupDefaultPlannerMock(); + + const { getByText } = await render(); + + await fireEvent.press(getByText(t('planner.prev'))); + expect(mockChangeWeek).toHaveBeenCalledWith(weekUtils.getPrevWeekId('2026-10-12')); + + await fireEvent.press(getByText(t('planner.next'))); + expect(mockChangeWeek).toHaveBeenCalledWith(weekUtils.getNextWeekId('2026-10-12')); }); - const { getByText, getByLabelText, queryByText } = await render( - - ); + it('shows current week badge when active week matches current week', async () => { + jest.spyOn(weekUtils, 'getCurrentWeekId').mockReturnValue('2026-10-12'); + setupDefaultPlannerMock(); - expect(queryByText(t('planner.emptyWeek'))).toBeNull(); - expect(getByText(t('planner.clearWeekBtn'))).toBeTruthy(); - expect(getByLabelText(t('planner.clearWeekBtn'))).toBeTruthy(); - }); + const { getByText } = await render(); + expect(getByText(t('planner.current'))).toBeTruthy(); + }); - it('triggers confirmation dialog when Clear Week button is tapped', async () => { - const alertSpy = jest.spyOn(Alert, 'alert'); + it('hides current week badge when viewing a different week', async () => { + jest.spyOn(weekUtils, 'getCurrentWeekId').mockReturnValue('2026-10-19'); + setupDefaultPlannerMock(); - mockUsePlanner.mockReturnValue({ - currentWeekId: '2026-10-12', - currentPlan: { weekId: '2026-10-12', blocks: [scheduledBlock] } as WeeklyPlan, - hasPreviousWeekBlocks: false, - categories: [defaultCategory], - templates: [defaultTemplate], - scheduleBlock: jest.fn(), - unscheduleBlock: jest.fn(), - toggleBlockDone: jest.fn(), - updateBlockDescription: jest.fn(), - getTemplateById: jest.fn().mockReturnValue(defaultTemplate), - getCategoryById: jest.fn().mockReturnValue(defaultCategory), - getCategoryHours: jest.fn().mockReturnValue({ scheduled: 1, completed: 0, target: 10 }), - changeWeek: mockChangeWeek, - copyPreviousWeek: mockCopyPreviousWeek, - clearCurrentWeek: mockClearCurrentWeek, + const { queryByText } = await render(); + expect(queryByText(t('planner.current'))).toBeNull(); }); - const { getByLabelText } = await render(); + it('renders category progress and skips categories with 0 target and 0 scheduled hours', async () => { + setupDefaultPlannerMock(); - await fireEvent.press(getByLabelText(t('planner.clearWeekBtn'))); + const { getByText, queryByText } = await render( + + ); - expect(alertSpy).toHaveBeenCalledTimes(1); - expect(alertSpy).toHaveBeenCalledWith( - t('planner.clearWeekTitle'), - t('planner.clearWeekMsg'), - expect.arrayContaining([ - expect.objectContaining({ text: t('common.cancel'), style: 'cancel' }), - expect.objectContaining({ - text: t('planner.clearWeekConfirm'), - style: 'destructive', - onPress: expect.any(Function), - }), - ]) - ); + expect(getByText('Study')).toBeTruthy(); + expect(queryByText('Free Time')).toBeNull(); + }); }); - it('calls clearCurrentWeek upon alert confirmation', async () => { - const alertSpy = jest.spyOn(Alert, 'alert'); + describe('empty week & copy previous week', () => { + it('renders empty week message and copy button when previous week has blocks', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + setupDefaultPlannerMock({ + currentPlan: { weekId: '2026-10-12', blocks: [] }, + hasPreviousWeekBlocks: true, + }); + + const { getByText } = await render(); + + expect(getByText(t('planner.emptyWeek'))).toBeTruthy(); + const copyBtn = getByText(t('planner.copyBtn')); + expect(copyBtn).toBeTruthy(); + + await fireEvent.press(copyBtn); + expect(alertSpy).toHaveBeenCalledWith( + t('planner.copyWeekTitle'), + t('planner.copyWeekMsg'), + expect.any(Array) + ); + + const alertButtons = alertSpy.mock.calls[0][2]!; + const copyConfirm = alertButtons.find((b) => b.text === t('planner.copyWeekTitle')); + copyConfirm?.onPress?.(); + + expect(mockCopyPreviousWeek).toHaveBeenCalled(); + alertSpy.mockRestore(); + }); + }); - mockUsePlanner.mockReturnValue({ - currentWeekId: '2026-10-12', - currentPlan: { weekId: '2026-10-12', blocks: [scheduledBlock] } as WeeklyPlan, - hasPreviousWeekBlocks: false, - categories: [defaultCategory], - templates: [defaultTemplate], - scheduleBlock: jest.fn(), - unscheduleBlock: jest.fn(), - toggleBlockDone: jest.fn(), - updateBlockDescription: jest.fn(), - getTemplateById: jest.fn().mockReturnValue(defaultTemplate), - getCategoryById: jest.fn().mockReturnValue(defaultCategory), - getCategoryHours: jest.fn().mockReturnValue({ scheduled: 1, completed: 0, target: 10 }), - changeWeek: mockChangeWeek, - copyPreviousWeek: mockCopyPreviousWeek, - clearCurrentWeek: mockClearCurrentWeek, + describe('Clear Week button', () => { + it('does not display Clear Week button when current week has no blocks', async () => { + setupDefaultPlannerMock({ + currentPlan: { weekId: '2026-10-12', blocks: [] }, + }); + + const { queryByLabelText } = await render(); + expect(queryByLabelText(t('planner.clearWeekBtn'))).toBeNull(); }); - const { getByLabelText } = await render(); + it('triggers confirmation dialog and calls clearCurrentWeek upon confirm', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + setupDefaultPlannerMock(); + + const { getByLabelText } = await render(); + await fireEvent.press(getByLabelText(t('planner.clearWeekBtn'))); + + expect(alertSpy).toHaveBeenCalledWith( + t('planner.clearWeekTitle'), + t('planner.clearWeekMsg'), + expect.any(Array) + ); - await fireEvent.press(getByLabelText(t('planner.clearWeekBtn'))); + const alertButtons = alertSpy.mock.calls[0][2]!; + const confirmButton = alertButtons.find((b) => b.text === t('planner.clearWeekConfirm')); + confirmButton?.onPress?.(); - const alertButtons = alertSpy.mock.calls[0][2]; - const confirmButton = alertButtons?.find((b) => b.text === t('planner.clearWeekConfirm')); - expect(confirmButton).toBeDefined(); + expect(mockClearCurrentWeek).toHaveBeenCalled(); + alertSpy.mockRestore(); + }); + + it('renders Clear Week button with dark theme styling', async () => { + mockUseThemeColors.mockReturnValue({ + colors: Colors.dark, + isDark: true, + }); + setupDefaultPlannerMock(); + + const { getByLabelText } = await render(); + expect(getByLabelText(t('planner.clearWeekBtn'))).toBeTruthy(); + }); - confirmButton?.onPress?.(); + it('renders Clear Week button with proper styling', async () => { + setupDefaultPlannerMock(); - expect(mockClearCurrentWeek).toHaveBeenCalledTimes(1); + const { getByLabelText } = await render(); + const clearBtn = getByLabelText(t('planner.clearWeekBtn')); + + expect(clearBtn.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ borderColor: Colors.light.danger })]) + ); + }); }); - it('renders Clear Week button with dark theme styling', async () => { - mockUseThemeColors.mockReturnValue({ - colors: Colors.dark, - isDark: true, + describe('grid blocks & edit modal interactions', () => { + it('renders blocks and opens edit modal on block press', async () => { + setupDefaultPlannerMock(); + + const { getByText, getByPlaceholderText } = await render( + + ); + + expect(getByText('Reading')).toBeTruthy(); + expect(getByText('Meeting')).toBeTruthy(); + + await fireEvent.press(getByText('Reading')); + + expect(getByText(t('planner.blockDetails'))).toBeTruthy(); + const descInput = getByPlaceholderText(t('planner.taskDescPlaceholder')); + expect(descInput.props.value).toBe('Chapter 1 notes'); + + await fireEvent.changeText(descInput, 'Updated chapter 2 notes'); + await fireEvent.press(getByText(t('common.save'))); + + expect(mockUpdateBlockDescription).toHaveBeenCalledWith('b1', 'Updated chapter 2 notes'); }); - mockUsePlanner.mockReturnValue({ - currentWeekId: '2026-10-12', - currentPlan: { weekId: '2026-10-12', blocks: [scheduledBlock] } as WeeklyPlan, - hasPreviousWeekBlocks: false, - categories: [defaultCategory], - templates: [defaultTemplate], - scheduleBlock: jest.fn(), - unscheduleBlock: jest.fn(), - toggleBlockDone: jest.fn(), - updateBlockDescription: jest.fn(), - getTemplateById: jest.fn().mockReturnValue(defaultTemplate), - getCategoryById: jest.fn().mockReturnValue(defaultCategory), - getCategoryHours: jest.fn().mockReturnValue({ scheduled: 1, completed: 0, target: 10 }), - changeWeek: mockChangeWeek, - copyPreviousWeek: mockCopyPreviousWeek, - clearCurrentWeek: mockClearCurrentWeek, + it('cancels block editing modal', async () => { + setupDefaultPlannerMock(); + + const { getByText, queryByText } = await render( + + ); + + await fireEvent.press(getByText('Reading')); + expect(getByText(t('planner.blockDetails'))).toBeTruthy(); + + await fireEvent.press(getByText(t('common.cancel'))); + expect(queryByText(t('planner.blockDetails'))).toBeNull(); }); - const { getByLabelText } = await render(); - const clearBtn = getByLabelText(t('planner.clearWeekBtn')); - expect(clearBtn).toBeTruthy(); + it('deletes block from edit modal', async () => { + setupDefaultPlannerMock(); + + const { getByText } = await render(); + + await fireEvent.press(getByText('Reading')); + await fireEvent.press(getByText(t('common.delete'))); + + expect(mockUnscheduleBlock).toHaveBeenCalledWith('b1'); + }); + + it('toggles block done and adds completed hours', async () => { + setupDefaultPlannerMock(); + + const { getByText } = await render(); + + await fireEvent.press(getByText('Reading')); + await fireEvent.press(getByText('✓ ' + t('planner.markDone'))); + + expect(mockToggleBlockDone).toHaveBeenCalledWith('b1'); + expect(mockAddCompletedHours).toHaveBeenCalledWith(1); + }); + + it('toggles block undone and subtracts completed hours for customDuration and one-off', async () => { + setupDefaultPlannerMock(); + + const { getByText } = await render(); + + await fireEvent.press(getByText('Workout')); + await fireEvent.press(getByText('✕ ' + t('planner.markTodo'))); + + expect(mockToggleBlockDone).toHaveBeenCalledWith('b2'); + expect(mockAddCompletedHours).toHaveBeenCalledWith(-2); + + await fireEvent.press(getByText('Meeting')); + await fireEvent.press(getByText('✓ ' + t('planner.markDone'))); + + expect(mockToggleBlockDone).toHaveBeenCalledWith('b3'); + expect(mockAddCompletedHours).toHaveBeenCalledWith(1.5); + }); }); - it('applies pressed opacity style when Clear Week button is pressed', async () => { - mockUsePlanner.mockReturnValue({ - currentWeekId: '2026-10-12', - currentPlan: { weekId: '2026-10-12', blocks: [scheduledBlock] } as WeeklyPlan, - hasPreviousWeekBlocks: false, - categories: [defaultCategory], - templates: [defaultTemplate], - scheduleBlock: jest.fn(), - unscheduleBlock: jest.fn(), - toggleBlockDone: jest.fn(), - updateBlockDescription: jest.fn(), - getTemplateById: jest.fn().mockReturnValue(defaultTemplate), - getCategoryById: jest.fn().mockReturnValue(defaultCategory), - getCategoryHours: jest.fn().mockReturnValue({ scheduled: 1, completed: 0, target: 10 }), - changeWeek: mockChangeWeek, - copyPreviousWeek: mockCopyPreviousWeek, - clearCurrentWeek: mockClearCurrentWeek, + describe('schedule block modal (template picker)', () => { + it('opens schedule modal and creates new block on submit', async () => { + setupDefaultPlannerMock(); + + const { getAllByText, getByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + expect(getByText(t('planner.scheduleBlock'))).toBeTruthy(); + + const studyCategories = getAllByText(/Study/); + await fireEvent.press(studyCategories[studyCategories.length - 1]); + expect(getByText('Coding')).toBeTruthy(); + + await fireEvent.press(getByText('Coding')); + expect(getByText('3h')).toBeTruthy(); + + await fireEvent.press(getByText('3h')); + await fireEvent.press(getByText(t('common.save'))); + + expect(mockScheduleBlock).toHaveBeenCalledWith('tpl3', 'lun', expect.any(String), 3); }); - const { getByLabelText } = await render(); - const clearBtn = getByLabelText(t('planner.clearWeekBtn')); - await fireEvent(clearBtn, 'responderGrant', { nativeEvent: {}, persist: () => {} }); - expect(clearBtn.props.style).toEqual( - expect.arrayContaining([expect.objectContaining({ opacity: 0.7 })]) - ); + it('cancels template selection and resets modal', async () => { + setupDefaultPlannerMock(); + + const { getAllByText, getByText, queryByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + const studyCategories = getAllByText(/Study/); + await fireEvent.press(studyCategories[studyCategories.length - 1]); + await fireEvent.press(getByText('Coding')); + + const cancelBtn = getByText(t('common.cancel')); + await fireEvent.press(cancelBtn); + + expect(queryByText(t('manage.duration') + ' (h):')).toBeNull(); + }); + + it('shows goToManage button when no active templates exist and navigates to BlocchiTab', async () => { + setupDefaultPlannerMock({ + templates: [], + }); + + const { getAllByText, getByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + expect(getByText(t('planner.createFirst'))).toBeTruthy(); + const manageBtn = getByText(t('planner.goToManage')); + await fireEvent.press(manageBtn); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('BlocchiTab'); + }); + + it('cancels schedule modal when no template is selected', async () => { + setupDefaultPlannerMock(); + + const { getAllByText, queryByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + expect(queryByText(t('planner.scheduleBlock'))).toBeTruthy(); + + const cancelButtons = getAllByText(t('common.cancel')); + await fireEvent.press(cancelButtons[cancelButtons.length - 1]); + + expect(queryByText(t('planner.scheduleBlock'))).toBeNull(); + }); + + it('supports iOS time picker onValueChange', async () => { + const originalPlatform = Platform.OS; + Platform.OS = 'ios'; + + setupDefaultPlannerMock(); + + const { getAllByText, getByTestId } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[3]); + + const picker = getByTestId('datetimepicker'); + const testDate = new Date(2026, 9, 12, 11, 30); + await fireEvent(picker, 'valueChange', {}, testDate); + + Platform.OS = originalPlatform; + }); + + it('supports Android time picker button click, value change, and dismiss', async () => { + const originalPlatform = Platform.OS; + Platform.OS = 'android'; + + setupDefaultPlannerMock(); + + const { getAllByText, getByTestId } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[3]); + + const timeButtons = getAllByText(/\d{2}:\d{2}/); + const timeBtn = timeButtons[timeButtons.length - 1]; + await fireEvent.press(timeBtn); + + const picker = getByTestId('datetimepicker'); + const testDate = new Date(2026, 9, 12, 15, 0); + await fireEvent(picker, 'valueChange', {}, testDate); + + await fireEvent.press(timeBtn); + const pickerDismiss = getByTestId('datetimepicker'); + await fireEvent(pickerDismiss, 'dismiss'); + + Platform.OS = originalPlatform; + }); + + it('sorts multiple blocks on the same day when calculating next start time', async () => { + const earlierBlock: ScheduledBlock = { + id: 'b4', + templateId: 'tpl1', + day: 'lun', + startTime: '08:00', + done: false, + }; + setupDefaultPlannerMock({ + currentPlan: { + weekId: '2026-10-12', + blocks: [scheduledBlock, earlierBlock], + } as WeeklyPlan, + }); + + const { getAllByText, getByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + expect(getByText(t('planner.scheduleBlock'))).toBeTruthy(); + }); + + it('calculates start time based on last block when day already has blocks', async () => { + setupDefaultPlannerMock(); + + const { getAllByText, getByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + expect(getByText(t('planner.scheduleBlock'))).toBeTruthy(); + }); + + it('handles onRequestClose on modals', async () => { + setupDefaultPlannerMock(); + + const { container, getAllByText, getByText, queryByText } = await render( + + ); + + const addButtons = getAllByText('+'); + await fireEvent.press(addButtons[0]); + + const modals = container.queryAll( + (node: any) => typeof node.props?.onRequestClose === 'function' + ); + const scheduleModal = modals[modals.length - 1]; + await fireEvent(scheduleModal, 'requestClose'); + expect(queryByText(t('planner.scheduleBlock'))).toBeNull(); + + await fireEvent.press(getByText('Reading')); + const activeModals = container.queryAll( + (node: any) => typeof node.props?.onRequestClose === 'function' + ); + const editModal = activeModals[0]; + await fireEvent(editModal, 'requestClose'); + expect(queryByText(t('planner.blockDetails'))).toBeNull(); + }); }); }); diff --git a/src/store/__tests__/PlannerContext.test.tsx b/src/store/__tests__/PlannerContext.test.tsx index 0791589..eeb9dd2 100644 --- a/src/store/__tests__/PlannerContext.test.tsx +++ b/src/store/__tests__/PlannerContext.test.tsx @@ -1,10 +1,23 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AppState, type AppStateStatus } from 'react-native'; import { PlannerProvider, usePlanner } from '../PlannerContext'; -import { deleteCalendarEvent } from '../../utils/calendar'; -import { loadWeeklyPlan, saveWeeklyPlan } from '../plannerStorage'; -import type { ScheduledBlock, WeeklyPlan } from '../../types'; +import { + createCalendarEvent, + deleteCalendarEvent, + updateCalendarEventDescription, +} from '../../utils/calendar'; +import { + loadWeeklyPlan, + saveWeeklyPlan, + loadCategories, + saveCategories, + loadTemplates, + saveTemplates, +} from '../plannerStorage'; +import type { Category, ScheduledBlock, WeeklyPlan } from '../../types'; +import * as weekUtils from '../../types/weekUtils'; jest.mock('@react-native-async-storage/async-storage', () => jest.requireActual('@react-native-async-storage/async-storage/jest/async-storage-mock') @@ -15,9 +28,19 @@ jest.mock('../plannerStorage', () => { return { ...actual, saveWeeklyPlan: jest.fn(actual.saveWeeklyPlan), + loadWeeklyPlan: jest.fn(actual.loadWeeklyPlan), + saveCategories: jest.fn(actual.saveCategories), + loadCategories: jest.fn(actual.loadCategories), + saveTemplates: jest.fn(actual.saveTemplates), + loadTemplates: jest.fn(actual.loadTemplates), }; }); +let mockIdCounter = 1; +jest.mock('../../utils/id', () => ({ + generateId: jest.fn(() => `test-id-${mockIdCounter++}`), +})); + jest.mock('../../utils/calendar', () => ({ createCalendarEvent: jest.fn().mockResolvedValue('event-123'), deleteCalendarEvent: jest.fn().mockResolvedValue(undefined), @@ -27,12 +50,37 @@ jest.mock('../../utils/calendar', () => ({ const mockDeleteCalendarEvent = deleteCalendarEvent as jest.MockedFunction< typeof deleteCalendarEvent >; +const mockCreateCalendarEvent = createCalendarEvent as jest.MockedFunction< + typeof createCalendarEvent +>; +const mockUpdateCalendarEventDescription = updateCalendarEventDescription as jest.MockedFunction< + typeof updateCalendarEventDescription +>; -describe('PlannerContext - clearCurrentWeek', () => { +describe('PlannerContext', () => { beforeEach(async () => { + mockIdCounter = 1; jest.useFakeTimers(); await AsyncStorage.clear(); jest.clearAllMocks(); + (saveWeeklyPlan as jest.Mock).mockImplementation( + jest.requireActual('../plannerStorage').saveWeeklyPlan + ); + (loadWeeklyPlan as jest.Mock).mockImplementation( + jest.requireActual('../plannerStorage').loadWeeklyPlan + ); + (saveCategories as jest.Mock).mockImplementation( + jest.requireActual('../plannerStorage').saveCategories + ); + (loadCategories as jest.Mock).mockImplementation( + jest.requireActual('../plannerStorage').loadCategories + ); + (saveTemplates as jest.Mock).mockImplementation( + jest.requireActual('../plannerStorage').saveTemplates + ); + (loadTemplates as jest.Mock).mockImplementation( + jest.requireActual('../plannerStorage').loadTemplates + ); }); afterEach(() => { @@ -43,154 +91,725 @@ describe('PlannerContext - clearCurrentWeek', () => { {children} ); - it('clears all scheduled blocks, removes calendar events, and persists empty plan', async () => { - const weekId = '2026-10-12'; - const initialBlocks: ScheduledBlock[] = [ - { - id: 'block-1', - templateId: 'tpl-1', - day: 'lun', - startTime: '09:00', - done: false, - calendarEventId: 'cal-event-1', - }, - { - id: 'block-2', - templateId: 'tpl-2', - day: 'mar', - startTime: '14:00', - done: true, - calendarEventId: 'cal-event-2', - }, - { - id: 'block-3', - day: 'mer', - startTime: '10:00', - done: false, - isOneOff: true, - oneOffName: 'Meeting', - oneOffCategoryId: 'cat-1', - oneOffDuration: 1, - }, - ]; - - const plan: WeeklyPlan = { - weekId, - blocks: initialBlocks, - }; - await saveWeeklyPlan(plan); - - const { result } = await renderHook(() => usePlanner(), { wrapper }); - - await act(async () => { - await result.current.changeWeek(weekId); - }); - - expect(result.current.currentPlan.blocks).toHaveLength(3); - - await act(async () => { - await result.current.clearCurrentWeek(); - }); - - expect(result.current.currentPlan.blocks).toEqual([]); - - expect(mockDeleteCalendarEvent).toHaveBeenCalledTimes(2); - expect(mockDeleteCalendarEvent).toHaveBeenCalledWith('cal-event-1'); - expect(mockDeleteCalendarEvent).toHaveBeenCalledWith('cal-event-2'); - - const storedPlan = await loadWeeklyPlan(weekId); - expect(storedPlan).toEqual({ - weekId, - blocks: [], + describe('initialization & lifecycle', () => { + it('throws error when usePlanner is used outside PlannerProvider', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + await expect(renderHook(() => usePlanner())).rejects.toThrow( + 'usePlanner must be used within PlannerProvider' + ); + consoleError.mockRestore(); }); - }); - it('handles clearing an already empty week without errors', async () => { - const weekId = '2026-10-12'; - const { result } = await renderHook(() => usePlanner(), { wrapper }); + it('initializes with default categories when storage is empty', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + expect(result.current.categories.length).toBeGreaterThan(0); + expect(result.current.categories.some((c) => c.id === 'sonno')).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('migrates legacy category names and emojis on load', async () => { + const legacyCats: Category[] = [ + { + id: 'lettura', + name: 'altro', + color: '#F59E0B', + emoji: '😴', + targetHoursPerWeek: 3, + }, + { + id: 'progetto', + name: 'other', + color: '#EC4899', + emoji: 'invalid-emoji-🔥', + targetHoursPerWeek: 2, + }, + { + id: 'custom', + name: 'Altro', + color: '#10B981', + emoji: 'shape', + targetHoursPerWeek: 4, + isCustomName: true, + }, + ]; + await saveCategories(legacyCats); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); - await act(async () => { - await result.current.changeWeek(weekId); + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + const lettura = result.current.categories.find((c) => c.id === 'lettura'); + expect(lettura?.emoji).toBe('bed'); + expect(lettura?.name).not.toBe('altro'); + + const custom = result.current.categories.find((c) => c.id === 'custom'); + expect(custom?.name).toBe('Altro'); }); - expect(result.current.currentPlan.blocks).toEqual([]); + it('handles app state change to active and refreshes real week', async () => { + let appStateListener: ((state: AppStateStatus) => void) | undefined; + jest.spyOn(AppState, 'addEventListener').mockImplementation((event, listener) => { + if (event === 'change') { + appStateListener = listener as (state: AppStateStatus) => void; + } + return { remove: jest.fn() } as any; + }); + + const getCurrentWeekIdSpy = jest.spyOn(weekUtils, 'getCurrentWeekId'); + getCurrentWeekIdSpy.mockReturnValue('2026-10-12'); - await act(async () => { - await result.current.clearCurrentWeek(); + await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + getCurrentWeekIdSpy.mockReturnValue('2026-10-19'); + + await act(async () => { + appStateListener?.('active'); + }); + + getCurrentWeekIdSpy.mockRestore(); }); - expect(result.current.currentPlan.blocks).toEqual([]); - expect(mockDeleteCalendarEvent).not.toHaveBeenCalled(); + it('handles 60s timer interval for real week check', async () => { + const getCurrentWeekIdSpy = jest.spyOn(weekUtils, 'getCurrentWeekId'); + getCurrentWeekIdSpy.mockReturnValue('2026-10-12'); + + await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + jest.advanceTimersByTime(60000); + }); + + getCurrentWeekIdSpy.mockRestore(); + }); + + it('logs error when refreshData fails', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (loadCategories as jest.Mock).mockRejectedValueOnce(new Error('Load failed')); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.refreshData(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Errore caricamento planner:', + expect.any(Error) + ); + consoleErrorSpy.mockRestore(); + }); + + it('refreshes data when current week differs from real week', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.changeWeek('2026-11-01'); + }); - const storedPlan = await loadWeeklyPlan(weekId); - expect(storedPlan).toEqual({ - weekId, - blocks: [], + await act(async () => { + await result.current.refreshData(); + }); + + expect(result.current.currentWeekId).toBe('2026-11-01'); }); }); - it('handles clearing when plans for current week is not yet loaded', async () => { - const { result } = await renderHook(() => usePlanner(), { wrapper }); + describe('category management', () => { + it('adds, edits, archives, unarchives and deletes categories', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addCategory('Climbing', 'mountain', '#EF4444', 6); + }); + + const added = result.current.categories.find((c) => c.name === 'Climbing'); + expect(added).toBeDefined(); + expect(added?.color).toBe('#EF4444'); + expect(added?.targetHoursPerWeek).toBe(6); + + const catId = added!.id; + + await act(async () => { + result.current.editCategory(catId, { name: 'Indoor Climbing', targetHoursPerWeek: 8 }); + }); + + expect(result.current.getCategoryById(catId)?.name).toBe('Indoor Climbing'); + expect(result.current.getCategoryById(catId)?.targetHoursPerWeek).toBe(8); + + await act(async () => { + result.current.archiveCategory(catId); + }); + expect(result.current.getCategoryById(catId)?.isArchived).toBe(true); + + await act(async () => { + result.current.unarchiveCategory(catId); + }); + expect(result.current.getCategoryById(catId)?.isArchived).toBe(false); + + await act(async () => { + result.current.addTemplate('Bouldering', catId, 2); + }); + const tpl = result.current.templates.find((t) => t.name === 'Bouldering'); + expect(tpl).toBeDefined(); + + await act(async () => { + result.current.scheduleBlock(tpl!.id, 'lun', '10:00'); + result.current.scheduleOneOffBlock('Free Climb', catId, 1, 'mar', '12:00'); + }); + + expect(result.current.currentPlan.blocks).toHaveLength(2); - await act(async () => { - await result.current.clearCurrentWeek(); + await act(async () => { + result.current.deleteCategory(catId); + }); + + expect(result.current.getCategoryById(catId)).toBeUndefined(); + expect(result.current.templates.find((t) => t.categoryId === catId)).toBeUndefined(); + expect(result.current.currentPlan.blocks).toHaveLength(0); }); + }); + + describe('template management', () => { + it('adds, archives, unarchives and deletes templates', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addTemplate('Guitar Practice', 'hobby', 1, 'peak-1'); + }); + + const tpl = result.current.templates.find((t) => t.name === 'Guitar Practice'); + expect(tpl).toBeDefined(); + expect(tpl?.peakId).toBe('peak-1'); + + const tplId = tpl!.id; + + await act(async () => { + result.current.archiveTemplate(tplId); + }); + expect(result.current.getTemplateById(tplId)?.isArchived).toBe(true); + + await act(async () => { + result.current.unarchiveTemplate(tplId); + }); + expect(result.current.getTemplateById(tplId)?.isArchived).toBe(false); + + await act(async () => { + result.current.scheduleBlock(tplId, 'mer', '15:00'); + }); + expect(result.current.currentPlan.blocks.some((b) => b.templateId === tplId)).toBe(true); + + await act(async () => { + result.current.deleteTemplate(tplId); + }); - expect(result.current.currentPlan.blocks).toEqual([]); + expect(result.current.getTemplateById(tplId)).toBeUndefined(); + expect(result.current.currentPlan.blocks.some((b) => b.templateId === tplId)).toBe(false); + }); }); - it('catches and logs error when saveWeeklyPlan fails during clearCurrentWeek', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - (saveWeeklyPlan as jest.Mock).mockRejectedValue(new Error('Storage failure')); + describe('scheduling & block operations', () => { + it('schedules a template block and syncs calendar event', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addTemplate('Deep Work', 'cp', 2); + }); + + const tpl = result.current.templates.find((t) => t.name === 'Deep Work')!; - const { result } = await renderHook(() => usePlanner(), { wrapper }); + await act(async () => { + result.current.scheduleBlock(tpl.id, 'gio', '09:00', 3); + }); - await act(async () => { - await result.current.clearCurrentWeek(); + const scheduled = result.current.currentPlan.blocks.find((b) => b.templateId === tpl.id); + expect(scheduled).toBeDefined(); + expect(scheduled?.customDuration).toBe(3); + expect(mockCreateCalendarEvent).toHaveBeenCalled(); }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Errore cancellazione settimana:', - expect.any(Error) - ); - consoleErrorSpy.mockRestore(); - (saveWeeklyPlan as jest.Mock).mockImplementation( - jest.requireActual('../plannerStorage').saveWeeklyPlan - ); + it('handles calendar event creation failure on scheduleBlock gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockCreateCalendarEvent.mockRejectedValueOnce(new Error('Calendar offline')); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addTemplate('Quick Task', 'libero', 1); + }); + + const tpl = result.current.templates.find((t) => t.name === 'Quick Task')!; + + await act(async () => { + result.current.scheduleBlock(tpl.id, 'ven', '11:00'); + }); + + expect(result.current.currentPlan.blocks.some((b) => b.templateId === tpl.id)).toBe(true); + consoleErrorSpy.mockRestore(); + }); + + it('schedules one-off block and syncs calendar event', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.scheduleOneOffBlock('Doctor Visit', 'libero', 1, 'sab', '16:00'); + }); + + const oneOff = result.current.currentPlan.blocks.find((b) => b.oneOffName === 'Doctor Visit'); + expect(oneOff).toBeDefined(); + expect(oneOff?.isOneOff).toBe(true); + expect(mockCreateCalendarEvent).toHaveBeenCalled(); + }); + + it('handles calendar failure on scheduleOneOffBlock gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockCreateCalendarEvent.mockRejectedValueOnce(new Error('Calendar error')); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.scheduleOneOffBlock('Dentist', 'libero', 1, 'dom', '10:00'); + }); + + expect(result.current.currentPlan.blocks.some((b) => b.oneOffName === 'Dentist')).toBe(true); + consoleErrorSpy.mockRestore(); + }); + + it('unschedules block and removes calendar event', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.scheduleOneOffBlock('Gym', 'hobby', 1.5, 'lun', '18:00'); + }); + + const block = result.current.currentPlan.blocks.find((b) => b.oneOffName === 'Gym')!; + + await act(async () => { + result.current.unscheduleBlock(block.id); + }); + + expect(result.current.currentPlan.blocks.find((b) => b.id === block.id)).toBeUndefined(); + }); + + it('unscheduleBlock does nothing if block id is not found', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.unscheduleBlock('non-existent-block'); + }); + + expect(result.current.currentPlan.blocks).toEqual([]); + }); + + it('toggles block done state', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.scheduleOneOffBlock('Meditate', 'hobby', 0.5, 'lun', '08:00'); + }); + + const blockId = result.current.currentPlan.blocks[0].id; + expect(result.current.currentPlan.blocks[0].done).toBe(false); + + await act(async () => { + result.current.toggleBlockDone(blockId); + }); + expect(result.current.currentPlan.blocks[0].done).toBe(true); + + await act(async () => { + result.current.toggleBlockDone(blockId); + }); + expect(result.current.currentPlan.blocks[0].done).toBe(false); + + await act(async () => { + result.current.toggleBlockDone('non-existent-id'); + }); + }); + + it('updates block description and syncs with calendar', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.scheduleOneOffBlock('Review', 'cp', 1, 'mar', '14:00'); + }); + + const block = result.current.currentPlan.blocks[0]; + + await act(async () => { + result.current.updateBlockDescription(block.id, 'Detailed notes for PR review'); + }); + + expect(result.current.currentPlan.blocks[0].description).toBe('Detailed notes for PR review'); + expect(mockUpdateCalendarEventDescription).toHaveBeenCalledWith( + 'event-123', + 'Detailed notes for PR review' + ); + + await act(async () => { + result.current.updateBlockDescription('non-existent-id', 'test'); + }); + }); }); - it('handles calendar event deletion failures gracefully', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mockDeleteCalendarEvent.mockRejectedValueOnce(new Error('Calendar error')); + describe('week navigation & copying', () => { + it('changes week and updates hasPreviousWeekBlocks', async () => { + const currentWeek = '2026-10-12'; + const prevWeek = '2026-10-05'; + + await saveWeeklyPlan({ + weekId: prevWeek, + blocks: [{ id: 'b-prev', templateId: 't1', day: 'lun', startTime: '09:00', done: false }], + }); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.changeWeek(currentWeek); + }); + + expect(result.current.currentWeekId).toBe(currentWeek); + expect(result.current.hasPreviousWeekBlocks).toBe(true); + }); + + it('logs error when changeWeek fails', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (loadWeeklyPlan as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + await act(async () => { + await result.current.changeWeek('2026-11-01'); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Errore cambio settimana:', expect.any(Error)); + consoleErrorSpy.mockRestore(); + }); + + it('copies recurring blocks from previous week, skipping one-offs', async () => { + const currentWeek = '2026-10-12'; + const prevWeek = '2026-10-05'; + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addTemplate('Lecture', 'uni', 2); + }); + const tpl = result.current.templates.find((t) => t.name === 'Lecture')!; + + await saveWeeklyPlan({ + weekId: prevWeek, + blocks: [ + { + id: 'b-recurring', + templateId: tpl.id, + day: 'lun', + startTime: '10:00', + done: true, + customDuration: 2, + }, + { + id: 'b-oneoff', + day: 'mar', + startTime: '11:00', + done: false, + isOneOff: true, + oneOffName: 'OneOff', + oneOffCategoryId: 'uni', + oneOffDuration: 1, + }, + ], + }); + + await act(async () => { + await result.current.changeWeek(currentWeek); + }); + + await act(async () => { + await result.current.copyPreviousWeek(); + }); + + expect(result.current.currentPlan.blocks).toHaveLength(1); + expect(result.current.currentPlan.blocks[0].templateId).toBe(tpl.id); + expect(result.current.currentPlan.blocks[0].done).toBe(false); + }); + + it('handles empty previous week when copying gracefully', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.copyPreviousWeek(); + }); + + expect(result.current.currentPlan.blocks).toEqual([]); + }); - const weekId = '2026-10-12'; - const plan: WeeklyPlan = { - weekId, - blocks: [ + it('handles calendar event creation failure in copyPreviousWeek gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockCreateCalendarEvent.mockRejectedValueOnce(new Error('Bulk cal error')); + + const currentWeek = '2026-10-12'; + const prevWeek = '2026-10-05'; + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addTemplate('Seminar', 'uni', 1); + }); + const tpl = result.current.templates.find((t) => t.name === 'Seminar')!; + + await saveWeeklyPlan({ + weekId: prevWeek, + blocks: [ + { + id: 'b-sem', + templateId: tpl.id, + day: 'mer', + startTime: '14:00', + done: false, + }, + ], + }); + + await act(async () => { + await result.current.changeWeek(currentWeek); + }); + + await act(async () => { + await result.current.copyPreviousWeek(); + }); + + expect(result.current.currentPlan.blocks).toHaveLength(1); + consoleErrorSpy.mockRestore(); + }); + + it('catches and logs error when copyPreviousWeek fails', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (loadWeeklyPlan as jest.Mock).mockRejectedValueOnce(new Error('Copy load error')); + + await act(async () => { + await result.current.copyPreviousWeek(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Errore copia settimana precedente:', + expect.any(Error) + ); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('hours & metrics calculation', () => { + it('calculates scheduled, completed and target hours correctly for a category', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + result.current.addCategory('Reading Track', 'book', '#F59E0B', 10); + }); + const cat = result.current.categories.find((c) => c.name === 'Reading Track')!; + + await act(async () => { + result.current.addTemplate('Book Chapter', cat.id, 2); + }); + const tpl = result.current.templates.find((t) => t.name === 'Book Chapter')!; + + await act(async () => { + result.current.scheduleBlock(tpl.id, 'lun', '09:00', 3); + result.current.scheduleBlock(tpl.id, 'mar', '09:00'); + }); + + const block2 = result.current.currentPlan.blocks.find( + (b) => b.day === 'mar' && b.templateId === tpl.id + )!; + + await act(async () => { + result.current.toggleBlockDone(block2.id); + }); + + const hours = result.current.getCategoryHours(cat.id); + expect(hours.scheduled).toBe(5); + expect(hours.completed).toBe(2); + expect(hours.target).toBe(10); + }); + + it('returns 0 hours for non-existent category', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + const hours = result.current.getCategoryHours('unknown-category'); + expect(hours).toEqual({ + scheduled: 0, + completed: 0, + target: 0, + }); + }); + }); + + describe('clearCurrentWeek', () => { + it('clears all scheduled blocks, removes calendar events, and persists empty plan', async () => { + const weekId = '2026-10-12'; + const initialBlocks: ScheduledBlock[] = [ { - id: 'block-fail', + id: 'block-1', templateId: 'tpl-1', day: 'lun', startTime: '09:00', done: false, - calendarEventId: 'event-fail', + calendarEventId: 'cal-event-1', + }, + { + id: 'block-2', + templateId: 'tpl-2', + day: 'mar', + startTime: '14:00', + done: true, + calendarEventId: 'cal-event-2', + }, + { + id: 'block-3', + day: 'mer', + startTime: '10:00', + done: false, + isOneOff: true, + oneOffName: 'Meeting', + oneOffCategoryId: 'cat-1', + oneOffDuration: 1, }, - ], - }; - await saveWeeklyPlan(plan); + ]; + + const plan: WeeklyPlan = { + weekId, + blocks: initialBlocks, + }; + await saveWeeklyPlan(plan); - const { result } = await renderHook(() => usePlanner(), { wrapper }); + const { result } = await renderHook(() => usePlanner(), { wrapper }); - await act(async () => { - await result.current.changeWeek(weekId); + await act(async () => { + await result.current.changeWeek(weekId); + }); + + expect(result.current.currentPlan.blocks).toHaveLength(3); + + await act(async () => { + await result.current.clearCurrentWeek(); + }); + + expect(result.current.currentPlan.blocks).toEqual([]); + + expect(mockDeleteCalendarEvent).toHaveBeenCalledTimes(2); + expect(mockDeleteCalendarEvent).toHaveBeenCalledWith('cal-event-1'); + expect(mockDeleteCalendarEvent).toHaveBeenCalledWith('cal-event-2'); + + const storedPlan = await loadWeeklyPlan(weekId); + expect(storedPlan).toEqual({ + weekId, + blocks: [], + }); + }); + + it('handles clearing an already empty week without errors', async () => { + const weekId = '2026-10-12'; + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.changeWeek(weekId); + }); + + expect(result.current.currentPlan.blocks).toEqual([]); + + await act(async () => { + await result.current.clearCurrentWeek(); + }); + + expect(result.current.currentPlan.blocks).toEqual([]); + expect(mockDeleteCalendarEvent).not.toHaveBeenCalled(); + + const storedPlan = await loadWeeklyPlan(weekId); + expect(storedPlan).toEqual({ + weekId, + blocks: [], + }); + }); + + it('handles clearing when plans for current week is not yet loaded', async () => { + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.clearCurrentWeek(); + }); + + expect(result.current.currentPlan.blocks).toEqual([]); }); - await act(async () => { - await result.current.clearCurrentWeek(); + it('catches and logs error when saveWeeklyPlan fails during clearCurrentWeek', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (saveWeeklyPlan as jest.Mock).mockRejectedValue(new Error('Storage failure')); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.clearCurrentWeek(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Errore cancellazione settimana:', + expect.any(Error) + ); + consoleErrorSpy.mockRestore(); }); - expect(mockDeleteCalendarEvent).toHaveBeenCalledWith('event-fail'); - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error)); - consoleErrorSpy.mockRestore(); + it('handles calendar event deletion failures gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockDeleteCalendarEvent.mockRejectedValueOnce(new Error('Calendar error')); + + const weekId = '2026-10-12'; + const plan: WeeklyPlan = { + weekId, + blocks: [ + { + id: 'block-fail', + templateId: 'tpl-1', + day: 'lun', + startTime: '09:00', + done: false, + calendarEventId: 'event-fail', + }, + ], + }; + await saveWeeklyPlan(plan); + + const { result } = await renderHook(() => usePlanner(), { wrapper }); + + await act(async () => { + await result.current.changeWeek(weekId); + }); + + await act(async () => { + await result.current.clearCurrentWeek(); + }); + + expect(mockDeleteCalendarEvent).toHaveBeenCalledWith('event-fail'); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error)); + consoleErrorSpy.mockRestore(); + }); }); });