447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
/**
|
|
* 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(
|
|
<ImageAttachment
|
|
images={uniqueImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ImageAttachment
|
|
images={updatedImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ImageAttachment
|
|
images={uniqueImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ImageAttachment
|
|
images={uniqueImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<ImageAttachment
|
|
images={uniqueImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<ImageAttachment
|
|
images={uniqueImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<ImageAttachment
|
|
images={uniqueImages}
|
|
onAdd={mockOnAdd}
|
|
onRemove={mockOnRemove}
|
|
onPreview={mockOnPreview}
|
|
/>
|
|
);
|
|
|
|
// 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 }
|
|
);
|
|
});
|
|
});
|