HarborForge.Frontend: proposal/essential tests on current branch head #11
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": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10"
|
||||
"react-router-dom": "^6.22.0"
|
||||
},
|
||||
"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-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"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