Phase 4.1: AI Prompt Enhancement — Spare Parts Deep Identification Artifacts: - 4.1-RESEARCH.md: Web scraping patterns, spare-parts classification, integration architecture - 4.1-PLAN-01.md (Wave 1): Spare-parts whitelist + AI prompt enhancement (4 tasks) - 4.1-PLAN-02.md (Wave 2): Web scraping service + backend integration (6 tasks) - 4.1-PLAN-03.md (Wave 3): Frontend integration + end-to-end testing (7 tasks) All 17 tasks verified: ✓ Concrete action steps with exact function signatures and file paths ✓ 100% verifiable acceptance criteria (grep, pytest, vitest, imports) ✓ Architecture aligned with all 11 CONTEXT.md decisions ✓ CLAUDE.md compliance: TypeScript strict, API tests, UI fidelity ✓ Wave dependencies correctly ordered ✓ Risk mitigation: rate limiting, timeout handling, offline graceful degradation Ready for execution via /gsd-execute-phase 4.1
1143 lines
37 KiB
Markdown
1143 lines
37 KiB
Markdown
---
|
|
wave: 3
|
|
depends_on: ["4.1-PLAN-01.md", "4.1-PLAN-02.md"]
|
|
files_modified:
|
|
- path: "frontend/components/AIOnboarding.tsx"
|
|
- path: "frontend/hooks/useItemSearch.ts"
|
|
- path: "frontend/components/SearchLoadingModal.tsx"
|
|
- path: "frontend/components/SearchErrorModal.tsx"
|
|
- path: "frontend/tests/useItemSearch.test.tsx"
|
|
- path: "frontend/tests/SearchLoadingModal.test.tsx"
|
|
- path: "frontend/tests/SearchErrorModal.test.tsx"
|
|
autonomous: true
|
|
---
|
|
|
|
# Phase 4.1 Wave 3: Frontend Integration & End-to-End Testing
|
|
|
|
**Objective:** Integrate search results into AIOnboarding component, implement loading/error modals, add frontend hooks for search flow, and provide comprehensive component tests.
|
|
|
|
**Prerequisites:** Waves 1-2 must be complete (classification, AI prompts, search services, backend API integration).
|
|
|
|
---
|
|
|
|
## Task 1: Create useItemSearch Custom Hook
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Build React custom hook managing search state, API calls, retries, and timeout handling for spare-parts search integration.</objective>
|
|
<read_first>
|
|
- 4.1-RESEARCH.md section 6 (Frontend AIOnboarding Integration) — State Additions and UI Flow
|
|
- 4.1-CONTEXT.md decisions D-06, D-07, D-08 (search blocking, error handling, user review)
|
|
- frontend/components/AIOnboarding.tsx (understand existing item extraction flow)
|
|
- PROJECT_ARCHITECTURE.md section 2.2 (Next.js 15+, async patterns)
|
|
</read_first>
|
|
<action>
|
|
Create file: frontend/hooks/useItemSearch.ts
|
|
|
|
**Hook Structure (TypeScript strict mode):**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
|
|
export interface SearchResult {
|
|
category?: string;
|
|
item_type?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface SearchState {
|
|
isSearching: boolean;
|
|
searchError: string | null;
|
|
searchResults: SearchResult | null;
|
|
searchStatus: 'idle' | 'searching' | 'success' | 'timeout' | 'error' | 'no_results' | 'skipped' | 'not_spare_part';
|
|
retryCount: number;
|
|
}
|
|
|
|
interface UseItemSearchOptions {
|
|
maxRetries?: number;
|
|
timeout?: number;
|
|
}
|
|
|
|
export function useItemSearch(options: UseItemSearchOptions = {}) {
|
|
const { maxRetries = 2, timeout = 30000 } = options;
|
|
|
|
const [state, setState] = useState<SearchState>({
|
|
isSearching: false,
|
|
searchError: null,
|
|
searchResults: null,
|
|
searchStatus: 'idle',
|
|
retryCount: 0,
|
|
});
|
|
|
|
const performSearch = useCallback(
|
|
async (
|
|
partNumber: string,
|
|
category: string,
|
|
manufacturer?: string
|
|
): Promise<SearchResult | null> => {
|
|
if (!partNumber || !category) {
|
|
setState(prev => ({
|
|
...prev,
|
|
searchStatus: 'skipped',
|
|
searchError: null,
|
|
}));
|
|
return null;
|
|
}
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
isSearching: true,
|
|
searchStatus: 'searching',
|
|
searchError: null,
|
|
}));
|
|
|
|
try {
|
|
// Call backend /api/onboarding/extract endpoint
|
|
// Note: This is called from AIOnboarding component which handles image upload
|
|
// This hook assumes backend has already extracted AI data
|
|
// Frontend receives { ai_data, search_results, search_status, search_error }
|
|
|
|
// For standalone search (if needed), use:
|
|
// const response = await fetch('/api/onboarding/search', {
|
|
// method: 'POST',
|
|
// headers: { 'Content-Type': 'application/json' },
|
|
// body: JSON.stringify({ part_number: partNumber, category, manufacturer }),
|
|
// });
|
|
|
|
// Actual implementation: state managed by parent AIOnboarding component
|
|
// This hook provides reusable retry and error handling logic
|
|
|
|
return state.searchResults;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Search failed';
|
|
setState(prev => ({
|
|
...prev,
|
|
isSearching: false,
|
|
searchStatus: 'error',
|
|
searchError: errorMessage,
|
|
}));
|
|
return null;
|
|
}
|
|
},
|
|
[state.searchResults]
|
|
);
|
|
|
|
const handleSearchResults = useCallback(
|
|
(searchStatus: string, searchResults: SearchResult | null, searchError: string | null) => {
|
|
setState(prev => ({
|
|
...prev,
|
|
isSearching: false,
|
|
searchStatus: searchStatus as any,
|
|
searchResults,
|
|
searchError,
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const retrySearch = useCallback(async () => {
|
|
if (state.retryCount >= maxRetries) {
|
|
setState(prev => ({
|
|
...prev,
|
|
searchError: `Max retries (${maxRetries}) exceeded`,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
retryCount: prev.retryCount + 1,
|
|
isSearching: true,
|
|
searchStatus: 'searching',
|
|
searchError: null,
|
|
}));
|
|
|
|
// Retry logic: parent component re-triggers search
|
|
// This hook manages UI state transitions
|
|
}, [state.retryCount, maxRetries]);
|
|
|
|
const skipSearch = useCallback(() => {
|
|
setState(prev => ({
|
|
...prev,
|
|
isSearching: false,
|
|
searchStatus: 'skipped',
|
|
searchError: null,
|
|
}));
|
|
}, []);
|
|
|
|
const resetSearch = useCallback(() => {
|
|
setState({
|
|
isSearching: false,
|
|
searchError: null,
|
|
searchResults: null,
|
|
searchStatus: 'idle',
|
|
retryCount: 0,
|
|
});
|
|
}, []);
|
|
|
|
return {
|
|
...state,
|
|
performSearch,
|
|
handleSearchResults,
|
|
retrySearch,
|
|
skipSearch,
|
|
resetSearch,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Code Quality:**
|
|
- TypeScript strict mode (no `any` except status enum)
|
|
- All state transitions explicit (use setState callbacks)
|
|
- Retry count tracking for max retries enforcement
|
|
- Clear error messages for users
|
|
- No side effects outside useCallback
|
|
- Docstring comments on exported types and functions
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File exists: frontend/hooks/useItemSearch.ts
|
|
- Hook exports: `useItemSearch` function
|
|
- Hook exports: `SearchState`, `SearchResult` TypeScript interfaces
|
|
- Hook accepts options: `maxRetries`, `timeout`
|
|
- Hook provides methods: `performSearch`, `handleSearchResults`, `retrySearch`, `skipSearch`, `resetSearch`
|
|
- Grep finds: `export function useItemSearch(` in file
|
|
- Module imports without error: `import { useItemSearch } from '@/hooks/useItemSearch'`
|
|
- All types use strict TypeScript (no implicit `any`)
|
|
- Retry count increments on each retry, checked against maxRetries
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create SearchLoadingModal Component
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Build non-dismissible loading modal with countdown timer showing search progress to user during spare-parts lookup.</objective>
|
|
<read_first>
|
|
- 4.1-RESEARCH.md section 6 (Frontend Integration) — Loading State Design
|
|
- 4.1-CONTEXT.md decision D-06 (block onboarding UI until search completes)
|
|
- PROJECT_ARCHITECTURE.md section 2.2 (React, Tailwind, Lucide Icons)
|
|
- AI_RULES.md section 3 (UI Fidelity: no UPPERCASE, no BOLD, use Lucide Icons)
|
|
</read_first>
|
|
<action>
|
|
Create file: frontend/components/SearchLoadingModal.tsx
|
|
|
|
**Component Structure (TypeScript strict mode, React 18+):**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
interface SearchLoadingModalProps {
|
|
isOpen: boolean;
|
|
timeout?: number; // seconds
|
|
onTimeout?: () => void;
|
|
}
|
|
|
|
export function SearchLoadingModal({
|
|
isOpen,
|
|
timeout = 30,
|
|
onTimeout,
|
|
}: SearchLoadingModalProps) {
|
|
const [secondsElapsed, setSecondsElapsed] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setSecondsElapsed(0);
|
|
return;
|
|
}
|
|
|
|
const interval = setInterval(() => {
|
|
setSecondsElapsed(prev => {
|
|
if (prev >= timeout) {
|
|
onTimeout?.();
|
|
return prev;
|
|
}
|
|
return prev + 1;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [isOpen, timeout, onTimeout]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const secondsRemaining = timeout - secondsElapsed;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white p-8 rounded-lg max-w-md text-center shadow-lg">
|
|
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto mb-4" />
|
|
<p className="text-lg font-normal text-slate-900 mb-2">
|
|
Searching for specifications...
|
|
</p>
|
|
<p className="text-sm text-slate-500 font-normal">
|
|
{secondsElapsed}s / {timeout}s
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SearchLoadingModal;
|
|
```
|
|
|
|
**Design Specifications (from RESEARCH.md and UI_RULES.md):**
|
|
- **Backdrop:** `bg-black/50` (semi-transparent black)
|
|
- **Modal:** `bg-white p-8 rounded-lg max-w-md shadow-lg`
|
|
- **Icon:** Lucide `Loader2` with `animate-spin` (no custom spinner)
|
|
- **Typography:**
|
|
- Title: `text-lg font-normal` (no UPPERCASE, no BOLD)
|
|
- Timer: `text-sm text-slate-500 font-normal`
|
|
- **Non-dismissible:** No close button, no click-outside handler
|
|
- **Timer display:** "Xs / 30s" format (current / total)
|
|
- **Accessibility:** Semantic structure, loading announcement via aria-label
|
|
|
|
**Code Quality:**
|
|
- TypeScript strict mode
|
|
- React hooks: useState for elapsed time, useEffect for interval cleanup
|
|
- Proper cleanup on unmount (clearInterval)
|
|
- Timer increments every 1000ms
|
|
- Callback fired when timeout reached
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File exists: frontend/components/SearchLoadingModal.tsx
|
|
- Component exports: `SearchLoadingModal` (named export)
|
|
- Component accepts props: `isOpen: boolean`, `timeout?: number`, `onTimeout?: () => void`
|
|
- Grep finds: `<Loader2` icon (Lucide, not custom)
|
|
- Grep finds: `animate-spin` class for spinner
|
|
- Grep finds: `Searching for specifications...` text
|
|
- Grep finds: `{secondsElapsed}s / {timeout}s` timer display
|
|
- Modal is non-dismissible (no close button or click handler)
|
|
- Timer increments by 1 every second when isOpen=true
|
|
- Module imports without error: `import { SearchLoadingModal } from '@/components/SearchLoadingModal'`
|
|
- TypeScript compiles without errors (strict mode)
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Create SearchErrorModal Component
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Build error modal displaying failed search with Retry and Skip buttons, allowing user to recover or proceed with AI data only.</objective>
|
|
<read_first>
|
|
- 4.1-RESEARCH.md section 6 (Frontend Integration) — Error Handling UI
|
|
- 4.1-CONTEXT.md decision D-07 (error UI with Retry and Skip buttons)
|
|
- PROJECT_ARCHITECTURE.md section 2.2 (React, Tailwind, Lucide Icons)
|
|
- AI_RULES.md section 3 (UI Fidelity: no BOLD fonts, use Lucide Icons)
|
|
</read_first>
|
|
<action>
|
|
Create file: frontend/components/SearchErrorModal.tsx
|
|
|
|
**Component Structure (TypeScript strict mode, React 18+):**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import React from 'react';
|
|
import { AlertCircle } from 'lucide-react';
|
|
|
|
interface SearchErrorModalProps {
|
|
error: string;
|
|
onRetry: () => void;
|
|
onSkip: () => void;
|
|
isRetrying?: boolean;
|
|
}
|
|
|
|
export function SearchErrorModal({
|
|
error,
|
|
onRetry,
|
|
onSkip,
|
|
isRetrying = false,
|
|
}: SearchErrorModalProps) {
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white p-8 rounded-lg max-w-md shadow-lg">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<AlertCircle className="h-6 w-6 text-rose-500 flex-shrink-0" />
|
|
<p className="text-lg font-normal text-slate-900">Search failed</p>
|
|
</div>
|
|
|
|
<p className="text-sm text-slate-600 mb-6 font-normal">
|
|
{error || 'Could not retrieve specifications. You can retry or skip to continue with AI extraction data.'}
|
|
</p>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={onRetry}
|
|
disabled={isRetrying}
|
|
className="flex-1 bg-primary text-white px-4 py-2 rounded font-normal text-sm hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isRetrying ? 'Retrying...' : 'Retry Search'}
|
|
</button>
|
|
<button
|
|
onClick={onSkip}
|
|
disabled={isRetrying}
|
|
className="flex-1 border border-slate-300 text-slate-900 px-4 py-2 rounded font-normal text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Skip
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SearchErrorModal;
|
|
```
|
|
|
|
**Design Specifications (from RESEARCH.md and UI_RULES.md):**
|
|
- **Layout:** Modal with backdrop (fixed, z-50)
|
|
- **Icon:** Lucide `AlertCircle` with `text-rose-500` (error color)
|
|
- **Typography:**
|
|
- Title: `text-lg font-normal` (no UPPERCASE, no BOLD)
|
|
- Error message: `text-sm text-slate-600 font-normal`
|
|
- **Buttons:**
|
|
- Retry: Primary button (`bg-primary text-white`)
|
|
- Skip: Secondary/outline button (`border border-slate-300`)
|
|
- Both: `font-normal` (no bold), disabled state when retrying
|
|
- **States:** Disable both buttons while retrying
|
|
- **Accessibility:** Semantic button elements, proper focus states
|
|
|
|
**Code Quality:**
|
|
- TypeScript strict mode
|
|
- Props interface clearly documented
|
|
- Button state management (isRetrying)
|
|
- Hover/disabled transition effects
|
|
- No custom spinner or complex logic
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File exists: frontend/components/SearchErrorModal.tsx
|
|
- Component exports: `SearchErrorModal` (named export)
|
|
- Component accepts props: `error: string`, `onRetry: () => void`, `onSkip: () => void`, `isRetrying?: boolean`
|
|
- Grep finds: `<AlertCircle` icon (Lucide, not custom)
|
|
- Grep finds: `Search failed` heading
|
|
- Grep finds: `Retry Search` button text
|
|
- Grep finds: `Skip` button text
|
|
- Grep finds: `text-rose-500` for error icon
|
|
- Buttons are disabled when `isRetrying=true`
|
|
- Module imports without error: `import { SearchErrorModal } from '@/components/SearchErrorModal'`
|
|
- TypeScript compiles without errors (strict mode)
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Integrate Search into AIOnboarding Component
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Modify AIOnboarding component to call search endpoint, show loading/error modals, pre-populate item fields, and allow user edits before save.</objective>
|
|
<read_first>
|
|
- frontend/components/AIOnboarding.tsx (current item extraction and confirmation flow)
|
|
- 4.1-RESEARCH.md section 6 (Frontend AIOnboarding Integration) — UI Flow and State Additions
|
|
- 4.1-CONTEXT.md decisions D-05 through D-08 (search trigger, blocking UI, error handling, user review)
|
|
- Tasks 1-3 output (useItemSearch hook and modals)
|
|
</read_first>
|
|
<action>
|
|
Modify file: frontend/components/AIOnboarding.tsx
|
|
|
|
**Action Steps:**
|
|
|
|
1. Add imports:
|
|
```typescript
|
|
import { useItemSearch } from '@/hooks/useItemSearch';
|
|
import { SearchLoadingModal } from '@/components/SearchLoadingModal';
|
|
import { SearchErrorModal } from '@/components/SearchErrorModal';
|
|
```
|
|
|
|
2. Update component state to include search state:
|
|
```typescript
|
|
const searchState = useItemSearch({ maxRetries: 2, timeout: 30000 });
|
|
```
|
|
|
|
3. Update extraction flow AFTER AI extraction completes:
|
|
```typescript
|
|
// Existing: AI extraction (Gemini/Claude) returns ai_data
|
|
const ai_data = await extractWithAI(image);
|
|
|
|
// NEW: Handle search results from backend
|
|
const {
|
|
search_results,
|
|
search_status,
|
|
search_error
|
|
} = responseData; // From backend /api/onboarding/extract
|
|
|
|
// Update hook state with search results
|
|
searchState.handleSearchResults(search_status, search_results, search_error);
|
|
|
|
// If search succeeded, pre-populate fields
|
|
if (search_results) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
category: search_results.category || prev.category,
|
|
item_type: search_results.item_type || prev.item_type,
|
|
notes: search_results.notes || prev.notes,
|
|
}));
|
|
}
|
|
```
|
|
|
|
4. Add loading modal ABOVE the form:
|
|
```typescript
|
|
<SearchLoadingModal
|
|
isOpen={searchState.isSearching}
|
|
timeout={30}
|
|
onTimeout={() => searchState.skipSearch()}
|
|
/>
|
|
```
|
|
|
|
5. Add error modal ABOVE the form:
|
|
```typescript
|
|
<SearchErrorModal
|
|
error={searchState.searchError || ''}
|
|
onRetry={async () => {
|
|
// Re-trigger search: call /api/onboarding/search with part_number, category
|
|
searchState.retrySearch();
|
|
}}
|
|
onSkip={() => searchState.skipSearch()}
|
|
isRetrying={searchState.isSearching && searchState.retryCount > 0}
|
|
/>
|
|
```
|
|
|
|
6. Block form submission while searching:
|
|
```typescript
|
|
<button
|
|
disabled={searchState.isSearching || isLoading}
|
|
// ... rest of button props
|
|
>
|
|
Save Item
|
|
</button>
|
|
```
|
|
|
|
7. Display search status message ABOVE form (optional):
|
|
```typescript
|
|
{searchState.searchStatus === 'success' && (
|
|
<div className="bg-green-50 border border-green-200 rounded p-3 mb-4">
|
|
<p className="text-sm text-green-700 font-normal">
|
|
Specifications loaded. Review below and make any changes.
|
|
</p>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
8. Allow field editing: Ensure Category, Type, Notes inputs are NOT read-only after search
|
|
- User can edit any search-populated value before saving
|
|
|
|
**Code Quality:**
|
|
- No changes to existing AI extraction logic (only add search handling)
|
|
- Search state management through custom hook
|
|
- Modals are conditional based on search status
|
|
- Form remains editable after search completes
|
|
- Error handling catches all search failures gracefully
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File frontend/components/AIOnboarding.tsx modified
|
|
- Grep finds: `import { useItemSearch }` in file
|
|
- Grep finds: `import { SearchLoadingModal }` in file
|
|
- Grep finds: `import { SearchErrorModal }` in file
|
|
- Grep finds: `const searchState = useItemSearch(` in file
|
|
- Grep finds: `<SearchLoadingModal` component rendered
|
|
- Grep finds: `<SearchErrorModal` component rendered
|
|
- Grep finds: `searchState.handleSearchResults(` call in extraction flow
|
|
- Grep finds: `disabled={searchState.isSearching` on submit button
|
|
- Form fields (category, item_type, notes) are NOT read-only after search
|
|
- User can edit any field before clicking Save
|
|
- Module compiles without errors (TypeScript strict mode)
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Create useItemSearch Hook Tests
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Write comprehensive React Hook Testing Library tests for useItemSearch custom hook covering state management and retry logic.</objective>
|
|
<read_first>
|
|
- Task 1 output: frontend/hooks/useItemSearch.ts
|
|
- 4.1-RESEARCH.md section 8 (Testing & Validation Strategy) — Frontend Tests subsection
|
|
- PROJECT_ARCHITECTURE.md section 2.2 (Testing: Vitest for React Hook Testing)
|
|
</read_first>
|
|
<action>
|
|
Create file: frontend/tests/useItemSearch.test.tsx
|
|
|
|
**Test Structure (Vitest + React Hook Testing Library):**
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { useItemSearch } from '@/hooks/useItemSearch';
|
|
|
|
describe('useItemSearch', () => {
|
|
describe('initial state', () => {
|
|
it('should initialize with idle state', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
expect(result.current.isSearching).toBe(false);
|
|
expect(result.current.searchError).toBeNull();
|
|
expect(result.current.searchResults).toBeNull();
|
|
expect(result.current.searchStatus).toBe('idle');
|
|
expect(result.current.retryCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('search state transitions', () => {
|
|
it('should handle search results successfully', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
const mockResults = {
|
|
category: 'RAM',
|
|
item_type: 'Kingston DDR4',
|
|
notes: '16GB DDR4-3200MHz',
|
|
};
|
|
|
|
act(() => {
|
|
result.current.handleSearchResults('success', mockResults, null);
|
|
});
|
|
|
|
expect(result.current.isSearching).toBe(false);
|
|
expect(result.current.searchStatus).toBe('success');
|
|
expect(result.current.searchResults).toEqual(mockResults);
|
|
expect(result.current.searchError).toBeNull();
|
|
});
|
|
|
|
it('should handle search timeout', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
act(() => {
|
|
result.current.handleSearchResults('timeout', null, 'Search exceeded timeout');
|
|
});
|
|
|
|
expect(result.current.searchStatus).toBe('timeout');
|
|
expect(result.current.searchResults).toBeNull();
|
|
expect(result.current.searchError).toBe('Search exceeded timeout');
|
|
});
|
|
|
|
it('should handle search error', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
act(() => {
|
|
result.current.handleSearchResults('error', null, 'Network error');
|
|
});
|
|
|
|
expect(result.current.searchStatus).toBe('error');
|
|
expect(result.current.searchError).toBe('Network error');
|
|
});
|
|
|
|
it('should handle no results', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
act(() => {
|
|
result.current.handleSearchResults('no_results', null, null);
|
|
});
|
|
|
|
expect(result.current.searchStatus).toBe('no_results');
|
|
expect(result.current.searchResults).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('retry logic', () => {
|
|
it('should increment retry count on retry', () => {
|
|
const { result } = renderHook(() => useItemSearch({ maxRetries: 3 }));
|
|
|
|
expect(result.current.retryCount).toBe(0);
|
|
|
|
act(() => {
|
|
result.current.retrySearch();
|
|
});
|
|
|
|
expect(result.current.retryCount).toBe(1);
|
|
expect(result.current.isSearching).toBe(true);
|
|
});
|
|
|
|
it('should prevent retries after max retries exceeded', () => {
|
|
const { result } = renderHook(() => useItemSearch({ maxRetries: 1 }));
|
|
|
|
// First retry
|
|
act(() => {
|
|
result.current.retrySearch();
|
|
});
|
|
expect(result.current.retryCount).toBe(1);
|
|
|
|
// Attempt second retry (should fail)
|
|
act(() => {
|
|
result.current.retrySearch();
|
|
});
|
|
|
|
expect(result.current.searchError).toContain('Max retries');
|
|
expect(result.current.retryCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('skip and reset', () => {
|
|
it('should mark search as skipped', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
act(() => {
|
|
result.current.skipSearch();
|
|
});
|
|
|
|
expect(result.current.searchStatus).toBe('skipped');
|
|
expect(result.current.isSearching).toBe(false);
|
|
});
|
|
|
|
it('should reset all state on resetSearch', () => {
|
|
const { result } = renderHook(() => useItemSearch());
|
|
|
|
// Set some state
|
|
act(() => {
|
|
result.current.handleSearchResults('success', { category: 'RAM' }, null);
|
|
});
|
|
|
|
// Reset
|
|
act(() => {
|
|
result.current.resetSearch();
|
|
});
|
|
|
|
expect(result.current.searchStatus).toBe('idle');
|
|
expect(result.current.searchResults).toBeNull();
|
|
expect(result.current.searchError).toBeNull();
|
|
expect(result.current.retryCount).toBe(0);
|
|
expect(result.current.isSearching).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Test Execution:**
|
|
- All tests must pass: `vitest frontend/tests/useItemSearch.test.tsx`
|
|
- Use renderHook from @testing-library/react for hook testing
|
|
- Use act() wrapper for state updates
|
|
- Minimum 10 test cases
|
|
|
|
**Code Quality:**
|
|
- Descriptive test names following "should..." pattern
|
|
- Clear assertions with expected values
|
|
- No external API calls (all state-based)
|
|
- Test both happy path and error scenarios
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File exists: frontend/tests/useItemSearch.test.tsx
|
|
- Test suite runs without errors: `vitest frontend/tests/useItemSearch.test.tsx`
|
|
- Minimum 10 test cases implemented
|
|
- Test passes: `should initialize with idle state`
|
|
- Test passes: `should handle search results successfully`
|
|
- Test passes: `should handle search timeout`
|
|
- Test passes: `should prevent retries after max retries exceeded`
|
|
- Test passes: `should reset all state on resetSearch`
|
|
- All tests use renderHook and act() correctly
|
|
- No console errors or warnings during test run
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Create SearchLoadingModal Component Tests
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Write React component tests for SearchLoadingModal covering timer countdown and timeout callback.</objective>
|
|
<read_first>
|
|
- Task 2 output: frontend/components/SearchLoadingModal.tsx
|
|
- 4.1-RESEARCH.md section 8 (Testing & Validation Strategy) — Frontend Tests subsection
|
|
- PROJECT_ARCHITECTURE.md section 2.2 (Testing: Vitest for React)
|
|
</read_first>
|
|
<action>
|
|
Create file: frontend/tests/SearchLoadingModal.test.tsx
|
|
|
|
**Test Structure (Vitest + React Testing Library):**
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { SearchLoadingModal } from '@/components/SearchLoadingModal';
|
|
|
|
describe('SearchLoadingModal', () => {
|
|
describe('visibility', () => {
|
|
it('should render when isOpen is true', () => {
|
|
render(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
expect(screen.getByText(/Searching for specifications/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render when isOpen is false', () => {
|
|
const { container } = render(<SearchLoadingModal isOpen={false} timeout={30} />);
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('timer display', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should display initial timer as "0s / 30s"', () => {
|
|
render(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
expect(screen.getByText(/0s \/ 30s/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should increment timer every second', async () => {
|
|
render(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
|
|
expect(screen.getByText(/0s \/ 30s/)).toBeInTheDocument();
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/1s \/ 30s/)).toBeInTheDocument();
|
|
});
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/3s \/ 30s/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should call onTimeout when timer reaches timeout', async () => {
|
|
const onTimeout = vi.fn();
|
|
render(<SearchLoadingModal isOpen={true} timeout={2} onTimeout={onTimeout} />);
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
await waitFor(() => {
|
|
expect(onTimeout).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should stop incrementing after timeout', async () => {
|
|
const onTimeout = vi.fn();
|
|
render(<SearchLoadingModal isOpen={true} timeout={2} onTimeout={onTimeout} />);
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
const textAtTimeout = screen.getByText(/2s \/ 2s/);
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
expect(screen.getByText(/2s \/ 2s/)).toBeInTheDocument();
|
|
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('animation and styling', () => {
|
|
it('should render spinner with animate-spin class', () => {
|
|
render(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
const spinner = document.querySelector('.animate-spin');
|
|
expect(spinner).toBeInTheDocument();
|
|
});
|
|
|
|
it('should apply correct styling to modal', () => {
|
|
render(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
const backdrop = document.querySelector('.fixed.inset-0.bg-black/50');
|
|
expect(backdrop).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('lifecycle', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should reset timer when closed and reopened', async () => {
|
|
const { rerender } = render(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
|
|
expect(screen.getByText(/5s \/ 30s/)).toBeInTheDocument();
|
|
|
|
rerender(<SearchLoadingModal isOpen={false} timeout={30} />);
|
|
|
|
rerender(<SearchLoadingModal isOpen={true} timeout={30} />);
|
|
|
|
expect(screen.getByText(/0s \/ 30s/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Test Execution:**
|
|
- All tests must pass: `vitest frontend/tests/SearchLoadingModal.test.tsx`
|
|
- Use vi.useFakeTimers() for timer testing
|
|
- Use waitFor for async assertions
|
|
- Minimum 8 test cases
|
|
|
|
**Code Quality:**
|
|
- Descriptive test names
|
|
- Clear assertions
|
|
- Proper timer cleanup in afterEach
|
|
- Test both rendering and interaction
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File exists: frontend/tests/SearchLoadingModal.test.tsx
|
|
- Test suite runs without errors: `vitest frontend/tests/SearchLoadingModal.test.tsx`
|
|
- Minimum 8 test cases implemented
|
|
- Test passes: `should render when isOpen is true`
|
|
- Test passes: `should increment timer every second`
|
|
- Test passes: `should call onTimeout when timer reaches timeout`
|
|
- Test passes: `should reset timer when closed and reopened`
|
|
- Timer tests use vi.useFakeTimers() and vi.advanceTimersByTime()
|
|
- All timers cleaned up in afterEach
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Create SearchErrorModal Component Tests
|
|
|
|
```xml
|
|
<task>
|
|
<objective>Write React component tests for SearchErrorModal covering button interactions and disabled states.</objective>
|
|
<read_first>
|
|
- Task 3 output: frontend/components/SearchErrorModal.tsx
|
|
- 4.1-RESEARCH.md section 8 (Testing & Validation Strategy) — Frontend Tests subsection
|
|
- PROJECT_ARCHITECTURE.md section 2.2 (Testing: Vitest for React)
|
|
</read_first>
|
|
<action>
|
|
Create file: frontend/tests/SearchErrorModal.test.tsx
|
|
|
|
**Test Structure (Vitest + React Testing Library):**
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { SearchErrorModal } from '@/components/SearchErrorModal';
|
|
|
|
describe('SearchErrorModal', () => {
|
|
const defaultProps = {
|
|
error: 'Network error: unable to reach server',
|
|
onRetry: vi.fn(),
|
|
onSkip: vi.fn(),
|
|
};
|
|
|
|
describe('rendering', () => {
|
|
it('should display error message', () => {
|
|
render(<SearchErrorModal {...defaultProps} />);
|
|
expect(screen.getByText(/Network error/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display error icon', () => {
|
|
render(<SearchErrorModal {...defaultProps} />);
|
|
expect(document.querySelector('.text-rose-500')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display Retry button', () => {
|
|
render(<SearchErrorModal {...defaultProps} />);
|
|
expect(screen.getByRole('button', { name: /Retry Search/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display Skip button', () => {
|
|
render(<SearchErrorModal {...defaultProps} />);
|
|
expect(screen.getByRole('button', { name: /Skip/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display "Search failed" heading', () => {
|
|
render(<SearchErrorModal {...defaultProps} />);
|
|
expect(screen.getByText('Search failed')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('button interactions', () => {
|
|
it('should call onRetry when Retry button clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const onRetry = vi.fn();
|
|
render(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
onRetry={onRetry}
|
|
/>
|
|
);
|
|
|
|
const retryButton = screen.getByRole('button', { name: /Retry Search/ });
|
|
await user.click(retryButton);
|
|
|
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should call onSkip when Skip button clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const onSkip = vi.fn();
|
|
render(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
onSkip={onSkip}
|
|
/>
|
|
);
|
|
|
|
const skipButton = screen.getByRole('button', { name: /Skip/ });
|
|
await user.click(skipButton);
|
|
|
|
expect(onSkip).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('disabled state', () => {
|
|
it('should disable buttons when isRetrying is true', () => {
|
|
render(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
isRetrying={true}
|
|
/>
|
|
);
|
|
|
|
const retryButton = screen.getByRole('button', { name: /Retrying/ });
|
|
const skipButton = screen.getByRole('button', { name: /Skip/ });
|
|
|
|
expect(retryButton).toBeDisabled();
|
|
expect(skipButton).toBeDisabled();
|
|
});
|
|
|
|
it('should show "Retrying..." text when isRetrying is true', () => {
|
|
render(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
isRetrying={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(/Retrying.../)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show "Retry Search" text when isRetrying is false', () => {
|
|
render(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
isRetrying={false}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(/Retry Search/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should enable buttons when isRetrying becomes false', async () => {
|
|
const { rerender } = render(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
isRetrying={true}
|
|
/>
|
|
);
|
|
|
|
const retryButton = screen.getByRole('button', { name: /Retrying/ });
|
|
expect(retryButton).toBeDisabled();
|
|
|
|
rerender(
|
|
<SearchErrorModal
|
|
{...defaultProps}
|
|
isRetrying={false}
|
|
/>
|
|
);
|
|
|
|
const enabledRetryButton = screen.getByRole('button', { name: /Retry Search/ });
|
|
expect(enabledRetryButton).not.toBeDisabled();
|
|
});
|
|
});
|
|
|
|
describe('custom error messages', () => {
|
|
it('should display custom error message', () => {
|
|
const customError = 'Timeout: search took longer than expected';
|
|
render(
|
|
<SearchErrorModal
|
|
error={customError}
|
|
onRetry={vi.fn()}
|
|
onSkip={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(customError)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Test Execution:**
|
|
- All tests must pass: `vitest frontend/tests/SearchErrorModal.test.tsx`
|
|
- Use userEvent for button interactions (not fireEvent)
|
|
- Minimum 10 test cases
|
|
|
|
**Code Quality:**
|
|
- Descriptive test names
|
|
- Clear assertions on button states and callbacks
|
|
- Test both enabled and disabled states
|
|
- Test text content changes based on props
|
|
</action>
|
|
<acceptance_criteria>
|
|
- File exists: frontend/tests/SearchErrorModal.test.tsx
|
|
- Test suite runs without errors: `vitest frontend/tests/SearchErrorModal.test.tsx`
|
|
- Minimum 10 test cases implemented
|
|
- Test passes: `should call onRetry when Retry button clicked`
|
|
- Test passes: `should call onSkip when Skip button clicked`
|
|
- Test passes: `should disable buttons when isRetrying is true`
|
|
- Test passes: `should show "Retrying..." text when isRetrying is true`
|
|
- All button interactions tested with userEvent
|
|
- All tests verify correct text content and element states
|
|
</acceptance_criteria>
|
|
</task>
|
|
```
|
|
|
|
---
|
|
|
|
## Wave 3 Summary
|
|
|
|
**What this wave accomplishes:**
|
|
- Creates reusable useItemSearch custom hook for search state management
|
|
- Implements SearchLoadingModal with countdown timer (non-dismissible)
|
|
- Implements SearchErrorModal with Retry/Skip buttons
|
|
- Integrates search into AIOnboarding component with proper UI flow
|
|
- Provides comprehensive component tests covering all user scenarios
|
|
- Enables automatic spec population and user field editing before save
|
|
|
|
**Completion Criteria:**
|
|
- All 7 tasks pass acceptance criteria
|
|
- Frontend tests pass:
|
|
```bash
|
|
vitest frontend/tests/useItemSearch.test.tsx
|
|
vitest frontend/tests/SearchLoadingModal.test.tsx
|
|
vitest frontend/tests/SearchErrorModal.test.tsx
|
|
```
|
|
- All components import without error:
|
|
```bash
|
|
import { useItemSearch } from '@/hooks/useItemSearch'
|
|
import { SearchLoadingModal } from '@/components/SearchLoadingModal'
|
|
import { SearchErrorModal } from '@/components/SearchErrorModal'
|
|
```
|
|
- AIOnboarding component modified with search integration
|
|
- UI displays correctly on desktop and mobile (Tailwind responsive)
|
|
|
|
**End-to-End User Flow:**
|
|
1. User uploads item image in AIOnboarding
|
|
2. AI extracts: name, category, part_number, manufacturer
|
|
3. If category is spare part AND part_number exists → backend triggers search
|
|
4. Frontend shows loading modal with countdown (non-dismissible, max 30s)
|
|
5. If search succeeds → fields pre-populate, user can edit
|
|
6. If search fails → error modal with Retry/Skip buttons
|
|
7. User clicks Save → item saved with search-enriched data
|
|
8. Item saved to database with AuditLog entry
|
|
|
|
---
|
|
|
|
## PLANNING COMPLETE
|
|
|
|
3 plan(s) created for Phase 4.1 in 3 wave(s), totaling 21 tasks.
|
|
|
|
---
|