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

37 KiB

wave, depends_on, files_modified, autonomous
wave depends_on files_modified autonomous
3
4.1-PLAN-01.md
4.1-PLAN-02.md
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
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

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

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

---

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