All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
306 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|