/** * ImageAttachment Component Property-Based Tests * Feature: accounting-feature-upgrade * Property 8: Image deletion consistency * Validates: Requirements 4.7 */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import fc from 'fast-check'; import { ImageAttachment } from './ImageAttachment'; import type { TransactionImage } from '../../../types'; describe('ImageAttachment Property Tests', () => { // Clean up after each test to avoid DOM pollution afterEach(() => { cleanup(); }); /** * Property 8: Image deletion consistency * For any image list and any delete operation, after deletion: * - The image should be removed from the list * - The list length should decrease by 1 * * **Validates: Requirements 4.7** */ it('Property 8: should maintain deletion consistency', () => { fc.assert( fc.property( // Generate array of 1-9 images fc.array( fc.record({ id: fc.integer({ min: 1, max: 10000 }), transactionId: fc.constant(1), filePath: fc.string(), fileName: fc.string(), fileSize: fc.integer({ min: 1, max: 10485760 }), mimeType: fc.constantFrom('image/jpeg', 'image/png', 'image/heic'), createdAt: fc.constant('2024-01-01T00:00:00Z'), }), { minLength: 1, maxLength: 9 } ), // Generate index to delete fc.nat(), (images, deleteIndexRaw) => { // Clean up before each iteration cleanup(); // Ensure unique IDs const uniqueImages = images.map((img, idx) => ({ ...img, id: idx + 1, })); const deleteIndex = deleteIndexRaw % uniqueImages.length; const imageToDelete = uniqueImages[deleteIndex]; // Track deletion let deletedImageId: number | null = null; const mockOnRemove = (imageId: number) => { deletedImageId = imageId; }; const mockOnAdd = vi.fn(); const mockOnPreview = vi.fn(); // Render component const { rerender, container } = render( ); // Verify initial state const initialCount = uniqueImages.length; const countElement = container.querySelector('.image-attachment__count'); expect(countElement?.textContent?.trim().replace(/\s+/g, ' ')).toBe(`${initialCount} / 9`); // Find and click delete button for the target image const deleteButtons = container.querySelectorAll('[aria-label="删除图片"]'); fireEvent.click(deleteButtons[deleteIndex]); // Verify onRemove was called with correct ID expect(deletedImageId).toBe(imageToDelete.id); // Simulate state update after deletion const updatedImages = uniqueImages.filter((img) => img.id !== imageToDelete.id); rerender( ); // Property verification: // 1. List length should decrease by 1 const newCount = updatedImages.length; expect(newCount).toBe(initialCount - 1); const newCountElement = container.querySelector('.image-attachment__count'); expect(newCountElement?.textContent?.trim().replace(/\s+/g, ' ')).toBe(`${newCount} / 9`); // 2. Deleted image should not be in the list const remainingImages = container.querySelectorAll('img'); expect(remainingImages).toHaveLength(newCount); // 3. All remaining images should be different from deleted image const remainingIds = updatedImages.map((img) => img.id); expect(remainingIds).not.toContain(imageToDelete.id); // Clean up after iteration cleanup(); return true; } ), { numRuns: 100 } ); }); /** * Property: Image count display consistency * For any valid image array (0-9 images), the displayed count should match the array length * * **Validates: Requirements 4.9** */ it('Property: should display correct image count for any valid array', () => { fc.assert( fc.property( fc.array( fc.record({ id: fc.integer({ min: 1, max: 10000 }), transactionId: fc.constant(1), filePath: fc.string(), fileName: fc.string(), fileSize: fc.integer({ min: 1, max: 10485760 }), mimeType: fc.constantFrom('image/jpeg', 'image/png', 'image/heic'), createdAt: fc.constant('2024-01-01T00:00:00Z'), }), { minLength: 0, maxLength: 9 } ), (images) => { // Clean up before each iteration cleanup(); // Ensure unique IDs const uniqueImages = images.map((img, idx) => ({ ...img, id: idx + 1, })); const mockOnAdd = vi.fn(); const mockOnRemove = vi.fn(); const mockOnPreview = vi.fn(); const { container } = render( ); // Verify count display const expectedCount = uniqueImages.length; const countElement = container.querySelector('.image-attachment__count'); expect(countElement?.textContent?.trim().replace(/\s+/g, ' ')).toBe(`${expectedCount} / 9`); // Verify actual rendered images match count const renderedImages = container.querySelectorAll('img'); expect(renderedImages).toHaveLength(expectedCount); // Clean up after iteration cleanup(); return true; } ), { numRuns: 100 } ); }); /** * Property: Add button visibility based on image count * For any image array, add button should be visible if count < 9, hidden if count >= 9 * * **Validates: Requirements 4.9, 4.12** */ it('Property: should show/hide add button based on image count', () => { fc.assert( fc.property( fc.array( fc.record({ id: fc.integer({ min: 1, max: 10000 }), transactionId: fc.constant(1), filePath: fc.string(), fileName: fc.string(), fileSize: fc.integer({ min: 1, max: 10485760 }), mimeType: fc.constantFrom('image/jpeg', 'image/png', 'image/heic'), createdAt: fc.constant('2024-01-01T00:00:00Z'), }), { minLength: 0, maxLength: 9 } ), (images) => { // Clean up before each iteration cleanup(); // Ensure unique IDs const uniqueImages = images.map((img, idx) => ({ ...img, id: idx + 1, })); const mockOnAdd = vi.fn(); const mockOnRemove = vi.fn(); const mockOnPreview = vi.fn(); const { container } = render( ); const addButton = container.querySelector('[aria-label="添加图片"]'); const imageCount = uniqueImages.length; // Property: Add button visible iff count < 9 if (imageCount < 9) { expect(addButton).toBeInTheDocument(); } else { expect(addButton).not.toBeInTheDocument(); } // Clean up after iteration cleanup(); return true; } ), { numRuns: 100 } ); }); /** * Property: Warning display based on image count * For any image array, warning should be visible if count >= 7, hidden if count < 7 * * **Validates: Requirements 4.9, 4.12** */ it('Property: should show warning when approaching limit', () => { fc.assert( fc.property( fc.array( fc.record({ id: fc.integer({ min: 1, max: 10000 }), transactionId: fc.constant(1), filePath: fc.string(), fileName: fc.string(), fileSize: fc.integer({ min: 1, max: 10485760 }), mimeType: fc.constantFrom('image/jpeg', 'image/png', 'image/heic'), createdAt: fc.constant('2024-01-01T00:00:00Z'), }), { minLength: 0, maxLength: 9 } ), (images) => { // Clean up before each iteration cleanup(); // Ensure unique IDs const uniqueImages = images.map((img, idx) => ({ ...img, id: idx + 1, })); const mockOnAdd = vi.fn(); const mockOnRemove = vi.fn(); const mockOnPreview = vi.fn(); const { container } = render( ); const warning = container.querySelector('.image-attachment__warning'); const imageCount = uniqueImages.length; // Property: Warning visible iff count >= 7 if (imageCount >= 7) { expect(warning).toBeInTheDocument(); } else { expect(warning).not.toBeInTheDocument(); } // Clean up after iteration cleanup(); return true; } ), { numRuns: 100 } ); }); /** * Property: Delete button count matches image count * For any non-empty image array (when not disabled), * the number of delete buttons should equal the number of images * * **Validates: Requirements 4.7** */ it('Property: should render delete button for each image', () => { fc.assert( fc.property( fc.array( fc.record({ id: fc.integer({ min: 1, max: 10000 }), transactionId: fc.constant(1), filePath: fc.string(), fileName: fc.string(), fileSize: fc.integer({ min: 1, max: 10485760 }), mimeType: fc.constantFrom('image/jpeg', 'image/png', 'image/heic'), createdAt: fc.constant('2024-01-01T00:00:00Z'), }), { minLength: 1, maxLength: 9 } ), (images) => { // Clean up before each iteration cleanup(); // Ensure unique IDs const uniqueImages = images.map((img, idx) => ({ ...img, id: idx + 1, })); const mockOnAdd = vi.fn(); const mockOnRemove = vi.fn(); const mockOnPreview = vi.fn(); const { container } = render( ); const deleteButtons = container.querySelectorAll('[aria-label="删除图片"]'); // Property: Number of delete buttons equals number of images expect(deleteButtons).toHaveLength(uniqueImages.length); // Clean up after iteration cleanup(); return true; } ), { numRuns: 100 } ); }); /** * Property: Preview callback receives correct index * For any image array and any click on an image, * the onPreview callback should be called with the correct index * * **Validates: Requirements 4.6** */ it('Property: should call onPreview with correct index', () => { fc.assert( fc.property( fc.array( fc.record({ id: fc.integer({ min: 1, max: 10000 }), transactionId: fc.constant(1), filePath: fc.string(), fileName: fc.string(), fileSize: fc.integer({ min: 1, max: 10485760 }), mimeType: fc.constantFrom('image/jpeg', 'image/png', 'image/heic'), createdAt: fc.constant('2024-01-01T00:00:00Z'), }), { minLength: 1, maxLength: 9 } ), fc.nat(), (images, clickIndexRaw) => { // Clean up before each iteration cleanup(); // Ensure unique IDs const uniqueImages = images.map((img, idx) => ({ ...img, id: idx + 1, })); const clickIndex = clickIndexRaw % uniqueImages.length; let previewedIndex: number | null = null; const mockOnPreview = (index: number) => { previewedIndex = index; }; const mockOnAdd = vi.fn(); const mockOnRemove = vi.fn(); const { container } = render( ); // Click on the image at clickIndex const imageItems = container.querySelectorAll('.image-attachment__item'); fireEvent.click(imageItems[clickIndex]); // Property: onPreview called with correct index expect(previewedIndex).toBe(clickIndex); // Clean up after iteration cleanup(); return true; } ), { numRuns: 100 } ); }); });