using FsCheck; using FsCheck.Xunit; using OfficeOpenXml; using WorkCameraExport.Models; using WorkCameraExport.Services; using Xunit; namespace WorkCameraExport.Tests { /// /// ExportService 属性测试 /// Feature: work-camera-2.0.1 /// 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: 导出数据完整性 /// /// Property 5: 导出数据完整性 /// For any 导出操作,导出的 Excel 文件中的记录数应等于查询结果的记录数(导出全部)或选中的记录数(导出所选)。 /// Validates: Requirements 6.1, 6.2 /// [Property(MaxTest = 100)] public Property ExportedExcel_RecordCount_ShouldMatchInputCount() { return Prop.ForAll( Arb.From(), (recordCountGen) => { var recordCount = (recordCountGen.Get % 50) + 1; // 1-50 条记录 var records = GenerateTestRecords(recordCount); var downloadedImages = new Dictionary(); 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}"); }); } /// /// Property 5 扩展: 导出的数据内容应与输入一致 /// [Property(MaxTest = 100)] public Property ExportedExcel_DataContent_ShouldMatchInput() { return Prop.ForAll( Arb.From(), (recordCountGen) => { var recordCount = (recordCountGen.Get % 20) + 1; var records = GenerateTestRecords(recordCount); var downloadedImages = new Dictionary(); 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: 分页数据获取 /// /// Property 6: 分页数据获取 /// For any 分页查询,每页返回的数据量应不超过 pageSize(50),且所有页的数据合并后应等于总记录数。 /// Validates: Requirements 6.3 /// [Property(MaxTest = 100)] public Property PaginatedData_PageSize_ShouldNotExceedLimit() { return Prop.ForAll( Arb.From(), Arb.From(), (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}"); }); } /// /// Property 6 扩展: 分页后的数据应保持顺序 /// [Property(MaxTest = 100)] public Property PaginatedData_Order_ShouldBePreserved() { return Prop.ForAll( Arb.From(), (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 图片布局 /// /// Property 8: Excel 图片布局 /// For any 导出的 Excel 文件,每个单元格中的图片应按水平排列,图片尺寸应为 100x60 像素。 /// Validates: Requirements 6.6, 6.7 /// [Property(MaxTest = 50)] public Property ExcelImages_Layout_ShouldBeHorizontal() { return Prop.ForAll( Arb.From(), (imageCountGen) => { var imageCount = (imageCountGen.Get % 5) + 1; // 1-5 张图片 var records = new List { GenerateRecordWithImages(imageCount) }; // 创建测试图片数据 var downloadedImages = new Dictionary(); 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"); }); } /// /// Property 8 扩展: 图片应被正确嵌入到 Excel /// 验证图片数量和存在性,而不是精确尺寸(EPPlus 内部处理尺寸) /// [Property(MaxTest = 50)] public Property ExcelImages_ShouldBeEmbedded() { return Prop.ForAll( Arb.From(), (imageCountGen) => { var imageCount = (imageCountGen.Get % 3) + 1; var records = new List { GenerateRecordWithImages(imageCount) }; var downloadedImages = new Dictionary(); 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 辅助方法 /// /// 生成测试记录 /// private List 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 { $"工人{i}" }, Images = new List(), CreateTime = DateTime.Now.AddDays(-i), UpdateTime = DateTime.Now }).ToList(); } /// /// 生成带图片的测试记录 /// private WorkRecordExportDto GenerateRecordWithImages(int imageCount) { return new WorkRecordExportDto { Id = 1, DeptName = "测试部门", RecordTime = DateTime.Now, Address = "测试地址", Content = "测试内容", StatusName = "正常", Workers = new List { "测试工人" }, Images = Enumerable.Range(1, imageCount) .Select(i => $"http://test.local/image{i}.jpg") .ToList(), CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; } /// /// 模拟分页 /// private List> SimulatePagination(int totalCount, int pageSize) { var pages = new List>(); 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; } /// /// 模拟分页(带数据) /// private List> SimulatePaginationWithData(List data, int pageSize) { var pages = new List>(); for (int i = 0; i < data.Count; i += pageSize) { pages.Add(data.Skip(i).Take(pageSize).ToList()); } return pages; } /// /// 创建测试图片数据(最小有效 JPEG) /// 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 } }