Compare commits

..

7 Commits

Author SHA1 Message Date
h z
f61c506fdb Merge pull request 'HarborForge.Frontend: proposal/essential tests on current branch head' (#11) from pr/dev-2026-03-29-frontend-tests-20260405 into main
Reviewed-on: #11
2026-04-05 22:07:46 +00:00
zhi
38ebd2bbd1 TEST-FE-PR-001: adapt proposal/essential tests for current UI 2026-04-05 20:48:47 +00:00
zhi
83c9cd8fb7 TEST-FE-PR-001: Add Proposal/Essential frontend unit tests
- Essential list rendering and display tests
- Essential create/edit/delete form tests
- Accept modal with milestone selection tests
- Story creation restriction UI tests
- Error handling tests
2026-04-05 20:48:47 +00:00
8014dcd602 feat: add calendar plan create and edit ui 2026-04-04 15:30:03 +00:00
f39e7da33c fix: render calendar overlap conflicts safely 2026-04-04 14:50:42 +00:00
ea841d0d39 fix: use current calendar slot endpoints 2026-04-04 12:09:43 +00:00
a431711ff0 fix: handle wrapped calendar api responses 2026-04-04 10:44:51 +00:00
2 changed files with 837 additions and 11 deletions

View File

@@ -26,6 +26,12 @@ interface TimeSlotResponse {
updated_at: string | null
}
interface DayViewResponse {
date: string
user_id: number
slots: TimeSlotResponse[]
}
interface SchedulePlanResponse {
id: number
slot_type: SlotType
@@ -41,6 +47,13 @@ interface SchedulePlanResponse {
updated_at: string | null
}
interface PlanListResponse {
plans: SchedulePlanResponse[]
}
type Weekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
type MonthName = 'jan' | 'feb' | 'mar' | 'apr' | 'may' | 'jun' | 'jul' | 'aug' | 'sep' | 'oct' | 'nov' | 'dec'
interface WorkloadWarning {
period: string
slot_type: string
@@ -67,6 +80,31 @@ const EVENT_TYPES: { value: EventType; label: string }[] = [
{ value: 'system_event', label: 'System Event' },
]
const WEEKDAYS: { value: Weekday; label: string }[] = [
{ value: 'sun', label: 'Sunday' },
{ value: 'mon', label: 'Monday' },
{ value: 'tue', label: 'Tuesday' },
{ value: 'wed', label: 'Wednesday' },
{ value: 'thu', label: 'Thursday' },
{ value: 'fri', label: 'Friday' },
{ value: 'sat', label: 'Saturday' },
]
const MONTHS: { value: MonthName; label: string }[] = [
{ value: 'jan', label: 'January' },
{ value: 'feb', label: 'February' },
{ value: 'mar', label: 'March' },
{ value: 'apr', label: 'April' },
{ value: 'may', label: 'May' },
{ value: 'jun', label: 'June' },
{ value: 'jul', label: 'July' },
{ value: 'aug', label: 'August' },
{ value: 'sep', label: 'September' },
{ value: 'oct', label: 'October' },
{ value: 'nov', label: 'November' },
{ value: 'dec', label: 'December' },
]
const SLOT_TYPE_ICONS: Record<string, string> = {
work: '💼',
on_call: '📞',
@@ -106,13 +144,27 @@ export default function CalendarPage() {
})
const [slotSaving, setSlotSaving] = useState(false)
const [warnings, setWarnings] = useState<WorkloadWarning[]>([])
const [showPlanModal, setShowPlanModal] = useState(false)
const [editingPlan, setEditingPlan] = useState<SchedulePlanResponse | null>(null)
const [planSaving, setPlanSaving] = useState(false)
const [planForm, setPlanForm] = useState({
slot_type: 'work' as SlotType,
estimated_duration: 25,
at_time: '09:00',
on_day: '' as Weekday | '',
on_week: '' as string,
on_month: '' as MonthName | '',
event_type: '' as string,
event_data_code: '',
event_data_event: '',
})
const fetchSlots = async (date: string) => {
setLoading(true)
setError('')
try {
const { data } = await api.get<TimeSlotResponse[]>(`/calendar/day?date=${date}`)
setSlots(data)
const { data } = await api.get<DayViewResponse>(`/calendar/day?date=${date}`)
setSlots(Array.isArray(data?.slots) ? data.slots : [])
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load calendar')
setSlots([])
@@ -123,8 +175,8 @@ export default function CalendarPage() {
const fetchPlans = async () => {
try {
const { data } = await api.get<SchedulePlanResponse[]>('/calendar/plans')
setPlans(data)
const { data } = await api.get<PlanListResponse>('/calendar/plans')
setPlans(Array.isArray(data?.plans) ? data.plans : [])
} catch (err: any) {
console.error('Failed to load plans:', err)
}
@@ -201,6 +253,84 @@ export default function CalendarPage() {
return null
}
const buildPlanEventData = () => {
if (!planForm.event_type) return null
if (planForm.event_type === 'job') {
return planForm.event_data_code ? { type: 'Task', code: planForm.event_data_code } : null
}
if (planForm.event_type === 'system_event') {
return planForm.event_data_event ? { event: planForm.event_data_event } : null
}
return null
}
const openCreatePlanModal = () => {
setEditingPlan(null)
setPlanForm({
slot_type: 'work',
estimated_duration: 25,
at_time: '09:00',
on_day: '',
on_week: '',
on_month: '',
event_type: '',
event_data_code: '',
event_data_event: '',
})
setError('')
setShowPlanModal(true)
}
const openEditPlanModal = (plan: SchedulePlanResponse) => {
setEditingPlan(plan)
setPlanForm({
slot_type: plan.slot_type,
estimated_duration: plan.estimated_duration,
at_time: plan.at_time.slice(0, 5),
on_day: (plan.on_day?.toLowerCase() as Weekday | undefined) || '',
on_week: plan.on_week ? String(plan.on_week) : '',
on_month: (plan.on_month?.toLowerCase() as MonthName | undefined) || '',
event_type: plan.event_type || '',
event_data_code: plan.event_data?.code || '',
event_data_event: plan.event_data?.event || '',
})
setError('')
setShowPlanModal(true)
}
const handleSavePlan = async () => {
setPlanSaving(true)
setError('')
try {
const payload: any = {
slot_type: planForm.slot_type,
estimated_duration: planForm.estimated_duration,
at_time: planForm.at_time,
on_day: planForm.on_day || null,
on_week: planForm.on_week ? Number(planForm.on_week) : null,
on_month: planForm.on_month || null,
event_type: planForm.event_type || null,
event_data: buildPlanEventData(),
}
if (editingPlan) {
await api.patch(`/calendar/plans/${editingPlan.id}`, payload)
} else {
await api.post('/calendar/plans', payload)
}
setShowPlanModal(false)
fetchPlans()
if (activeTab === 'daily') {
fetchSlots(selectedDate)
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Save plan failed')
} finally {
setPlanSaving(false)
}
}
const handleSaveSlot = async () => {
setSlotSaving(true)
setError('')
@@ -218,12 +348,17 @@ export default function CalendarPage() {
let response: any
if (editingSlot) {
// Edit existing slot
payload.date = selectedDate
response = await api.post(`/calendar/edit?date=${selectedDate}&slot_id=${editingSlot.slot_id}`, payload)
if (editingSlot.is_virtual) {
response = await api.patch(`/calendar/slots/virtual/${editingSlot.slot_id}`, payload)
} else if (editingSlot.slot_id) {
response = await api.patch(`/calendar/slots/${editingSlot.slot_id}`, payload)
} else {
throw new Error('Missing slot identifier for edit')
}
} else {
// Create new slot
payload.date = selectedDate
response = await api.post('/calendar/schedule', payload)
response = await api.post('/calendar/slots', payload)
}
// Check for warnings in response
@@ -237,6 +372,17 @@ export default function CalendarPage() {
const detail = err.response?.data?.detail
if (typeof detail === 'string' && detail.toLowerCase().includes('overlap')) {
setError(`⚠️ Overlap conflict: ${detail}`)
} else if (detail && typeof detail === 'object') {
const message = typeof detail.message === 'string' ? detail.message : 'Save failed'
const conflicts = Array.isArray(detail.conflicts) ? detail.conflicts : []
if (conflicts.length > 0) {
const summary = conflicts
.map((conflict: any) => `${conflict.slot_type || 'slot'} at ${conflict.scheduled_at || 'unknown time'}`)
.join(', ')
setError(`⚠️ ${message}: ${summary}`)
} else {
setError(`⚠️ ${message}`)
}
} else {
setError(detail || 'Save failed')
}
@@ -250,7 +396,13 @@ export default function CalendarPage() {
if (!confirm(`Cancel this ${slot.slot_type} slot at ${slot.scheduled_at}?`)) return
setError('')
try {
await api.post(`/calendar/cancel?date=${selectedDate}&slot_id=${slot.slot_id}`)
if (slot.is_virtual) {
await api.post(`/calendar/slots/virtual/${slot.slot_id}/cancel`)
} else if (slot.slot_id) {
await api.post(`/calendar/slots/${slot.slot_id}/cancel`)
} else {
throw new Error('Missing slot identifier for cancel')
}
fetchSlots(selectedDate)
} catch (err: any) {
setError(err.response?.data?.detail || 'Cancel failed')
@@ -397,7 +549,12 @@ export default function CalendarPage() {
)}
{activeTab === 'plans' && (
<div className="milestone-grid">
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn-primary" onClick={openCreatePlanModal}>+ New Plan</button>
</div>
<div className="milestone-grid">
{plans.length === 0 ? (
<div className="empty" style={{ padding: '40px 0', color: 'var(--text-secondary)', textAlign: 'center' }}>
No schedule plans configured.
@@ -417,7 +574,14 @@ export default function CalendarPage() {
{plan.event_type && <div>📌 {plan.event_type.replace('_', ' ')}</div>}
</div>
{plan.is_active && (
<div style={{ marginTop: 8 }}>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<button
className="btn-transition"
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
onClick={() => openEditPlanModal(plan)}
>
Edit Plan
</button>
<button
className="btn-danger"
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
@@ -430,7 +594,8 @@ export default function CalendarPage() {
</div>
))
)}
</div>
</div>
</>
)}
{/* Create/Edit Slot Modal */}
@@ -545,6 +710,142 @@ export default function CalendarPage() {
</div>
</div>
)}
{showPlanModal && (
<div className="modal-overlay" onClick={() => setShowPlanModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{editingPlan ? 'Edit Plan' : 'New Plan'}</h3>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Slot Type</strong>
<select
value={planForm.slot_type}
onChange={(e) => setPlanForm({ ...planForm, slot_type: e.target.value as SlotType })}
style={{ width: '100%', marginTop: 4 }}
>
{SLOT_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>At Time</strong>
<input
type="time"
value={planForm.at_time}
onChange={(e) => setPlanForm({ ...planForm, at_time: e.target.value })}
style={{ width: '100%', marginTop: 4 }}
/>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Estimated Duration (minutes, 150)</strong>
<input
type="number"
min={1}
max={50}
value={planForm.estimated_duration}
onChange={(e) => setPlanForm({ ...planForm, estimated_duration: Number(e.target.value) })}
style={{ width: '100%', marginTop: 4 }}
/>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>On Day (optional)</strong>
<select
value={planForm.on_day}
onChange={(e) => setPlanForm({ ...planForm, on_day: e.target.value as Weekday | '' })}
style={{ width: '100%', marginTop: 4 }}
>
<option value="">Every day</option>
{WEEKDAYS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>On Week (optional)</strong>
<select
value={planForm.on_week}
onChange={(e) => setPlanForm({ ...planForm, on_week: e.target.value })}
style={{ width: '100%', marginTop: 4 }}
>
<option value="">Every matching week</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>On Month (optional)</strong>
<select
value={planForm.on_month}
onChange={(e) => setPlanForm({ ...planForm, on_month: e.target.value as MonthName | '' })}
style={{ width: '100%', marginTop: 4 }}
>
<option value="">Every month</option>
{MONTHS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Event Type</strong>
<select
value={planForm.event_type}
onChange={(e) => setPlanForm({ ...planForm, event_type: e.target.value })}
style={{ width: '100%', marginTop: 4 }}
>
<option value="">None</option>
{EVENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
{planForm.event_type === 'job' && (
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Job Code</strong>
<input
type="text"
value={planForm.event_data_code}
onChange={(e) => setPlanForm({ ...planForm, event_data_code: e.target.value })}
placeholder="e.g. TASK-42"
style={{ width: '100%', marginTop: 4 }}
/>
</label>
)}
{planForm.event_type === 'system_event' && (
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>System Event</strong>
<select
value={planForm.event_data_event}
onChange={(e) => setPlanForm({ ...planForm, event_data_event: e.target.value })}
style={{ width: '100%', marginTop: 4 }}
>
<option value="">Select event</option>
<option value="ScheduleToday">Schedule Today</option>
<option value="SummaryToday">Summary Today</option>
<option value="ScheduledGatewayRestart">Scheduled Gateway Restart</option>
</select>
</label>
)}
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button className="btn-primary" onClick={handleSavePlan} disabled={planSaving}>
{planSaving ? 'Saving...' : 'Save Plan'}
</button>
<button className="btn-back" onClick={() => setShowPlanModal(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,525 @@
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 ProposalDetailPage from '@/pages/ProposalDetailPage'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDelete = vi.fn()
vi.mock('@/services/api', () => ({
default: {
get: (...args: any[]) => mockGet(...args),
post: (...args: any[]) => mockPost(...args),
patch: (...args: any[]) => mockPatch(...args),
delete: (...args: any[]) => mockDelete(...args),
},
}))
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
useParams: () => ({ id: '1' }),
useSearchParams: () => [new URLSearchParams('?project_id=1'), vi.fn()],
}
})
type Essential = {
id: number
essential_code: string
proposal_id: number
type: 'feature' | 'improvement' | 'refactor'
title: string
description: string | null
created_by_id: number | null
created_at: string
updated_at: string | null
}
type Proposal = {
id: number
proposal_code: string | null
title: string
description: string | null
status: 'open' | 'accepted' | 'rejected'
project_id: number
created_by_id: number | null
created_by_username: string | null
feat_task_id: string | null
essentials: Essential[] | null
generated_tasks: any[] | null
created_at: string
updated_at: string | null
}
type Milestone = {
id: number
title: string
status: 'open' | 'freeze' | 'undergoing' | 'completed' | 'closed'
}
const mockProposal: Proposal = {
id: 1,
proposal_code: 'PROP-001',
title: 'Test Proposal',
description: 'Test description',
status: 'open',
project_id: 1,
created_by_id: 1,
created_by_username: 'admin',
feat_task_id: null,
essentials: [],
generated_tasks: null,
created_at: '2026-03-01T00:00:00Z',
updated_at: null,
}
const mockEssentials: Essential[] = [
{
id: 1,
essential_code: 'ESS-001',
proposal_id: 1,
type: 'feature',
title: 'Feature Essential',
description: 'A feature essential',
created_by_id: 1,
created_at: '2026-03-01T00:00:00Z',
updated_at: null,
},
{
id: 2,
essential_code: 'ESS-002',
proposal_id: 1,
type: 'improvement',
title: 'Improvement Essential',
description: null,
created_by_id: 1,
created_at: '2026-03-01T00:00:00Z',
updated_at: null,
},
]
const mockMilestones: Milestone[] = [
{ id: 1, title: 'Milestone 1', status: 'open' },
{ id: 2, title: 'Milestone 2', status: 'open' },
]
function setupApi(options?: {
proposal?: Proposal
essentials?: Essential[]
milestones?: Milestone[]
error?: string
}) {
const proposal = options?.proposal ?? mockProposal
const essentials = options?.essentials ?? mockEssentials
const milestones = options?.milestones ?? mockMilestones
mockGet.mockImplementation((url: string) => {
if (url.includes('/proposals/1') && !url.includes('/essentials')) {
if (options?.error) {
return Promise.reject({ response: { data: { detail: options.error } } })
}
return Promise.resolve({ data: proposal })
}
if (url.includes('/essentials')) {
return Promise.resolve({ data: essentials })
}
if (url.includes('/milestones')) {
return Promise.resolve({ data: milestones })
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
})
mockPost.mockResolvedValue({ data: {} })
mockPatch.mockResolvedValue({ data: {} })
mockDelete.mockResolvedValue({ data: {} })
}
describe('ProposalDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks()
setupApi()
vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('Essential List Display', () => {
it('renders essentials section with count', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Essentials (2)')).toBeInTheDocument()
})
})
it('displays essential cards with type badges', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
expect(screen.getByText('Improvement Essential')).toBeInTheDocument()
expect(screen.getByText('feature')).toBeInTheDocument()
expect(screen.getByText('improvement')).toBeInTheDocument()
})
it('displays essential codes', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('ESS-001')).toBeInTheDocument()
})
expect(screen.getByText('ESS-002')).toBeInTheDocument()
})
it('displays essential descriptions when available', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('A feature essential')).toBeInTheDocument()
})
})
it('shows empty state when no essentials', async () => {
setupApi({ essentials: [] })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Essentials (0)')).toBeInTheDocument()
})
expect(screen.getByText(/No essentials yet/)).toBeInTheDocument()
expect(screen.getByText(/Add one to define deliverables/)).toBeInTheDocument()
})
it('shows loading state for essentials', async () => {
mockGet.mockImplementation((url: string) => {
if (url.includes('/proposals/1') && !url.includes('/essentials')) {
return Promise.resolve({ data: mockProposal })
}
if (url.includes('/essentials')) {
return new Promise(() => {}) // Never resolve
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
})
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
})
})
})
describe('Essential Create/Edit Forms', () => {
it('opens create essential modal', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
expect(screen.getByRole('heading', { name: 'New Essential' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('Essential title')).toBeInTheDocument()
expect(screen.getByText('Feature')).toBeInTheDocument()
expect(screen.getByText('Improvement')).toBeInTheDocument()
expect(screen.getByText('Refactor')).toBeInTheDocument()
})
it('creates new essential', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'New Test Essential')
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'refactor' } })
await userEvent.type(screen.getByPlaceholderText('Description (optional)'), 'Test description')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/projects/1/proposals/1/essentials',
expect.objectContaining({
title: 'New Test Essential',
type: 'refactor',
description: 'Test description',
})
)
})
})
it('opens edit essential modal with pre-filled data', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const editButtons = screen.getAllByRole('button', { name: 'Edit' })
await userEvent.click(editButtons[0])
expect(screen.getByRole('heading', { name: 'Edit Essential' })).toBeInTheDocument()
const titleInput = screen.getByDisplayValue('Feature Essential')
expect(titleInput).toBeInTheDocument()
const descriptionInput = screen.getByDisplayValue('A feature essential')
expect(descriptionInput).toBeInTheDocument()
})
it('updates essential', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const editButtons = screen.getAllByRole('button', { name: 'Edit' })
await userEvent.click(editButtons[0])
const titleInput = screen.getByDisplayValue('Feature Essential')
await userEvent.clear(titleInput)
await userEvent.type(titleInput, 'Updated Essential Title')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith(
'/projects/1/proposals/1/essentials/1',
expect.objectContaining({
title: 'Updated Essential Title',
type: 'feature',
description: 'A feature essential',
})
)
})
})
it('deletes essential after confirmation', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[0])
await waitFor(() => {
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this Essential?')
})
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith('/projects/1/proposals/1/essentials/1')
})
})
it('disables save button when title is empty', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
const saveButton = screen.getByRole('button', { name: 'Save' })
expect(saveButton).toBeDisabled()
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Some Title')
expect(saveButton).toBeEnabled()
await userEvent.clear(screen.getByPlaceholderText('Essential title'))
expect(saveButton).toBeDisabled()
})
})
describe('Accept with Milestone Selection', () => {
it('opens accept modal with milestone selector', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
expect(screen.getByRole('heading', { name: 'Accept Proposal' })).toBeInTheDocument()
expect(screen.getByText(/milestone to generate story tasks/i)).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('disables confirm button without milestone selection', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' })
expect(confirmButton).toBeDisabled()
})
it('enables confirm button after milestone selection', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: '1' } })
const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' })
expect(confirmButton).toBeEnabled()
})
it('calls accept API with selected milestone', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: '1' } })
await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' }))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/projects/1/proposals/1/accept',
{ milestone_id: 1 }
)
})
})
it('shows warning when no open milestones available', async () => {
setupApi({ milestones: [] })
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
expect(screen.getByText('No open milestones available.')).toBeInTheDocument()
})
it('displays generated tasks after accept', async () => {
const acceptedProposal: Proposal = {
...mockProposal,
status: 'accepted',
generated_tasks: [
{ task_id: 1, task_code: 'TASK-001', title: 'Story Task 1', task_type: 'story', task_subtype: 'feature' },
{ task_id: 2, task_code: 'TASK-002', title: 'Story Task 2', task_type: 'story', task_subtype: 'improvement' },
],
}
setupApi({ proposal: acceptedProposal })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Generated Tasks')).toBeInTheDocument()
})
expect(screen.getByText('Story Task 1')).toBeInTheDocument()
expect(screen.getByText('Story Task 2')).toBeInTheDocument()
expect(screen.getByText('story/feature')).toBeInTheDocument()
expect(screen.getByText('story/improvement')).toBeInTheDocument()
})
})
describe('Story Creation Restriction UI', () => {
it('hides essential controls for accepted proposal', async () => {
const acceptedProposal: Proposal = {
...mockProposal,
status: 'accepted',
essentials: mockEssentials,
}
setupApi({ proposal: acceptedProposal })
render(<ProposalDetailPage />)
await screen.findByText('Feature Essential')
expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument()
})
it('hides edit/delete buttons for essentials when proposal is accepted', async () => {
const acceptedProposal: Proposal = {
...mockProposal,
status: 'accepted',
essentials: mockEssentials,
}
setupApi({ proposal: acceptedProposal })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const deleteButtons = screen.queryAllByRole('button', { name: 'Delete' })
expect(deleteButtons.length).toBe(0)
})
it('hides edit/delete buttons for rejected proposal', async () => {
const rejectedProposal: Proposal = {
...mockProposal,
status: 'rejected',
essentials: mockEssentials,
}
setupApi({ proposal: rejectedProposal })
render(<ProposalDetailPage />)
await screen.findByRole('button', { name: /reopen/i })
expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument()
})
})
describe('Error Handling', () => {
it('displays error message on essential create failure', async () => {
mockPost.mockRejectedValue({ response: { data: { detail: 'Failed to create essential' } } })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Test')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(screen.getByText('Failed to create essential')).toBeInTheDocument()
})
})
it('displays error message on accept failure', async () => {
mockPost.mockRejectedValue({ response: { data: { detail: 'Accept failed: milestone required' } } })
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '1' } })
await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' }))
await waitFor(() => {
expect(screen.getByText('Accept failed: milestone required')).toBeInTheDocument()
})
})
})
})