Files
XplorePlane/XplorePlane.Tests/Services/Reporting/CncReportPipelineIntegrationTests.cs
T
2026-08-10 14:13:32 +08:00

202 lines
10 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 System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Moq;
using XP.Common.Localization.Enums;
using XP.Common.Localization.Interfaces;
using XP.Common.Logging.Interfaces;
using XP.ReportEngine.Models;
using XP.ReportEngine.Services;
using XplorePlane.Models;
using XplorePlane.Services.Reporting;
using Xunit;
namespace XplorePlane.Tests.Services.Reporting
{
/// <summary>
/// CNC 报告管线集成测试:真实 CNC 模板 + 映射构建器 + 分段重复绑定 + 排版。
/// 不触发 iText 渲染(规避字体/环境依赖),聚焦「逐节点展开」端到端正确性。
/// Integration test for the CNC report pipeline using the real CNC template: builder + repeat-section
/// binding + layout. Skips iText rendering (avoids font/environment dependencies), focusing on the
/// end-to-end correctness of per-node expansion.
/// </summary>
public class CncReportPipelineIntegrationTests
{
private static string CncTemplatePath =>
Path.Combine(AppContext.BaseDirectory, "Templates", "CncInspectionReportTemplate.json");
private static Mock<ILoggerService> Logger()
{
var m = new Mock<ILoggerService>();
m.Setup(l => l.ForModule<It.IsAnyType>()).Returns(m.Object);
return m;
}
/// <summary>
/// 构造与示例 manifest1c077c0f_)等价的运行明细:1 个采图节点(带 Pass 标记) + 1 个检测节点(4 指标, Fail 标记)。
/// </summary>
private static InspectionRunDetail CreateSampleDetail()
{
var runId = Guid.NewGuid();
var captureNode = Guid.NewGuid();
var inspectionNode = Guid.NewGuid();
return new InspectionRunDetail
{
SchemaVersion = 2,
Run = new InspectionRunRecord
{
RunId = runId,
ProgramName = "新建检测程序",
OverallPass = true,
StartedAt = new DateTime(2026, 7, 6, 7, 45, 53, DateTimeKind.Utc),
CompletedAt = new DateTime(2026, 7, 6, 7, 46, 4, DateTimeKind.Utc),
ResultRootPath = "Results/2026-07-06/1c077c0f_20260706_154553",
NodeCount = 2
},
Nodes = new List<InspectionNodeResult>
{
new() { RunId = runId, NodeId = captureNode, NodeIndex = 0, NodeName = "位置1", NodeKind = InspectionNodeKind.Capture, NodePass = true },
new() { RunId = runId, NodeId = inspectionNode, NodeIndex = 1, NodeName = "模块1", NodeKind = InspectionNodeKind.Inspection, NodePass = false, PipelineName = "检测模块_1" }
},
Metrics = new List<InspectionMetricResult>
{
new() { RunId = runId, NodeId = inspectionNode, MetricKey = "BgaCount", MetricName = "BGA焊球数", MetricValue = 32, Unit = "个", IsPass = true, DisplayOrder = 0 },
new() { RunId = runId, NodeId = inspectionNode, MetricKey = "BgaVoidRate", MetricName = "BGA空洞率", MetricValue = 0, Unit = "%", UpperLimit = 25, IsPass = true, DisplayOrder = 1 },
new() { RunId = runId, NodeId = inspectionNode, MetricKey = "BgaFillRate", MetricName = "BGA填充率", MetricValue = 100, Unit = "%", IsPass = true, DisplayOrder = 2 },
new() { RunId = runId, NodeId = inspectionNode, MetricKey = "BgaTotalVoidCount", MetricName = "气泡总数", MetricValue = 0, Unit = "个", IsPass = true, DisplayOrder = 3 }
},
Marks = new List<InspectionMarkRecord>
{
new() { CncNodeId = captureNode, Type = InspectionMarkType.Pass, Status = InspectionMarkStatus.Pass, Comment = "位置1" },
new() { CncNodeId = inspectionNode, Type = InspectionMarkType.Fail, Status = InspectionMarkStatus.Fail, Comment = "模块1" }
}
};
}
/// <summary>
/// 最小复刻 ReportService 的上下文组装:将请求映射为 ReportContext(本测试不调用真实 ReportService,避免 iText 渲染)。
/// </summary>
private static ReportContext BuildContext(ReportRequest request)
{
var context = new ReportContext
{
Metadata = request.Metadata,
Properties = new Dictionary<string, object>(),
Images = new Dictionary<string, ImageData>()
};
foreach (var kvp in request.CustomProperties) context.Properties[kvp.Key] = kvp.Value;
foreach (var kvp in request.AdditionalImages) context.Images[kvp.Key] = kvp.Value;
return context;
}
[Fact]
public void Pipeline_ExpandsOneNodePagePerReportableNode_WithRealTemplate()
{
Assert.True(File.Exists(CncTemplatePath), $"未找到 CNC 模板,应随构建拷贝: {CncTemplatePath}");
// 1. 映射 | Map
var builderLogger = new Mock<ILoggerService>();
builderLogger.Setup(l => l.ForModule<CncReportContextBuilder>()).Returns(builderLogger.Object);
var builder = new CncReportContextBuilder(builderLogger.Object);
var request = builder.Build(CreateSampleDetail(), Path.GetTempPath());
Assert.Equal(CncReportContextBuilder.CncTemplateRelativePath, request.TemplatePathOverride);
// 2. 加载真实 CNC 模板 | Load the real CNC template
var templateEngine = new JsonTemplateEngine(Logger().Object);
var template = templateEngine.LoadTemplate(CncTemplatePath);
Assert.NotNull(template);
Assert.True(templateEngine.Validate(template).IsValid);
// 模板含 1 个重复页(repeat=nodeSections| Template has one repeat page
Assert.Contains(template.Pages, p => string.Equals(p.Repeat, "nodeSections", StringComparison.OrdinalIgnoreCase));
// 3. 数据绑定(含分段重复展开)| Data binding (with repeat-section expansion)
var loc = new Mock<ILocalizationService>();
loc.SetupGet(l => l.CurrentLanguage).Returns(SupportedLanguage.ZhCN);
var binder = new ExpressionDataBinder(Logger().Object, loc.Object);
var context = BuildContext(request);
var bound = binder.Bind(template, context);
// 首页 1 + 2 个可报告节点(检测节点 + 有标记的采图节点)= 3 个模板页
var nodeDetailPages = bound.Pages.Count(p => string.Equals(p.Type, "nodeDetail", StringComparison.OrdinalIgnoreCase));
Assert.Equal(2, nodeDetailPages);
Assert.Contains(bound.Pages, p => string.Equals(p.Type, "homepage", StringComparison.OrdinalIgnoreCase));
Assert.Equal(3, bound.Pages.Count);
// 节点页正文已按分段作用域绑定(节点名出现在文本中)
var boundText = bound.Pages
.Where(p => p.Type == "nodeDetail")
.SelectMany(p => p.Elements)
.Select(e => e.Content ?? string.Empty)
.ToList();
Assert.Contains(boundText, t => t.Contains("模块1"));
Assert.Contains(boundText, t => t.Contains("位置1"));
// 4. 排版计算(不触发渲染)| Layout calculation (no rendering)
var layoutEngine = new PageLayoutEngine(Logger().Object, new JsonTemplateEngine(Logger().Object));
var layoutPages = layoutEngine.CalculateLayout(bound, new ReportGenerationOptions
{
TemplatePath = CncTemplatePath,
Format = ReportOutputFormat.Pdf
});
// 至少包含首页 + 2 个节点详情页(排版可能因内容进一步分页,故用 >=)
Assert.True(layoutPages.Count >= 3, $"排版页数应 >= 3,实际 {layoutPages.Count}");
Assert.True(layoutPages.Count(p => p.PageType == "nodeDetail") >= 2);
}
[Fact]
public async System.Threading.Tasks.Task Pipeline_RendersActualPdf_ToFile()
{
Assert.True(File.Exists(CncTemplatePath), $"未找到 CNC 模板: {CncTemplatePath}");
var builderLogger = new Mock<ILoggerService>();
builderLogger.Setup(l => l.ForModule<CncReportContextBuilder>()).Returns(builderLogger.Object);
var builder = new CncReportContextBuilder(builderLogger.Object);
var request = builder.Build(CreateSampleDetail(), Path.GetTempPath());
var context = BuildContext(request);
var loc = new Mock<ILocalizationService>();
loc.SetupGet(l => l.CurrentLanguage).Returns(SupportedLanguage.ZhCN);
// 组装真实 PDF 生成管线(含 iText 渲染)| Assemble the real PDF pipeline (with iText rendering)
var generator = new PdfReportGenerator(
Logger().Object,
new JsonTemplateEngine(Logger().Object),
new ExpressionDataBinder(Logger().Object, loc.Object),
new PageLayoutEngine(Logger().Object, new JsonTemplateEngine(Logger().Object)),
new ITextPdfRenderer(Logger().Object, loc.Object, new XP.ReportEngine.Configs.ReportConfig()));
var outputPath = Path.Combine(Path.GetTempPath(), $"cnc_report_test_{Guid.NewGuid():N}.pdf");
try
{
var result = await generator.GenerateAsync(context, new ReportGenerationOptions
{
TemplatePath = CncTemplatePath,
OutputFilePath = outputPath,
Format = ReportOutputFormat.Pdf
});
Assert.NotNull(result);
Assert.True(result.IsSuccess, $"PDF 生成失败: {result?.ErrorMessage}");
Assert.True(File.Exists(outputPath), "PDF 文件应已写出");
var bytes = File.ReadAllBytes(outputPath);
Assert.True(bytes.Length > 0, "PDF 文件不应为空");
// 校验 PDF 魔数 %PDF | Validate the %PDF magic number
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
result.PdfStream?.Dispose();
}
finally
{
if (File.Exists(outputPath)) File.Delete(outputPath);
}
}
}
}