TEST-FE-CAL-001 add calendar frontend tests
This commit is contained in:
2528
package-lock.json
generated
2528
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -6,20 +6,30 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0"
|
||||||
"axios": "^1.6.7",
|
|
||||||
"dayjs": "^1.11.10"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"@vitest/ui": "^4.1.2",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vite": "^5.1.0"
|
"vite": "^5.1.0",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
317
src/test/calendar.test.tsx
Normal file
317
src/test/calendar.test.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
56
src/test/setup.ts
Normal file
56
src/test/setup.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn((key: string) => {
|
||||||
|
if (key === 'token') return 'mock-token'
|
||||||
|
if (key === 'HF_BACKEND_BASE_URL') return 'http://localhost:8000'
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
}
|
||||||
|
vi.stubGlobal('localStorage', localStorageMock)
|
||||||
|
|
||||||
|
// Mock window.location
|
||||||
|
delete window.location
|
||||||
|
window.location = Object.defineProperties({}, {
|
||||||
|
pathname: { value: '/calendar', writable: true },
|
||||||
|
href: { value: 'http://localhost/calendar', writable: true },
|
||||||
|
assign: { value: vi.fn(), writable: true },
|
||||||
|
replace: { value: vi.fn(), writable: true },
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||||
|
|
||||||
|
// Mock IntersectionObserver
|
||||||
|
class IntersectionObserverMock {
|
||||||
|
constructor() {}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
|
||||||
@@ -19,4 +19,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
css: true,
|
||||||
|
include: ['src/test/**/*.test.{ts,tsx}'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user