421 lines
17 KiB
C#
421 lines
17 KiB
C#
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 分页查询,每页返回的数据量应不超过 pageSize(50),且所有页的数据合并后应等于总记录数。
|
||
/// 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
|
||
}
|
||
}
|