318 lines
9.1 KiB
TypeScript
318 lines
9.1 KiB
TypeScript
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<string, any> | 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<string, any> | 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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
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(<CalendarPage />)
|
|
|
|
expect(await screen.findByText(/Failed to load calendar/i)).toBeInTheDocument()
|
|
})
|
|
})
|