This commit is contained in:
2026-01-25 20:12:33 +08:00
parent 3c3868e2a7
commit fd7cb4485c
364 changed files with 66196 additions and 0 deletions

View File

@@ -0,0 +1,446 @@
/**
* 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 }
);
});
});