import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' import userEvent from '@testing-library/user-event' import CalendarPage from '@/pages/CalendarPage' const mockGet = vi.fn() const mockPost = vi.fn() vi.mock('@/services/api', () => ({ default: { get: (...args: any[]) => mockGet(...args), post: (...args: any[]) => mockPost(...args), }, })) type Slot = { slot_id: string date: string slot_type: 'work' | 'on_call' | 'entertainment' | 'system' estimated_duration: number scheduled_at: string started_at: string | null attended: boolean actual_duration: number | null event_type: 'job' | 'entertainment' | 'system_event' | null event_data: Record | null priority: number status: 'not_started' | 'ongoing' | 'deferred' | 'skipped' | 'paused' | 'finished' | 'aborted' plan_id: number | null is_virtual: boolean created_at: string | null updated_at: string | null } type Plan = { id: number slot_type: 'work' | 'on_call' | 'entertainment' | 'system' estimated_duration: number event_type: string | null event_data: Record | null at_time: string on_day: string | null on_week: number | null on_month: string | null is_active: boolean created_at: string updated_at: string | null } const slots: Slot[] = [ { slot_id: '1', date: '2026-04-01', slot_type: 'work', estimated_duration: 25, scheduled_at: '09:00', started_at: null, attended: false, actual_duration: null, event_type: 'job', event_data: { type: 'Task', code: 'TASK-1' }, priority: 50, status: 'not_started', plan_id: null, is_virtual: false, created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-01T00:00:00Z', }, { slot_id: 'plan-5-2026-04-01', date: '2026-04-01', slot_type: 'on_call', estimated_duration: 30, scheduled_at: '14:00', started_at: null, attended: false, actual_duration: null, event_type: null, event_data: null, priority: 70, status: 'deferred', plan_id: 5, is_virtual: true, created_at: null, updated_at: null, }, ] const plans: Plan[] = [ { id: 5, slot_type: 'on_call', estimated_duration: 30, event_type: null, event_data: null, at_time: '14:00', on_day: 'Mon', on_week: null, on_month: null, is_active: true, created_at: '2026-03-01T00:00:00Z', updated_at: null, }, { id: 6, slot_type: 'work', estimated_duration: 50, event_type: 'system_event', event_data: { event: 'ScheduleToday' }, at_time: '08:00', on_day: 'Tue', on_week: 1, on_month: null, is_active: false, created_at: '2026-03-01T00:00:00Z', updated_at: null, }, ] function setupApi(options?: { slots?: Slot[] plans?: Plan[] dayError?: string postImpl?: (url: string, payload?: any) => any }) { const currentSlots = options?.slots ?? slots const currentPlans = options?.plans ?? plans mockGet.mockImplementation((url: string) => { if (url.startsWith('/calendar/day')) { if (options?.dayError) { return Promise.reject({ response: { data: { detail: options.dayError } } }) } return Promise.resolve({ data: currentSlots }) } if (url === '/calendar/plans') { return Promise.resolve({ data: currentPlans }) } return Promise.reject(new Error(`Unhandled GET ${url}`)) }) mockPost.mockImplementation((url: string, payload?: any) => { if (options?.postImpl) return options.postImpl(url, payload) return Promise.resolve({ data: {} }) }) } describe('CalendarPage', () => { beforeEach(() => { vi.clearAllMocks() setupApi() vi.spyOn(window, 'confirm').mockReturnValue(true) }) afterEach(() => { cleanup() vi.restoreAllMocks() }) it('renders daily slots, deferred notice, and virtual-plan badge', async () => { render() expect(await screen.findByText('09:00')).toBeInTheDocument() expect(screen.getByText('14:00')).toBeInTheDocument() expect(screen.getAllByText(/deferred/i).length).toBeGreaterThan(0) expect(screen.getByText(/agent unavailability/i)).toBeInTheDocument() expect(screen.getByText(/TASK-1/i)).toBeInTheDocument() expect(screen.getByText(/^plan$/i)).toBeInTheDocument() expect(screen.getByText(/Priority: 50/i)).toBeInTheDocument() }) it('renders plans tab content', async () => { render() await screen.findByText('09:00') await userEvent.click(screen.getByRole('button', { name: /Plans \(2\)/i })) expect(await screen.findByText(/Plan #5/i)).toBeInTheDocument() expect(screen.getByText(/Plan #6/i)).toBeInTheDocument() expect(screen.getByText(/at 14:00 · on Mon/i)).toBeInTheDocument() expect(screen.getByText(/inactive/i)).toBeInTheDocument() }) it('opens create modal and toggles event-specific fields', async () => { render() await screen.findByText('09:00') await userEvent.click(screen.getByRole('button', { name: /\+ New Slot/i })) expect(screen.getByRole('heading', { name: /New Slot/i })).toBeInTheDocument() const selects = screen.getAllByRole('combobox') const eventTypeSelect = selects[1] fireEvent.change(eventTypeSelect, { target: { value: 'job' } }) expect(screen.getByLabelText(/Job Code/i)).toBeInTheDocument() fireEvent.change(eventTypeSelect, { target: { value: 'system_event' } }) expect(screen.getByLabelText(/System Event/i)).toBeInTheDocument() }) it('submits new slot and displays workload warnings', async () => { setupApi({ postImpl: (url: string) => { if (url === '/calendar/schedule') { return Promise.resolve({ data: { slot: slots[0], warnings: [ { period: 'daily', slot_type: 'work', current: 25, minimum: 120, message: 'Daily work minimum not met', }, ], }, }) } return Promise.resolve({ data: {} }) }, }) render() await screen.findByText('09:00') await userEvent.click(screen.getByRole('button', { name: /\+ New Slot/i })) await userEvent.click(screen.getByRole('button', { name: /^Save$/i })) await waitFor(() => { expect(mockPost).toHaveBeenCalledWith( '/calendar/schedule', expect.objectContaining({ slot_type: 'work', scheduled_at: '09:00', estimated_duration: 25, priority: 50, date: '2026-04-01', }), ) }) expect(await screen.findByText(/Workload Warnings/i)).toBeInTheDocument() expect(screen.getByText(/Daily work minimum not met/i)).toBeInTheDocument() }) it('shows overlap error when save fails with overlap detail', async () => { setupApi({ postImpl: (url: string) => { if (url === '/calendar/schedule') { return Promise.reject({ response: { data: { detail: 'Slot overlap with existing slot at 09:00' } }, }) } return Promise.resolve({ data: {} }) }, }) render() await screen.findByText('09:00') await userEvent.click(screen.getByRole('button', { name: /\+ New Slot/i })) await userEvent.click(screen.getByRole('button', { name: /^Save$/i })) expect(await screen.findByText(/Overlap conflict/i)).toBeInTheDocument() }) it('opens edit modal with existing slot values and submits edit request', async () => { setupApi({ postImpl: (url: string) => { if (url.startsWith('/calendar/edit')) { return Promise.resolve({ data: { slot: slots[0], warnings: [] } }) } return Promise.resolve({ data: {} }) }, }) render() await screen.findByText('09:00') await userEvent.click(screen.getAllByRole('button', { name: /✏️ Edit/i })[0]) expect(screen.getByRole('heading', { name: /Edit Slot/i })).toBeInTheDocument() expect((screen.getAllByRole('combobox')[0] as HTMLSelectElement).value).toBe('work') expect((document.querySelector('input[type="time"]') as HTMLInputElement).value).toBe('09:00') await userEvent.click(screen.getByRole('button', { name: /^Save$/i })) await waitFor(() => { expect(mockPost).toHaveBeenCalledWith( expect.stringContaining('/calendar/edit?date=2026-04-01&slot_id=1'), expect.objectContaining({ slot_type: 'work' }), ) }) }) it('cancels a slot after confirmation', async () => { render() await screen.findByText('09:00') await userEvent.click(screen.getAllByRole('button', { name: /❌ Cancel/i })[0]) await waitFor(() => { expect(window.confirm).toHaveBeenCalled() expect(mockPost).toHaveBeenCalledWith('/calendar/cancel?date=2026-04-01&slot_id=1') }) }) it('shows fetch error banner when day loading fails', async () => { setupApi({ dayError: 'Failed to load calendar' }) render() expect(await screen.findByText(/Failed to load calendar/i)).toBeInTheDocument() }) })