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
}
}