202 lines
10 KiB
C#
202 lines
10 KiB
C#
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>
|
||
/// 构造与示例 manifest(1c077c0f_)等价的运行明细: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);
|
||
}
|
||
}
|
||
}
|
||
}
|