Files
tfm_ainventory/.planning/phases/4.1-ai-spare-parts-deep-id/4.1-PLAN-03.md
Daniel Bedeleanu ac87c4c06b docs(4.1): planning complete - research + 3 executable plans
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
2026-04-22 16:28:26 +03:00

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.
---