import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ref } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; import { useBreakPlacement } from './useBreakPlacement'; import { NoFreeWindowError } from './cellMath'; import { api } from '@/packages/api/src'; import type { TimeEntry } from '@/packages/api/src'; import type { TimesheetRow } from '@/utils/useTimesheetGrid'; const addNotification = vi.fn(); const mutationOptionsSpy = vi.hoisted(() => vi.fn()); vi.mock('@/utils/useUser', () => ({ getCurrentOrganizationId: vi.fn(() => 'org-1'), getCurrentMembershipId: vi.fn(() => 'mem-1'), })); vi.mock('@tanstack/vue-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), useMutation: (options: { mutationFn: (variables: unknown) => Promise; onSuccess?: (data: unknown, variables: unknown) => void | Promise; onError?: (error: unknown, variables: unknown) => void | Promise; onSettled?: () => void | Promise; }) => { mutationOptionsSpy(options); return { mutateAsync: async (variables: unknown) => { try { const data = await options.mutationFn(variables); await options.onSuccess?.(data, variables); return data; } catch (error) { await options.onError?.(error, variables); throw error; } finally { await options.onSettled?.(); } }, }; }, })); vi.mock('@/utils/notification', () => ({ useNotificationsStore: () => ({ addNotification }), })); vi.mock('@/packages/api/src', () => ({ api: { createTimeEntry: vi.fn(async () => ({ data: { id: 'new-id' } })), updateTimeEntry: vi.fn(async () => undefined), deleteTimeEntry: vi.fn(async () => undefined), deleteTimeEntries: vi.fn(async () => undefined), }, })); const apiMocks = vi.mocked(api); const DATE = '2026-04-10'; const HOUR = 3600; function entry(start: string, end: string | null, overrides: Partial = {}): TimeEntry { return { id: overrides.id ?? `e-${start}`, start, end, description: '', member_id: 'mem-1', project_id: 'p-1', task_id: null, billable: false, tags: [], type: 'work', ...overrides, } as unknown as TimeEntry; } const breakRow: TimesheetRow = { key: 'break-row', projectId: null, taskId: null, billable: false, tags: [], type: 'break', cells: new Map(), totalSeconds: 0, }; function setup(allEntries: TimeEntry[], preventOverlaps = false) { const createCell = vi.fn(async () => undefined); const updateEntry = vi.fn(async (_entry: TimeEntry) => undefined); const deleteEntry = vi.fn(async (_id: string) => undefined); const bp = useBreakPlacement({ weekDays: ref([DATE, '2026-04-11', '2026-04-12']), timeEntries: ref(allEntries), requireOrgId: () => 'org-1', createCell, updateEntry, deleteEntry, preventOverlappingTimeEntries: () => preventOverlaps, }); return { bp, createCell, updateEntry, deleteEntry }; } beforeEach(() => { setActivePinia(createPinia()); apiMocks.createTimeEntry.mockClear(); apiMocks.updateTimeEntry.mockClear(); apiMocks.deleteTimeEntry.mockClear(); addNotification.mockClear(); mutationOptionsSpy.mockClear(); }); describe('useBreakPlacement.placeBreak', () => { it('saves the break directly when it drops into a valid gap', async () => { const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'morning', }); const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', { id: 'afternoon', }); const { bp } = setup([morning, afternoon]); await bp.placeBreak(breakRow, 0, HOUR); // exactly fills the 12:00-13:00 gap expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual( expect.objectContaining({ type: 'break', start: '2026-04-10T12:00:00Z', end: '2026-04-10T13:00:00Z', }) ); expect(bp.breakPlacementRequest.value).toBeNull(); }); it('never places a break over a running entry', async () => { const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'morning' }); const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', { id: 'afternoon', }); const running = entry('2026-04-10T12:30:00Z', null, { id: 'running' }); const { bp } = setup([morning, afternoon, running]); // Centered placement (12:15-12:45) would overlap the running entry, so // the break slides to the free part of the gap instead. await bp.placeBreak(breakRow, 0, HOUR / 2); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual( expect.objectContaining({ type: 'break', start: '2026-04-10T12:00:00Z', end: '2026-04-10T12:30:00Z', }) ); }); it('drops a new break into the first free window when the day has no work', async () => { const { bp, createCell } = setup([]); await bp.placeBreak(breakRow, 0, HOUR); expect(createCell).toHaveBeenCalledWith(breakRow, 0, HOUR); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); it('resizes a break on a workless day in place while it still fits', async () => { const brk = entry('2026-04-10T20:00:00Z', '2026-04-10T20:30:00Z', { id: 'b1', type: 'break', }); const { bp, updateEntry } = setup([brk]); await bp.placeBreak(breakRow, 0, HOUR, 'b1'); expect(updateEntry).toHaveBeenCalledWith( expect.objectContaining({ id: 'b1', start: '2026-04-10T20:00:00Z', end: '2026-04-10T21:00:00Z', }) ); }); it('requests input in the split modal when a single work entry blocks every gap', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' }); const { bp } = setup([work]); await expect(bp.placeBreak(breakRow, 0, HOUR)).resolves.toBe('needs-input'); expect(bp.breakPlacementRequest.value).toEqual( expect.objectContaining({ durationSeconds: HOUR, replaceBreakId: null, workEntries: [expect.objectContaining({ id: 'w1' })], }) ); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); }); describe('useBreakPlacement.applyBreakPlacement (split)', () => { it('configures the logical placement as a serialized, non-retrying mutation', () => { setup([]); expect(mutationOptionsSpy).toHaveBeenCalledWith( expect.objectContaining({ mutationKey: ['timesheet', 'break-placement'], scope: { id: 'timesheet-break-placement' }, retry: false, }) ); }); it('shrinks the original, pushes the rest of the work out, and saves the break', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' }); const { bp, updateEntry } = setup([work]); await bp.placeBreak(breakRow, 0, HOUR); await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR); expect(updateEntry).toHaveBeenCalledWith( expect.objectContaining({ id: 'w1', start: '2026-04-10T09:00:00Z', end: '2026-04-10T12:00:00Z', }) ); const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]); expect(created).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'work', start: '2026-04-10T13:00:00Z', end: '2026-04-10T18:00:00Z', }), expect.objectContaining({ type: 'break', start: '2026-04-10T12:00:00Z', end: '2026-04-10T13:00:00Z', }), ]) ); expect(bp.breakPlacementRequest.value).toBeNull(); expect(addNotification).toHaveBeenCalledWith('success', 'Break added', expect.any(String)); }); it('moves an existing break out of the way of the pushed-out work', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' }); const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', { id: 'b1', type: 'break', }); const { bp, updateEntry } = setup([work, existingBreak]); await bp.placeBreak(breakRow, 0, HOUR); await bp.applyBreakPlacement('2026-04-10T10:00:00Z', HOUR); expect(updateEntry).toHaveBeenNthCalledWith( 1, expect.objectContaining({ id: 'w1', start: '2026-04-10T09:00:00Z', end: '2026-04-10T10:00:00Z', }) ); expect(updateEntry).toHaveBeenNthCalledWith( 2, expect.objectContaining({ id: 'b1', start: '2026-04-10T13:00:00Z', end: '2026-04-10T13:15:00Z', }) ); const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]); expect(created).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'work', start: '2026-04-10T11:00:00Z', end: '2026-04-10T13:00:00Z', }), expect.objectContaining({ type: 'break', start: '2026-04-10T10:00:00Z', end: '2026-04-10T11:00:00Z', }), ]) ); }); it('moves a replacement break before creating work through its old slot', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' }); const existingBreak = entry('2026-04-10T17:00:00Z', '2026-04-10T17:30:00Z', { id: 'b1', type: 'break', }); const { bp, updateEntry } = setup([work, existingBreak], true); await bp.placeBreak(breakRow, 0, HOUR, 'b1'); await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR); expect(updateEntry).toHaveBeenNthCalledWith( 1, expect.objectContaining({ id: 'w1', end: '2026-04-10T12:00:00Z' }) ); expect(updateEntry).toHaveBeenNthCalledWith( 2, expect.objectContaining({ id: 'b1', start: '2026-04-10T12:00:00Z', end: '2026-04-10T13:00:00Z', }) ); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual( expect.objectContaining({ type: 'work', start: '2026-04-10T13:00:00Z', end: '2026-04-10T18:00:00Z', }) ); }); it('restores the original entry when creating the second half fails', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' }); const { bp, updateEntry } = setup([work]); apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom')); await bp.placeBreak(breakRow, 0, HOUR); await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow('boom'); expect(updateEntry).toHaveBeenNthCalledWith( 1, expect.objectContaining({ id: 'w1', start: '2026-04-10T09:00:00Z', end: '2026-04-10T12:00:00Z', }) ); expect(updateEntry).toHaveBeenNthCalledWith( 2, expect.objectContaining({ id: 'w1', start: '2026-04-10T09:00:00Z', end: '2026-04-10T17:00:00Z', }) ); expect(addNotification).toHaveBeenCalledWith( 'error', 'Failed to add break', expect.any(String) ); }); it('restores shifted breaks as well as the original when creating work fails', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' }); const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', { id: 'b1', type: 'break', }); const { bp, updateEntry } = setup([work, existingBreak]); apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('work create failed')); await bp.placeBreak(breakRow, 0, HOUR); await expect(bp.applyBreakPlacement('2026-04-10T10:00:00Z', HOUR)).rejects.toThrow( 'work create failed' ); expect(updateEntry.mock.calls.map((call) => call[0])).toEqual([ expect.objectContaining({ id: 'w1', end: '2026-04-10T10:00:00Z' }), expect.objectContaining({ id: 'b1', start: '2026-04-10T13:00:00Z', end: '2026-04-10T13:15:00Z', }), expect.objectContaining({ id: 'b1', start: '2026-04-10T12:00:00Z', end: '2026-04-10T12:15:00Z', }), expect.objectContaining({ id: 'w1', end: '2026-04-10T12:00:00Z' }), ]); }); it('deletes the created second half and restores updates when saving the break fails', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' }); const { bp, updateEntry, deleteEntry } = setup([work]); apiMocks.createTimeEntry .mockResolvedValueOnce({ data: { id: 'second-half' } } as never) .mockRejectedValueOnce(new Error('break create failed')); await bp.placeBreak(breakRow, 0, HOUR); await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow( 'break create failed' ); expect(deleteEntry).toHaveBeenCalledWith('second-half'); expect(updateEntry).toHaveBeenLastCalledWith( expect.objectContaining({ id: 'w1', end: '2026-04-10T17:00:00Z' }) ); expect(bp.breakPlacementRequest.value).not.toBeNull(); }); it('surfaces incomplete recovery and closes a stale placement request', async () => { const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' }); const { bp, updateEntry } = setup([work]); updateEntry .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('restore failed')); apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('create failed')); await bp.placeBreak(breakRow, 0, HOUR); await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow( 'create failed' ); expect(addNotification).toHaveBeenCalledWith( 'error', 'Break placement needs attention', expect.any(String) ); expect(bp.breakPlacementRequest.value).toBeNull(); }); }); describe('useBreakPlacement.applyBreakPlacement (move)', () => { it('rejects a replacement dependency cycle before changing any entry', async () => { const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'morning', }); const afternoon = entry('2026-04-10T12:00:00Z', '2026-04-10T17:00:00Z', { id: 'afternoon', }); const existingBreak = entry('2026-04-10T17:00:00Z', '2026-04-10T17:30:00Z', { id: 'b1', type: 'break', }); const { bp, updateEntry } = setup([morning, afternoon, existingBreak], true); await bp.placeBreak(breakRow, 0, HOUR, 'b1'); await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toBeInstanceOf( NoFreeWindowError ); expect(updateEntry).not.toHaveBeenCalled(); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); });