using Moq; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using XP.Common.Configs; using XP.Common.Database.Implementations; using XP.Common.Database.Interfaces; using XP.Common.Logging.Interfaces; using XplorePlane.Models; using XplorePlane.Services.InspectionResults; using Xunit; namespace XplorePlane.Tests.Services { public class InspectionResultStoreTests : IDisposable { private readonly string _tempRoot; private readonly Mock _mockLogger; private readonly IDbContext _dbContext; private readonly InspectionResultStore _store; public InspectionResultStoreTests() { _tempRoot = Path.Combine(Path.GetTempPath(), "XplorePlane.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempRoot); _mockLogger = new Mock(); _mockLogger.Setup(l => l.ForModule(It.IsAny())).Returns(_mockLogger.Object); _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); var sqliteConfig = new SqliteConfig { DbFilePath = Path.Combine(_tempRoot, "inspection-results.db"), CreateIfNotExists = true, EnableWalMode = false, EnableSqlLogging = false }; _dbContext = new SqliteContext(sqliteConfig, _mockLogger.Object); _store = new InspectionResultStore(_dbContext, _mockLogger.Object, Path.Combine(_tempRoot, "assets")); } [Fact] public async Task FullRun_WithTwoNodes_CanRoundTripDetailAndQuery() { var startedAt = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); var run = new InspectionRunRecord { ProgramName = "NewCncProgram", WorkpieceId = "QFN_1", SerialNumber = "SN-001", StartedAt = startedAt }; var runSource = CreateTempFile("run-source.bmp", "run-source"); await _store.BeginRunAsync(run, new InspectionAssetWriteRequest { AssetType = InspectionAssetType.RunSourceImage, SourceFilePath = runSource, FileFormat = "bmp" }); var pipelineA = BuildPipeline("Recipe-A", ("GaussianBlur", 0), ("Threshold", 1)); var node1Id = Guid.NewGuid(); await _store.AppendNodeResultAsync( new InspectionNodeResult { RunId = run.RunId, NodeId = node1Id, NodeIndex = 1, NodeName = "检测节点1", PipelineId = pipelineA.Id, PipelineName = pipelineA.Name, NodePass = true, DurationMs = 135 }, new[] { new InspectionMetricResult { MetricKey = "bridge.rate", MetricName = "Bridge Rate", MetricValue = 0.12, Unit = "%", UpperLimit = 0.2, IsPass = true, DisplayOrder = 1 }, new InspectionMetricResult { MetricKey = "void.area", MetricName = "Void Area", MetricValue = 5.6, Unit = "px", UpperLimit = 8, IsPass = true, DisplayOrder = 2 } }, new PipelineExecutionSnapshot { PipelineName = pipelineA.Name, PipelineDefinitionJson = JsonSerializer.Serialize(pipelineA) }, new[] { new InspectionAssetWriteRequest { AssetType = InspectionAssetType.NodeInputImage, SourceFilePath = CreateTempFile("node1-input.bmp", "node1-input"), FileFormat = "bmp" }, new InspectionAssetWriteRequest { AssetType = InspectionAssetType.NodeResultImage, SourceFilePath = CreateTempFile("node1-result.bmp", "node1-result"), FileFormat = "bmp" } }); var pipelineB = BuildPipeline("Recipe-B", ("MeanFilter", 0), ("ContourDetection", 1)); var node2Id = Guid.NewGuid(); await _store.AppendNodeResultAsync( new InspectionNodeResult { RunId = run.RunId, NodeId = node2Id, NodeIndex = 2, NodeName = "检测节点2", PipelineId = pipelineB.Id, PipelineName = pipelineB.Name, NodePass = false, Status = InspectionNodeStatus.Failed, DurationMs = 240 }, new[] { new InspectionMetricResult { MetricKey = "solder.height", MetricName = "Solder Height", MetricValue = 1.7, Unit = "mm", LowerLimit = 1.8, IsPass = false, DisplayOrder = 1 } }, new PipelineExecutionSnapshot { PipelineName = pipelineB.Name, PipelineDefinitionJson = JsonSerializer.Serialize(pipelineB) }, new[] { new InspectionAssetWriteRequest { AssetType = InspectionAssetType.NodeResultImage, SourceFilePath = CreateTempFile("node2-result.bmp", "node2-result"), FileFormat = "bmp" } }); await _store.CompleteRunAsync(run.RunId); var queried = await _store.QueryRunsAsync(new InspectionRunQuery { ProgramName = "NewCncProgram", WorkpieceId = "QFN_1", PipelineName = "Recipe-A" }); var detail = await _store.GetRunDetailAsync(run.RunId); Assert.Single(queried); Assert.Equal(run.RunId, queried[0].RunId); Assert.False(detail.Run.OverallPass); Assert.Equal(2, detail.Run.NodeCount); Assert.Equal(2, detail.Nodes.Count); Assert.Equal(3, detail.Metrics.Count); Assert.Equal(4, detail.Assets.Count); Assert.Equal(2, detail.PipelineSnapshots.Count); Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass); Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass); Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash))); var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json"); Assert.True(File.Exists(manifestPath)); } [Fact] public async Task AppendNodeResult_MissingAsset_DoesNotCrashAndMarksAssetMissing() { var run = new InspectionRunRecord { ProgramName = "Program-A", WorkpieceId = "Part-01", SerialNumber = "SN-404" }; await _store.BeginRunAsync(run); var nodeId = Guid.NewGuid(); await _store.AppendNodeResultAsync( new InspectionNodeResult { RunId = run.RunId, NodeId = nodeId, NodeIndex = 1, NodeName = "缺图节点", PipelineId = Guid.NewGuid(), PipelineName = "Recipe-Missing", NodePass = true }, new[] { new InspectionMetricResult { MetricKey = "metric.only", MetricName = "Metric Only", MetricValue = 1, Unit = "pcs", IsPass = true, DisplayOrder = 1 } }, new PipelineExecutionSnapshot { PipelineName = "Recipe-Missing", PipelineDefinitionJson = "{\"nodes\":[\"gaussian\"]}" }, new[] { new InspectionAssetWriteRequest { AssetType = InspectionAssetType.NodeResultImage, SourceFilePath = Path.Combine(_tempRoot, "missing-file.bmp"), FileFormat = "bmp" } }); var detail = await _store.GetRunDetailAsync(run.RunId); var node = Assert.Single(detail.Nodes); Assert.Equal(InspectionNodeStatus.AssetMissing, node.Status); Assert.Single(detail.Metrics); Assert.Empty(detail.Assets); } [Fact] public async Task PipelineSnapshot_IsStoredAsExecutionSnapshot_NotDependentOnLaterChanges() { var run = new InspectionRunRecord { ProgramName = "Program-Snapshot", WorkpieceId = "Part-02", SerialNumber = "SN-SNAP" }; await _store.BeginRunAsync(run); var pipeline = BuildPipeline("Recipe-Snapshot", ("GaussianBlur", 0), ("ContourDetection", 1)); var snapshotJson = JsonSerializer.Serialize(pipeline); var originalHash = ComputeExpectedHash(snapshotJson); await _store.AppendNodeResultAsync( new InspectionNodeResult { RunId = run.RunId, NodeId = Guid.NewGuid(), NodeIndex = 1, NodeName = "快照节点", PipelineId = pipeline.Id, PipelineName = pipeline.Name, NodePass = true }, pipelineSnapshot: new PipelineExecutionSnapshot { PipelineName = pipeline.Name, PipelineDefinitionJson = snapshotJson }); pipeline.Name = "Recipe-Snapshot-Changed"; pipeline.Nodes[0].OperatorKey = "MeanFilter"; var detail = await _store.GetRunDetailAsync(run.RunId); var snapshot = Assert.Single(detail.PipelineSnapshots); Assert.Equal("Recipe-Snapshot", snapshot.PipelineName); Assert.Equal(snapshotJson, snapshot.PipelineDefinitionJson); Assert.Equal(originalHash, snapshot.PipelineHash); } public void Dispose() { _dbContext.Dispose(); if (Directory.Exists(_tempRoot)) { try { Directory.Delete(_tempRoot, true); } catch (IOException) { // SQLite file handles may release slightly after test teardown. } } } private string CreateTempFile(string fileName, string content) { var path = Path.Combine(_tempRoot, fileName); File.WriteAllText(path, content); return path; } private static PipelineModel BuildPipeline(string name, params (string OperatorKey, int Order)[] nodes) { return new PipelineModel { Name = name, Nodes = nodes.Select(node => new PipelineNodeModel { OperatorKey = node.OperatorKey, Order = node.Order }).ToList() }; } private static string ComputeExpectedHash(string value) { using var sha = System.Security.Cryptography.SHA256.Create(); var bytes = System.Text.Encoding.UTF8.GetBytes(value); return Convert.ToHexString(sha.ComputeHash(bytes)); } } }