HarborForge.Frontend: proposal/essential tests on current branch head #11

Merged
hzhang merged 13 commits from pr/dev-2026-03-29-frontend-tests-20260405 into main 2026-04-05 22:07:47 +00:00
5 changed files with 2922 additions and 6 deletions
Showing only changes of commit e4804128f6 - Show all commits

2528
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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)

View File

@@ -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}'],
},
})