Files
flyer-crawler.projectium.com/src/components/FlyerCorrectionTool.test.tsx
Torben Sorensen 2564df1c64
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
get rid of localhost in tests - not a qualified URL - we'll see
2026-01-05 20:02:44 -08:00

306 lines
13 KiB
TypeScript

// 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<typeof notifySuccess>;
const mockedNotifyError = notifyError as Mocked<typeof notifyError>;
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
imageUrl: 'https://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<typeof fetch>;
// 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(<FlyerCorrectionTool {...defaultProps} isOpen={false} />);
expect(container.firstChild).toBeNull();
});
it('should render correctly when isOpen is true', () => {
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
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(<FlyerCorrectionTool {...defaultProps} />);
// 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(<FlyerCorrectionTool {...defaultProps} />);
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(<FlyerCorrectionTool {...defaultProps} />);
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(<FlyerCorrectionTool {...defaultProps} />);
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<Response>) => void;
const rescanPromise = new Promise<Response>((resolve) => {
resolveRescanPromise = resolve;
});
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
// 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<typeof fetch>;
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
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<typeof fetch>;
renderWithProviders(<FlyerCorrectionTool {...defaultProps} />);
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(<FlyerCorrectionTool {...defaultProps} />);
// 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(<FlyerCorrectionTool {...defaultProps} />);
// 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(<FlyerCorrectionTool {...defaultProps} />);
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();
});
});