341 lines
13 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|