Files
XplorePlane/XplorePlane.Tests/Services/InspectionResultStoreTests.cs
T

341 lines
13 KiB
C#

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<ILoggerService> _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<ILoggerService>();
_mockLogger.Setup(l => l.ForModule(It.IsAny<string>())).Returns(_mockLogger.Object);
_mockLogger.Setup(l => l.ForModule<InspectionResultStore>()).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));
}
}
}