Files
tfm_ainventory/frontend/tests/hooks/useAIExtraction.test.ts
Daniel Bedeleanu 08fc785583 feat: pass extracted image and image_processing metadata to item creation
- Updated confirmSingleItem() to include extractedImageBlob and imageProcessing
- Updated confirmAllItems() to pass image data for bulk item creation
- Each extracted item now carries its own image_processing metadata
- All items in bulk creation share the same extracted image blob
- Added 12 comprehensive tests verifying data is passed correctly
- All 465 frontend tests passing, zero regressions
2026-04-21 19:27:17 +03:00

444 lines
13 KiB
TypeScript

import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAIExtraction } from '@/hooks/useAIExtraction';
import * as api from '@/lib/api';
import { toast } from 'react-hot-toast';
vi.mock('@/lib/api');
vi.mock('react-hot-toast');
describe('useAIExtraction', () => {
const mockInventory = [
{ id: 1, name: 'Item 1', type: 'Type A', box_label: 'Box 1' },
{ id: 2, name: 'Item 2', type: 'Type B', box_label: 'Box 2' }
];
const mockOnComplete = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.inventoryApi.analyzeLabel).mockResolvedValue({
items: [
{
name: 'Test Item',
Item: 'Test Item',
category: 'Electronics',
Category: 'Electronics',
type: 'Resistor',
Type: 'Resistor',
part_number: 'R-001',
PartNr: 'R-001',
image_processing: {
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
rotation_degrees: 0,
confidence: 0.95
}
}
]
});
});
describe('extractedImageBlob state', () => {
it('should initialize extractedImageBlob as null', () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
expect(result.current.extractedImageBlob).toBeNull();
});
it('should store blob after processImage fetches from data URL', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['fake image data'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,abc123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedImageBlob).toBe(mockBlob);
});
it('should allow manual setExtractedImageBlob', () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const testBlob = new Blob(['test data'], { type: 'image/jpeg' });
act(() => {
result.current.setExtractedImageBlob(testBlob);
});
expect(result.current.extractedImageBlob).toBe(testBlob);
});
it('should allow clearing extractedImageBlob by setting to null', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
act(() => {
result.current.setExtractedImageBlob(mockBlob);
});
expect(result.current.extractedImageBlob).toBe(mockBlob);
act(() => {
result.current.setExtractedImageBlob(null);
});
expect(result.current.extractedImageBlob).toBeNull();
});
});
describe('extractedItems with image_processing metadata', () => {
it('should store extractedItems with image_processing from AI response', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,abc123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedItems).toHaveLength(1);
expect(result.current.extractedItems[0]).toMatchObject({
name: 'Test Item',
category: 'Electronics',
type: 'Resistor',
part_number: 'R-001',
image_processing: {
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
rotation_degrees: 0,
confidence: 0.95
}
});
});
it('should preserve image_processing when handling wrapped AI responses', () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
// Test that the hook properly handles items with image_processing metadata
const itemWithMetadata = {
Item: 'Test Item',
name: 'Test Item',
image_processing: {
crop_bounds: { x: 5, y: 15, width: 200, height: 150 },
rotation_degrees: 90,
confidence: 0.87
}
};
act(() => {
result.current.setExtractedItems([itemWithMetadata]);
});
expect(result.current.extractedItems[0].image_processing).toEqual({
crop_bounds: { x: 5, y: 15, width: 200, height: 150 },
rotation_degrees: 90,
confidence: 0.87
});
});
it('should handle multiple items each with independent image_processing', async () => {
(api.inventoryApi.analyzeLabel as any).mockResolvedValue({
items: [
{
name: 'Item 1',
Item: 'Item 1',
image_processing: {
crop_bounds: { x: 0, y: 0, width: 100, height: 100 },
rotation_degrees: 0,
confidence: 0.9
}
},
{
name: 'Item 2',
Item: 'Item 2',
image_processing: {
crop_bounds: { x: 110, y: 0, width: 100, height: 100 },
rotation_degrees: 45,
confidence: 0.85
}
}
]
});
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,multi123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedItems).toHaveLength(2);
expect(result.current.extractedItems[0].image_processing.crop_bounds).toEqual({
x: 0,
y: 0,
width: 100,
height: 100
});
expect(result.current.extractedItems[1].image_processing.crop_bounds).toEqual({
x: 110,
y: 0,
width: 100,
height: 100
});
});
});
describe('blob and metadata together', () => {
it('should store both blob and image_processing for use in photo upload', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['image data'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,together123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
// Both blob and metadata should be available
expect(result.current.extractedImageBlob).toBe(mockBlob);
expect(result.current.extractedItems).toHaveLength(1);
const item = result.current.extractedItems[0];
expect(item.image_processing).toBeDefined();
expect(item.image_processing.crop_bounds).toBeDefined();
});
it('should maintain blob when extractedItems are updated', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,maintain123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
const originalBlob = result.current.extractedImageBlob;
act(() => {
result.current.updateEditingItem({ name: 'Updated Name' });
});
expect(result.current.extractedImageBlob).toBe(originalBlob);
});
});
describe('cleanup and reset', () => {
it('should clear extractedImageBlob when resetting extracted items', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,reset123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedImageBlob).toBe(mockBlob);
act(() => {
result.current.setExtractedItems([]);
result.current.setExtractedImageBlob(null);
});
expect(result.current.extractedItems).toHaveLength(0);
expect(result.current.extractedImageBlob).toBeNull();
});
it('should allow resetting image without affecting blob storage', () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
act(() => {
result.current.setExtractedImageBlob(mockBlob);
result.current.setImage('data:image/jpeg;base64,somedata');
});
expect(result.current.extractedImageBlob).toBe(mockBlob);
expect(result.current.image).toBe('data:image/jpeg;base64,somedata');
act(() => {
result.current.setImage(null);
});
expect(result.current.extractedImageBlob).toBe(mockBlob);
expect(result.current.image).toBeNull();
});
});
describe('error handling', () => {
it('should not set extractedImageBlob if fetch fails', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const dataURL = 'data:image/jpeg;base64,error123';
global.fetch = vi.fn().mockRejectedValue(new Error('Fetch failed'));
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedImageBlob).toBeNull();
});
it('should not set extractedImageBlob if blob conversion fails', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const dataURL = 'data:image/jpeg;base64,blobfail123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockRejectedValue(new Error('Blob conversion failed'))
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedImageBlob).toBeNull();
});
});
describe('accessibility for photo upload', () => {
it('should provide extractedImageBlob as FormData-ready Blob for later photo upload', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const mockBlob = new Blob(['fake jpeg data'], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,formdata123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
// Blob should be usable in FormData
const formData = new FormData();
formData.append('file', result.current.extractedImageBlob!, 'photo.jpg');
// FormData converts Blob to File, but content should be accessible
const fileEntry = formData.get('file');
expect(fileEntry).toBeTruthy();
expect(fileEntry instanceof Blob || fileEntry instanceof File).toBe(true);
});
it('should preserve blob size and type for upload validation', async () => {
const { result } = renderHook(() =>
useAIExtraction(mockInventory, mockOnComplete)
);
const blobData = new Uint8Array(5000); // 5KB blob
const mockBlob = new Blob([blobData], { type: 'image/jpeg' });
const dataURL = 'data:image/jpeg;base64,size123';
global.fetch = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(mockBlob)
});
act(() => {
result.current.setImage(dataURL);
});
await act(async () => {
await result.current.processImage();
});
expect(result.current.extractedImageBlob?.size).toBe(5000);
expect(result.current.extractedImageBlob?.type).toBe('image/jpeg');
});
});
});