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