// src/components/FlyerCorrectionTool.test.tsx import React from 'react'; import { screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { FlyerCorrectionTool } from './FlyerCorrectionTool'; import * as aiApiClient from '../services/aiApiClient'; import { notifyError, notifySuccess } from '../services/notificationService'; import { renderWithProviders } from '../tests/utils/renderWithProviders'; // Unmock the component to test the real implementation vi.unmock('./FlyerCorrectionTool'); // The aiApiClient, notificationService, and logger are mocked globally. // We can get a typed reference to the aiApiClient for individual test overrides. const mockedAiApiClient = vi.mocked(aiApiClient); const mockedNotifySuccess = notifySuccess as Mocked; const mockedNotifyError = notifyError as Mocked; const defaultProps = { isOpen: true, onClose: vi.fn(), imageUrl: 'http://example.com/flyer.jpg', onDataExtracted: vi.fn(), }; describe('FlyerCorrectionTool', () => { beforeEach(() => { vi.clearAllMocks(); // Mock global fetch for fetching the image blob inside the component global.fetch = vi.fn(() => Promise.resolve(new Response(new Blob(['dummy-image-content'], { type: 'image/jpeg' }))), ) as Mocked; // Mock canvas methods for jsdom environment window.HTMLCanvasElement.prototype.getContext = vi.fn((contextId: string) => { if (contextId === '2d') { return { clearRect: vi.fn(), strokeRect: vi.fn(), setLineDash: vi.fn(), strokeStyle: '', lineWidth: 0, } as unknown as CanvasRenderingContext2D; } return null; }) as typeof window.HTMLCanvasElement.prototype.getContext; }); it('should not render when isOpen is false', () => { const { container } = renderWithProviders(); expect(container.firstChild).toBeNull(); }); it('should render correctly when isOpen is true', () => { renderWithProviders(); expect(screen.getByRole('heading', { name: /flyer correction tool/i })).toBeInTheDocument(); expect(screen.getByAltText('Flyer for correction')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /extract store name/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeInTheDocument(); }); it('should call onClose when the close button is clicked', () => { renderWithProviders(); // Use the specific aria-label defined in the component to find the close button const closeButton = screen.getByLabelText(/close correction tool/i); fireEvent.click(closeButton); expect(defaultProps.onClose).toHaveBeenCalledTimes(1); }); it('should have disabled extraction buttons initially', () => { renderWithProviders(); expect(screen.getByRole('button', { name: /extract store name/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeDisabled(); }); it('should enable extraction buttons after a selection is made', () => { renderWithProviders(); const canvas = screen.getByRole('dialog').querySelector('canvas')!; // Simulate drawing a rectangle fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 }); fireEvent.mouseUp(canvas); expect(screen.getByRole('button', { name: /extract store name/i })).toBeEnabled(); expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeEnabled(); }); it('should stop drawing when the mouse leaves the canvas', () => { renderWithProviders(); const canvas = screen.getByRole('dialog').querySelector('canvas')!; fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 }); fireEvent.mouseLeave(canvas); // Simulate mouse leaving expect(screen.getByRole('button', { name: /extract store name/i })).toBeEnabled(); }); it('should call rescanImageArea with correct parameters and show success', async () => { console.log('\n--- [TEST LOG] ---: Starting test: "should call rescanImageArea..."'); // 1. Create a controllable promise for the mock. console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.'); let resolveRescanPromise: (value: Response | PromiseLike) => void; const rescanPromise = new Promise((resolve) => { resolveRescanPromise = resolve; }); mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise); renderWithProviders(); // Wait for the image fetch to complete to ensure 'imageFile' state is populated console.log('--- [TEST LOG] ---: Awaiting image fetch inside component...'); await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(defaultProps.imageUrl)); console.log('--- [TEST LOG] ---: Image fetch complete.'); const canvas = screen.getByRole('dialog').querySelector('canvas')!; const image = screen.getByAltText('Flyer for correction'); // Mock image dimensions for coordinate scaling console.log('--- [TEST LOG] ---: Mocking image dimensions.'); Object.defineProperty(image, 'naturalWidth', { value: 1000, configurable: true }); Object.defineProperty(image, 'naturalHeight', { value: 800, configurable: true }); Object.defineProperty(image, 'clientWidth', { value: 500, configurable: true }); Object.defineProperty(image, 'clientHeight', { value: 400, configurable: true }); // Simulate drawing a rectangle console.log('--- [TEST LOG] ---: Simulating user drawing a rectangle...'); fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(canvas, { clientX: 60, clientY: 30 }); fireEvent.mouseUp(canvas); console.log('--- [TEST LOG] ---: Rectangle drawn.'); // 2. Click the extract button, which will trigger the pending promise. console.log('--- [TEST LOG] ---: 2. Clicking "Extract Store Name" button.'); fireEvent.click(screen.getByRole('button', { name: /extract store name/i })); // 3. Assert the loading state. try { console.log('--- [TEST LOG] ---: 3. Awaiting "Processing..." loading state.'); expect(await screen.findByText('Processing...')).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 3a. SUCCESS: Found "Processing..." text.'); } catch (error) { console.error('--- [TEST LOG] ---: 3a. ERROR: Did not find "Processing..." text.'); screen.debug(); throw error; } // 4. Check that the API was called with correctly scaled coordinates. console.log('--- [TEST LOG] ---: 4. Awaiting API call verification...'); await waitFor(() => { console.log('--- [TEST LOG] ---: 4a. waitFor check: Checking rescanImageArea call...'); expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledTimes(1); expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledWith( expect.any(File), // 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40 { x: 20, y: 20, width: 100, height: 40 }, 'store_name', ); }); console.log('--- [TEST LOG] ---: 4b. SUCCESS: API call verified.'); // 5. Resolve the promise. console.log('--- [TEST LOG] ---: 5. Manually resolving the API promise inside act()...'); await act(async () => { console.log('--- [TEST LOG] ---: 5a. Calling resolveRescanPromise...'); resolveRescanPromise(new Response(JSON.stringify({ text: 'Super Store' }))); }); console.log('--- [TEST LOG] ---: 5b. Promise resolved and act() block finished.'); // 6. Assert the final state after the promise has resolved. console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...'); await waitFor(() => { console.log( '--- [TEST LOG] ---: 6a. waitFor check: Verifying notifications and callbacks...', ); expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store'); expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store'); expect(defaultProps.onClose).toHaveBeenCalledTimes(1); }); console.log('--- [TEST LOG] ---: 6b. SUCCESS: Final state verified.'); }); it('should show an error notification if image fetching fails', async () => { // Mock fetch to reject global.fetch = vi.fn(() => Promise.reject(new Error('Network error'))) as Mocked; renderWithProviders(); await waitFor(() => { expect(mockedNotifyError).toHaveBeenCalledWith('Could not load the image for correction.'); }); }); it('should show an error if rescan is attempted before image is loaded', async () => { console.log( 'TEST: Starting "should show an error if rescan is attempted before image is loaded"', ); // Override fetch to be pending forever so 'imageFile' remains null // This allows us to test the guard clause inside handleRescan while the button is enabled global.fetch = vi.fn(() => { console.log('TEST: fetch called, returning pending promise to simulate loading'); return new Promise(() => {}); }) as Mocked; renderWithProviders(); const canvas = screen.getByRole('dialog').querySelector('canvas')!; // Draw a selection to enable the button (bypassing the disabled={!selectionRect} check) console.log('TEST: Drawing selection to enable button'); fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 }); fireEvent.mouseUp(canvas); const extractButton = screen.getByRole('button', { name: /extract store name/i }); expect(extractButton).toBeEnabled(); console.log('TEST: Button is enabled, clicking now...'); // Attempt rescan. // - selectionRect is present (button enabled) // - imageFile is null (fetch pending) // -> Should trigger guard and notifyError fireEvent.click(extractButton); console.log('TEST: Checking for error notification'); expect(mockedNotifyError).toHaveBeenCalledWith('Please select an area on the image first.'); }); it('should handle non-standard API errors during rescan', async () => { console.log('TEST: Starting "should handle non-standard API errors during rescan"'); mockedAiApiClient.rescanImageArea.mockRejectedValue('A plain string error'); renderWithProviders(); // Wait for image fetch to ensure imageFile is set before we interact await waitFor(() => expect(global.fetch).toHaveBeenCalled()); // Allow the promise chain in useEffect to complete await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); const canvas = screen.getByRole('dialog').querySelector('canvas')!; fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 }); fireEvent.mouseUp(canvas); console.log('TEST: Clicking button to trigger API error'); fireEvent.click(screen.getByRole('button', { name: /extract store name/i })); await waitFor(() => { expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.'); }); }); it('should handle API failure response (ok: false) correctly', async () => { console.log('TEST: Starting "should handle API failure response (ok: false) correctly"'); mockedAiApiClient.rescanImageArea.mockResolvedValue({ ok: false, json: async () => ({ message: 'Custom API Error' }), } as Response); renderWithProviders(); // Wait for image fetch await waitFor(() => expect(global.fetch).toHaveBeenCalled()); // Draw selection const canvas = screen.getByRole('dialog').querySelector('canvas')!; fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); fireEvent.mouseMove(canvas, { clientX: 50, clientY: 50 }); fireEvent.mouseUp(canvas); // Click extract fireEvent.click(screen.getByRole('button', { name: /extract store name/i })); await waitFor(() => { expect(mockedNotifyError).toHaveBeenCalledWith('Custom API Error'); }); }); it('should redraw the canvas when the image loads', () => { console.log('TEST: Starting "should redraw the canvas when the image loads"'); const clearRectSpy = vi.fn(); // Override the getContext mock for this test to capture the spy window.HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ clearRect: clearRectSpy, strokeRect: vi.fn(), setLineDash: vi.fn(), strokeStyle: '', lineWidth: 0, })) as any; renderWithProviders(); const image = screen.getByAltText('Flyer for correction'); // The draw function is called on mount via useEffect, so we clear that call. clearRectSpy.mockClear(); // Simulate image load event which triggers onLoad={draw} fireEvent.load(image); expect(clearRectSpy).toHaveBeenCalled(); }); });