WorkCamera/client/WorkCameraExport.Tests/ExportServicePropertyTests.cs
2026-01-06 22:23:29 +08:00

421 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using FsCheck;
using FsCheck.Xunit;
using OfficeOpenXml;
using WorkCameraExport.Models;
using WorkCameraExport.Services;
using Xunit;
namespace WorkCameraExport.Tests
{
/// <summary>
/// ExportService 属性测试
/// Feature: work-camera-2.0.1
/// </summary>
public class ExportServicePropertyTests : IDisposable
{
private readonly string _testOutputDir;
public ExportServicePropertyTests()
{
_testOutputDir = Path.Combine(Path.GetTempPath(), $"ExportService_Test_{Guid.NewGuid():N}");
Directory.CreateDirectory(_testOutputDir);
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
}
public void Dispose()
{
try
{
if (Directory.Exists(_testOutputDir))
{
Directory.Delete(_testOutputDir, true);
}
}
catch
{
// 忽略清理错误
}
}
#region Property 5:
/// <summary>
/// Property 5: 导出数据完整性
/// For any 导出操作,导出的 Excel 文件中的记录数应等于查询结果的记录数(导出全部)或选中的记录数(导出所选)。
/// Validates: Requirements 6.1, 6.2
/// </summary>
[Property(MaxTest = 100)]
public Property ExportedExcel_RecordCount_ShouldMatchInputCount()
{
return Prop.ForAll(
Arb.From<PositiveInt>(),
(recordCountGen) =>
{
var recordCount = (recordCountGen.Get % 50) + 1; // 1-50 条记录
var records = GenerateTestRecords(recordCount);
var downloadedImages = new Dictionary<string, byte[]>();
var outputPath = Path.Combine(_testOutputDir, $"test_{Guid.NewGuid():N}.xlsx");
var excelService = new ExcelService();
// 生成 Excel
excelService.GenerateExcel(outputPath, records, downloadedImages);
// 验证记录数
using var package = new ExcelPackage(new FileInfo(outputPath));
var worksheet = package.Workbook.Worksheets[0];
var dataRowCount = worksheet.Dimension.End.Row - 1; // 减去表头行
// 清理
File.Delete(outputPath);
return (dataRowCount == recordCount)
.Label($"Excel row count {dataRowCount} should equal input record count {recordCount}");
});
}
/// <summary>
/// Property 5 扩展: 导出的数据内容应与输入一致
/// </summary>
[Property(MaxTest = 100)]
public Property ExportedExcel_DataContent_ShouldMatchInput()
{
return Prop.ForAll(
Arb.From<PositiveInt>(),
(recordCountGen) =>
{
var recordCount = (recordCountGen.Get % 20) + 1;
var records = GenerateTestRecords(recordCount);
var downloadedImages = new Dictionary<string, byte[]>();
var outputPath = Path.Combine(_testOutputDir, $"test_{Guid.NewGuid():N}.xlsx");
var excelService = new ExcelService();
excelService.GenerateExcel(outputPath, records, downloadedImages);
using var package = new ExcelPackage(new FileInfo(outputPath));
var worksheet = package.Workbook.Worksheets[0];
// 验证每条记录的部门名称
var allMatch = true;
for (int i = 0; i < records.Count; i++)
{
var row = i + 2; // 跳过表头
var cellValue = worksheet.Cells[row, 2].Value?.ToString() ?? "";
if (cellValue != records[i].DeptName)
{
allMatch = false;
break;
}
}
File.Delete(outputPath);
return allMatch.Label("All department names should match");
});
}
#endregion
#region Property 6:
/// <summary>
/// Property 6: 分页数据获取
/// For any 分页查询,每页返回的数据量应不超过 pageSize50且所有页的数据合并后应等于总记录数。
/// Validates: Requirements 6.3
/// </summary>
[Property(MaxTest = 100)]
public Property PaginatedData_PageSize_ShouldNotExceedLimit()
{
return Prop.ForAll(
Arb.From<PositiveInt>(),
Arb.From<PositiveInt>(),
(totalCountGen, pageSizeGen) =>
{
var totalCount = (totalCountGen.Get % 200) + 1; // 1-200 条记录
var pageSize = (pageSizeGen.Get % 50) + 1; // 1-50 每页
// 模拟分页
var pages = SimulatePagination(totalCount, pageSize);
// 验证每页数据量不超过 pageSize
var allPagesValid = pages.All(p => p.Count <= pageSize);
// 验证总数据量等于 totalCount
var totalFromPages = pages.Sum(p => p.Count);
return (allPagesValid && totalFromPages == totalCount)
.Label($"All pages should have <= {pageSize} items and total should be {totalCount}, got {totalFromPages}");
});
}
/// <summary>
/// Property 6 扩展: 分页后的数据应保持顺序
/// </summary>
[Property(MaxTest = 100)]
public Property PaginatedData_Order_ShouldBePreserved()
{
return Prop.ForAll(
Arb.From<PositiveInt>(),
(totalCountGen) =>
{
var totalCount = (totalCountGen.Get % 100) + 1;
var pageSize = 50;
// 生成有序数据
var allData = Enumerable.Range(1, totalCount).ToList();
// 模拟分页获取
var pages = SimulatePaginationWithData(allData, pageSize);
// 合并所有页
var merged = pages.SelectMany(p => p).ToList();
// 验证顺序
var orderPreserved = merged.SequenceEqual(allData);
return orderPreserved.Label("Data order should be preserved after pagination");
});
}
#endregion
#region Property 8: Excel
/// <summary>
/// Property 8: Excel 图片布局
/// For any 导出的 Excel 文件,每个单元格中的图片应按水平排列,图片尺寸应为 100x60 像素。
/// Validates: Requirements 6.6, 6.7
/// </summary>
[Property(MaxTest = 50)]
public Property ExcelImages_Layout_ShouldBeHorizontal()
{
return Prop.ForAll(
Arb.From<PositiveInt>(),
(imageCountGen) =>
{
var imageCount = (imageCountGen.Get % 5) + 1; // 1-5 张图片
var records = new List<WorkRecordExportDto>
{
GenerateRecordWithImages(imageCount)
};
// 创建测试图片数据
var downloadedImages = new Dictionary<string, byte[]>();
foreach (var url in records[0].Images)
{
downloadedImages[url] = CreateTestImageData();
}
var outputPath = Path.Combine(_testOutputDir, $"test_{Guid.NewGuid():N}.xlsx");
var excelService = new ExcelService();
excelService.GenerateExcel(outputPath, records, downloadedImages);
using var package = new ExcelPackage(new FileInfo(outputPath));
var worksheet = package.Workbook.Worksheets[0];
// 获取图片
var drawings = worksheet.Drawings;
var imagesInRow = drawings.Where(d => d.Name.StartsWith("img_2_3_")).ToList();
// 验证图片数量
var countMatch = imagesInRow.Count == imageCount;
// 验证图片水平排列X 坐标递增)
var horizontalLayout = true;
if (imagesInRow.Count > 1)
{
var xPositions = imagesInRow
.Select(d => d.From.ColumnOff)
.ToList();
for (int i = 1; i < xPositions.Count; i++)
{
if (xPositions[i] <= xPositions[i - 1])
{
horizontalLayout = false;
break;
}
}
}
File.Delete(outputPath);
return (countMatch && horizontalLayout)
.Label($"Image count should be {imageCount} (got {imagesInRow.Count}) and layout should be horizontal");
});
}
/// <summary>
/// Property 8 扩展: 图片应被正确嵌入到 Excel
/// 验证图片数量和存在性而不是精确尺寸EPPlus 内部处理尺寸)
/// </summary>
[Property(MaxTest = 50)]
public Property ExcelImages_ShouldBeEmbedded()
{
return Prop.ForAll(
Arb.From<PositiveInt>(),
(imageCountGen) =>
{
var imageCount = (imageCountGen.Get % 3) + 1;
var records = new List<WorkRecordExportDto>
{
GenerateRecordWithImages(imageCount)
};
var downloadedImages = new Dictionary<string, byte[]>();
foreach (var url in records[0].Images)
{
downloadedImages[url] = CreateTestImageData();
}
var outputPath = Path.Combine(_testOutputDir, $"test_{Guid.NewGuid():N}.xlsx");
var excelService = new ExcelService();
excelService.GenerateExcel(outputPath, records, downloadedImages);
using var package = new ExcelPackage(new FileInfo(outputPath));
var worksheet = package.Workbook.Worksheets[0];
var drawings = worksheet.Drawings;
var images = drawings.Where(d => d.Name.StartsWith("img_")).ToList();
// 验证图片数量正确
var countCorrect = images.Count == imageCount;
// 验证所有图片都是 ExcelPicture 类型
var allArePictures = images.All(img => img is OfficeOpenXml.Drawing.ExcelPicture);
File.Delete(outputPath);
return (countCorrect && allArePictures)
.Label($"Should have {imageCount} embedded pictures, got {images.Count}");
});
}
#endregion
#region
/// <summary>
/// 生成测试记录
/// </summary>
private List<WorkRecordExportDto> GenerateTestRecords(int count)
{
return Enumerable.Range(1, count).Select(i => new WorkRecordExportDto
{
Id = i,
DeptName = $"部门{i}",
RecordTime = DateTime.Now.AddDays(-i),
Address = $"地址{i}",
Content = $"工作内容{i}",
StatusName = "正常",
Workers = new List<string> { $"工人{i}" },
Images = new List<string>(),
CreateTime = DateTime.Now.AddDays(-i),
UpdateTime = DateTime.Now
}).ToList();
}
/// <summary>
/// 生成带图片的测试记录
/// </summary>
private WorkRecordExportDto GenerateRecordWithImages(int imageCount)
{
return new WorkRecordExportDto
{
Id = 1,
DeptName = "测试部门",
RecordTime = DateTime.Now,
Address = "测试地址",
Content = "测试内容",
StatusName = "正常",
Workers = new List<string> { "测试工人" },
Images = Enumerable.Range(1, imageCount)
.Select(i => $"http://test.local/image{i}.jpg")
.ToList(),
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now
};
}
/// <summary>
/// 模拟分页
/// </summary>
private List<List<int>> SimulatePagination(int totalCount, int pageSize)
{
var pages = new List<List<int>>();
var remaining = totalCount;
var pageNum = 0;
while (remaining > 0)
{
var count = Math.Min(remaining, pageSize);
pages.Add(Enumerable.Range(pageNum * pageSize + 1, count).ToList());
remaining -= count;
pageNum++;
}
return pages;
}
/// <summary>
/// 模拟分页(带数据)
/// </summary>
private List<List<int>> SimulatePaginationWithData(List<int> data, int pageSize)
{
var pages = new List<List<int>>();
for (int i = 0; i < data.Count; i += pageSize)
{
pages.Add(data.Skip(i).Take(pageSize).ToList());
}
return pages;
}
/// <summary>
/// 创建测试图片数据(最小有效 JPEG
/// </summary>
private static byte[] CreateTestImageData()
{
return new byte[]
{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00,
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72,
0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45,
0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6,
0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4,
0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF1, 0x7E, 0xA9,
0x00, 0x0C, 0x3E, 0xF8, 0xA8, 0x6E, 0x2D, 0xA3, 0x80, 0x0F, 0x2A, 0x36,
0x07, 0xB0, 0xA0, 0x0F, 0xFF, 0xD9
};
}
#endregion
}
}