位置节点增加保存图像到本地的功能;支持输入图像
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using System.Linq;
|
||||||
|
using XplorePlane.Helpers;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FsCheck property-based tests for FileNameSanitizer.
|
||||||
|
/// Property 1: 文件名非法字符清理
|
||||||
|
/// Validates: Requirements 1.2
|
||||||
|
/// </summary>
|
||||||
|
public class FileNameSanitizerPropertyTests
|
||||||
|
{
|
||||||
|
private static readonly char[] InvalidChars =
|
||||||
|
new[] { '\\', '/', ':', '*', '?', '"', '<', '>', '|' };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成包含各种字符(含非法字符)的文件名字符串
|
||||||
|
/// </summary>
|
||||||
|
private static Arbitrary<string> FileNameArb()
|
||||||
|
{
|
||||||
|
// Mix of normal chars, unicode chars, and illegal file system chars
|
||||||
|
var charGen = Gen.Frequency(
|
||||||
|
(5, Gen.Choose(0x20, 0x7E).Select(i => (char)i)), // printable ASCII
|
||||||
|
(2, Gen.Elements(InvalidChars)), // illegal chars
|
||||||
|
(2, Gen.Choose(0x4E00, 0x9FFF).Select(i => (char)i)), // CJK characters
|
||||||
|
(1, Gen.Elements('_', '-', '.', ' ')) // common filename chars
|
||||||
|
);
|
||||||
|
|
||||||
|
var gen = from len in Gen.Choose(1, 50)
|
||||||
|
from chars in Gen.ListOf<char>(charGen, len)
|
||||||
|
select new string(chars.ToArray());
|
||||||
|
|
||||||
|
return gen.ToArbitrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 1: Output never contains any illegal characters ─────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 1.2**
|
||||||
|
/// For any input string, the sanitized output must not contain any of the 9 illegal characters.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SanitizedOutput_NeverContainsIllegalCharacters()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
FileNameArb(),
|
||||||
|
input =>
|
||||||
|
{
|
||||||
|
var result = FileNameSanitizer.Sanitize(input);
|
||||||
|
return !result.Any(c => InvalidChars.Contains(c));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 1 (cont): Legal characters are preserved unchanged ──────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 1.2**
|
||||||
|
/// For any input string, characters that are NOT illegal must appear unchanged
|
||||||
|
/// at the same position in the output.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SanitizedOutput_PreservesLegalCharacters()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
FileNameArb(),
|
||||||
|
input =>
|
||||||
|
{
|
||||||
|
var result = FileNameSanitizer.Sanitize(input);
|
||||||
|
|
||||||
|
for (int i = 0; i < input.Length; i++)
|
||||||
|
{
|
||||||
|
if (!InvalidChars.Contains(input[i]))
|
||||||
|
{
|
||||||
|
if (result[i] != input[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 1 (cont): Output length equals input length ─────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 1.2**
|
||||||
|
/// For any non-null, non-empty input, the output length equals the input length.
|
||||||
|
/// For null or empty input, the output is "_".
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SanitizedOutput_LengthEqualsInputLength()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
FileNameArb(),
|
||||||
|
input =>
|
||||||
|
{
|
||||||
|
var result = FileNameSanitizer.Sanitize(input);
|
||||||
|
return result.Length == input.Length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 1 (cont): Sanitize is idempotent ────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 1.2**
|
||||||
|
/// Sanitizing the output a second time produces the same result (idempotent).
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SanitizedOutput_IsIdempotent()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
FileNameArb(),
|
||||||
|
input =>
|
||||||
|
{
|
||||||
|
var once = FileNameSanitizer.Sanitize(input);
|
||||||
|
var twice = FileNameSanitizer.Sanitize(once);
|
||||||
|
return once == twice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge case: null and empty input ──────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 1.2**
|
||||||
|
/// Null or empty input always returns "_".
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property NullOrEmptyInput_ReturnsUnderscore()
|
||||||
|
{
|
||||||
|
var gen = Gen.Elements<string>(null, "", null, "");
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
gen.ToArbitrary(),
|
||||||
|
input =>
|
||||||
|
{
|
||||||
|
var result = FileNameSanitizer.Sanitize(input);
|
||||||
|
return result == "_";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using XplorePlane.Helpers;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FsCheck property-based tests for ManualImageValidator.
|
||||||
|
/// Property 4: ManualImagePath 验证正确性
|
||||||
|
/// **Validates: Requirements 2.1, 2.3**
|
||||||
|
/// </summary>
|
||||||
|
public class ManualImageValidatorPropertyTests
|
||||||
|
{
|
||||||
|
// ── Generators ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates null, empty, or whitespace-only strings.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<string> NullOrWhitespaceGen()
|
||||||
|
{
|
||||||
|
return Gen.OneOf(
|
||||||
|
Gen.Constant((string)null),
|
||||||
|
Gen.Constant(string.Empty),
|
||||||
|
Gen.Constant(" "),
|
||||||
|
Gen.Constant(" "),
|
||||||
|
Gen.Constant("\t"),
|
||||||
|
Gen.Constant("\t \n"),
|
||||||
|
Gen.Constant(" \r\n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates paths longer than 260 characters (non-whitespace).
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<string> PathTooLongGen()
|
||||||
|
{
|
||||||
|
// Generate lengths from 261 to 500
|
||||||
|
return Gen.Choose(261, 500).Select(len =>
|
||||||
|
{
|
||||||
|
// Use a valid-looking path prefix to ensure it's not whitespace
|
||||||
|
var prefix = @"C:\Images\";
|
||||||
|
var remaining = len - prefix.Length;
|
||||||
|
if (remaining <= 0) remaining = 1;
|
||||||
|
return prefix + new string('a', remaining);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates non-empty paths with length ≤ 260 that point to non-existent files.
|
||||||
|
/// Uses random alphanumeric characters to ensure the file won't exist.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<string> NonExistentPathGen()
|
||||||
|
{
|
||||||
|
// Generate a path that is very unlikely to exist on disk
|
||||||
|
var charGen = Gen.Elements(
|
||||||
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
|
||||||
|
'A', 'B', 'C', 'D', 'E', 'F', '0', '1', '2', '3');
|
||||||
|
|
||||||
|
return from len in Gen.Choose(5, 50)
|
||||||
|
from chars in Gen.ArrayOf(charGen).Resize(len)
|
||||||
|
select @"C:\NonExistent_" + new string(chars) + ".bmp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property Tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property 4.1: Null/whitespace/empty paths always return Empty.
|
||||||
|
/// For any null, empty, or whitespace-only string, Validate SHALL return Empty.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property NullOrWhitespacePaths_ReturnEmpty()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
NullOrWhitespaceGen().ToArbitrary(),
|
||||||
|
path =>
|
||||||
|
{
|
||||||
|
var result = ManualImageValidator.Validate(path);
|
||||||
|
return result == ManualImageValidationResult.Empty;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property 4.2: Paths longer than 260 characters always return PathTooLong.
|
||||||
|
/// For any non-whitespace path with length > 260, Validate SHALL return PathTooLong.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property PathsLongerThan260_ReturnPathTooLong()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
PathTooLongGen().ToArbitrary(),
|
||||||
|
path =>
|
||||||
|
{
|
||||||
|
var result = ManualImageValidator.Validate(path);
|
||||||
|
return result == ManualImageValidationResult.PathTooLong;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property 4.3: Non-existent file paths (non-empty, ≤260) return FileNotFound.
|
||||||
|
/// For any non-whitespace path with length ≤ 260 that does not exist on disk,
|
||||||
|
/// Validate SHALL return FileNotFound.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property NonExistentPaths_ReturnFileNotFound()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
NonExistentPathGen().ToArbitrary(),
|
||||||
|
path =>
|
||||||
|
{
|
||||||
|
var result = ManualImageValidator.Validate(path);
|
||||||
|
return result == ManualImageValidationResult.FileNotFound;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property 4.4: The result is always one of the defined enum values (exhaustive classification).
|
||||||
|
/// For any arbitrary string, Validate SHALL return a value within the defined enum range.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property Result_IsAlwaysDefinedEnumValue()
|
||||||
|
{
|
||||||
|
// Generate arbitrary strings including nulls, empty, whitespace, and random paths
|
||||||
|
var charGen = Gen.Frequency(
|
||||||
|
(5, Gen.Choose(0x20, 0x7E).Select(i => (char)i)), // printable ASCII
|
||||||
|
(2, Gen.Choose(0x4E00, 0x9FFF).Select(i => (char)i)), // CJK characters
|
||||||
|
(1, Gen.Elements('\\', '/', ':', '*', '?', '"', '<', '>', '|')),
|
||||||
|
(1, Gen.Elements(' ', '\t', '\n', '\r'))
|
||||||
|
);
|
||||||
|
|
||||||
|
var randomStringGen = from len in Gen.Choose(0, 300)
|
||||||
|
from chars in Gen.ArrayOf(charGen).Resize(len)
|
||||||
|
select new string(chars);
|
||||||
|
|
||||||
|
var anyStringGen = Gen.OneOf(
|
||||||
|
Gen.Constant((string)null),
|
||||||
|
Gen.Constant(string.Empty),
|
||||||
|
Gen.Constant(" "),
|
||||||
|
randomStringGen);
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
anyStringGen.ToArbitrary(),
|
||||||
|
path =>
|
||||||
|
{
|
||||||
|
var result = ManualImageValidator.Validate(path);
|
||||||
|
return Enum.IsDefined(typeof(ManualImageValidationResult), result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property 4.5: Existing files with supported extensions return Valid.
|
||||||
|
/// Creates temporary files with supported extensions and verifies Validate returns Valid.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 20)]
|
||||||
|
public Property ExistingFilesWithSupportedExtension_ReturnValid()
|
||||||
|
{
|
||||||
|
var supportedExtGen = Gen.Elements(".bmp", ".png", ".tiff", ".tif");
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
supportedExtGen.ToArbitrary(),
|
||||||
|
ext =>
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "ManualImageValidatorTests");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var tempFile = Path.Combine(tempDir, $"test_{Guid.NewGuid():N}{ext}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(tempFile, new byte[] { 0x00 });
|
||||||
|
var result = ManualImageValidator.Validate(tempFile);
|
||||||
|
return result == ManualImageValidationResult.Valid;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tempFile))
|
||||||
|
File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property 4.6: Existing files with unsupported extensions return UnsupportedFormat.
|
||||||
|
/// Creates temporary files with unsupported extensions and verifies Validate returns UnsupportedFormat.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 20)]
|
||||||
|
public Property ExistingFilesWithUnsupportedExtension_ReturnUnsupportedFormat()
|
||||||
|
{
|
||||||
|
var unsupportedExtGen = Gen.Elements(".jpg", ".jpeg", ".gif", ".svg", ".webp", ".txt", ".dat");
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
unsupportedExtGen.ToArbitrary(),
|
||||||
|
ext =>
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "ManualImageValidatorTests");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var tempFile = Path.Combine(tempDir, $"test_{Guid.NewGuid():N}{ext}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(tempFile, new byte[] { 0x00 });
|
||||||
|
var result = ManualImageValidator.Validate(tempFile);
|
||||||
|
return result == ManualImageValidationResult.UnsupportedFormat;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tempFile))
|
||||||
|
File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FsCheck property-based tests for BatchCaptureResult JSON serialization round-trip.
|
||||||
|
/// Property 10: 摘要 JSON 序列化往返
|
||||||
|
/// </summary>
|
||||||
|
public class SummarySerializationPropertyTests
|
||||||
|
{
|
||||||
|
// ── Generators ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a non-null string (FsCheck may generate nulls by default).
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<string> NonNullStringGen =>
|
||||||
|
ArbMap.Default.GeneratorFor<NonEmptyString>()
|
||||||
|
.Select(s => s.Get)
|
||||||
|
.Or(Gen.Constant(string.Empty));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a nullable string (either null or a non-empty string).
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<string> NullableStringGen =>
|
||||||
|
Gen.OneOf(
|
||||||
|
Gen.Constant<string>(null),
|
||||||
|
ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a random PositionResult with valid field values.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<PositionResult> PositionResultGen =>
|
||||||
|
from nodeName in NonNullStringGen
|
||||||
|
from nodeIndex in Gen.Choose(0, 1000)
|
||||||
|
from status in Gen.Elements("Success", "Failed")
|
||||||
|
from errorMessage in NullableStringGen
|
||||||
|
from imagePath in NullableStringGen
|
||||||
|
select new PositionResult
|
||||||
|
{
|
||||||
|
NodeName = nodeName,
|
||||||
|
NodeIndex = nodeIndex,
|
||||||
|
Status = status,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
ImagePath = imagePath
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a random BatchCaptureResult with valid field values.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<BatchCaptureResult> BatchCaptureResultGen =>
|
||||||
|
from programName in NonNullStringGen
|
||||||
|
from startTime in NonNullStringGen
|
||||||
|
from durationSeconds in Gen.Choose(0, 100000).Select(i => i / 100.0)
|
||||||
|
from totalPositions in Gen.Choose(0, 50)
|
||||||
|
from succeededPositions in Gen.Choose(0, 50)
|
||||||
|
from failedPositions in Gen.Choose(0, 50)
|
||||||
|
from savedImageCount in Gen.Choose(0, 100)
|
||||||
|
from status in Gen.Elements("Completed", "Cancelled")
|
||||||
|
from completedBeforeCancel in Gen.OneOf(
|
||||||
|
Gen.Constant<int?>(null),
|
||||||
|
Gen.Choose(0, 50).Select(i => (int?)i))
|
||||||
|
from notExecutedAfterCancel in Gen.OneOf(
|
||||||
|
Gen.Constant<int?>(null),
|
||||||
|
Gen.Choose(0, 50).Select(i => (int?)i))
|
||||||
|
from positionCount in Gen.Choose(0, 5)
|
||||||
|
from positions in Gen.ListOf<PositionResult>(PositionResultGen, positionCount)
|
||||||
|
select new BatchCaptureResult
|
||||||
|
{
|
||||||
|
ProgramName = programName,
|
||||||
|
StartTime = startTime,
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
TotalPositions = totalPositions,
|
||||||
|
SucceededPositions = succeededPositions,
|
||||||
|
FailedPositions = failedPositions,
|
||||||
|
SavedImageCount = savedImageCount,
|
||||||
|
Status = status,
|
||||||
|
CompletedBeforeCancel = completedBeforeCancel,
|
||||||
|
NotExecutedAfterCancel = notExecutedAfterCancel,
|
||||||
|
Positions = positions.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Property 10 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 4.3**
|
||||||
|
///
|
||||||
|
/// For any valid BatchCaptureResult object, serializing to JSON and
|
||||||
|
/// deserializing back produces an object with identical field values.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property BatchCaptureResult_JsonRoundTrip_PreservesAllFields()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
BatchCaptureResultGen.ToArbitrary(),
|
||||||
|
original =>
|
||||||
|
{
|
||||||
|
// Serialize to JSON
|
||||||
|
var json = JsonSerializer.Serialize(original);
|
||||||
|
|
||||||
|
// Deserialize back
|
||||||
|
var deserialized = JsonSerializer.Deserialize<BatchCaptureResult>(json);
|
||||||
|
|
||||||
|
// Verify all scalar properties
|
||||||
|
bool scalarFieldsMatch =
|
||||||
|
deserialized.ProgramName == original.ProgramName &&
|
||||||
|
deserialized.StartTime == original.StartTime &&
|
||||||
|
deserialized.DurationSeconds == original.DurationSeconds &&
|
||||||
|
deserialized.TotalPositions == original.TotalPositions &&
|
||||||
|
deserialized.SucceededPositions == original.SucceededPositions &&
|
||||||
|
deserialized.FailedPositions == original.FailedPositions &&
|
||||||
|
deserialized.SavedImageCount == original.SavedImageCount &&
|
||||||
|
deserialized.Status == original.Status;
|
||||||
|
|
||||||
|
// Verify nullable int properties
|
||||||
|
bool nullableFieldsMatch =
|
||||||
|
deserialized.CompletedBeforeCancel == original.CompletedBeforeCancel &&
|
||||||
|
deserialized.NotExecutedAfterCancel == original.NotExecutedAfterCancel;
|
||||||
|
|
||||||
|
// Verify list property count
|
||||||
|
bool listCountMatch =
|
||||||
|
deserialized.Positions.Count == original.Positions.Count;
|
||||||
|
|
||||||
|
// Verify each position in the list
|
||||||
|
bool positionsMatch = true;
|
||||||
|
for (int i = 0; i < original.Positions.Count; i++)
|
||||||
|
{
|
||||||
|
var orig = original.Positions[i];
|
||||||
|
var deser = deserialized.Positions[i];
|
||||||
|
if (deser.NodeName != orig.NodeName ||
|
||||||
|
deser.NodeIndex != orig.NodeIndex ||
|
||||||
|
deser.Status != orig.Status ||
|
||||||
|
deser.ErrorMessage != orig.ErrorMessage ||
|
||||||
|
deser.ImagePath != orig.ImagePath)
|
||||||
|
{
|
||||||
|
positionsMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scalarFieldsMatch && nullableFieldsMatch &&
|
||||||
|
listCountMatch && positionsMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Property 8: 取消令牌中止后续位置执行
|
||||||
|
/// **Validates: Requirements 3.5**
|
||||||
|
///
|
||||||
|
/// For any CNC program being executed, when a CancellationToken is triggered,
|
||||||
|
/// no SavePositionNode with Index greater than the currently executing node SHALL be processed.
|
||||||
|
/// </summary>
|
||||||
|
public class CancellationPropertyTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a CncExecutionService with all dependencies mocked.
|
||||||
|
/// The SaveImageAsync mock triggers cancellation after K positions have been processed.
|
||||||
|
/// </summary>
|
||||||
|
private static (CncExecutionService Service, Mock<IImagePersistenceService> ImagePersistence, List<string> ExecutedNodeNames)
|
||||||
|
CreateServiceWithCancellation(CancellationTokenSource cts, int cancelAfterK)
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
// Logger setup
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
// EventAggregator setup - prevent NRE on constructor subscription
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
// InspectionResultStore setup
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Provide a valid BitmapSource from the viewport so image acquisition succeeds
|
||||||
|
var dummyBitmap = BitmapSource.Create(
|
||||||
|
1, 1, 96, 96, PixelFormats.Gray8, null, new byte[] { 128 }, 1);
|
||||||
|
dummyBitmap.Freeze();
|
||||||
|
mockMainViewportService
|
||||||
|
.Setup(m => m.LatestManualImage)
|
||||||
|
.Returns(dummyBitmap);
|
||||||
|
|
||||||
|
// Track which nodes have their images saved (i.e., which positions were executed)
|
||||||
|
var executedNodeNames = new List<string>();
|
||||||
|
int saveCallCount = 0;
|
||||||
|
|
||||||
|
// ImagePersistenceService - cancel after K positions have been saved
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<byte[], string, string, CancellationToken>((_, nodeName, __, ___) =>
|
||||||
|
{
|
||||||
|
executedNodeNames.Add(nodeName);
|
||||||
|
int currentCount = Interlocked.Increment(ref saveCallCount);
|
||||||
|
// Cancel after K positions have been processed
|
||||||
|
if (currentCount >= cancelAfterK)
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
return Task.FromResult(new ImageSaveResult(true, $"C:\\test\\{nodeName}.bmp", 1024));
|
||||||
|
});
|
||||||
|
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockImagePersistenceService, executedNodeNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FsCheck generator for a cancellation test scenario:
|
||||||
|
/// - N SavePositionNodes (N between 2 and 10)
|
||||||
|
/// - A cancellation point K (1 <= K < N), meaning cancel after K positions are processed
|
||||||
|
/// </summary>
|
||||||
|
private static Arbitrary<(List<SavePositionNode> Nodes, int CancelAfterK)> CancellationScenarioArb()
|
||||||
|
{
|
||||||
|
var gen =
|
||||||
|
from count in Gen.Choose(2, 10)
|
||||||
|
from cancelAfterK in Gen.Choose(1, count - 1)
|
||||||
|
from indices in Gen.Shuffle(Enumerable.Range(0, count).ToArray())
|
||||||
|
.Select(arr => arr.ToList())
|
||||||
|
select (
|
||||||
|
Nodes: indices.Select(idx =>
|
||||||
|
new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
idx,
|
||||||
|
$"Position_{idx}",
|
||||||
|
MotionState.Default,
|
||||||
|
SaveImage: true,
|
||||||
|
ManualImagePath: ""))
|
||||||
|
.ToList(),
|
||||||
|
CancelAfterK: cancelAfterK
|
||||||
|
);
|
||||||
|
|
||||||
|
return gen.ToArbitrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature: cnc-multi-position-image-capture, Property 8: 取消令牌中止后续位置执行
|
||||||
|
// **Validates: Requirements 3.5**
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property Cancellation_StopsSubsequentPositionExecution()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
CancellationScenarioArb(),
|
||||||
|
scenario =>
|
||||||
|
{
|
||||||
|
var (nodes, cancelAfterK) = scenario;
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var (service, _, executedNodeNames) = CreateServiceWithCancellation(cts, cancelAfterK);
|
||||||
|
|
||||||
|
// Build a CncProgram containing the SavePositionNodes
|
||||||
|
// (in their original random order - the service sorts by Index)
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
// Track progress reports to verify which positions were executed
|
||||||
|
var executedPositionIndices = new List<int>();
|
||||||
|
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
|
||||||
|
{
|
||||||
|
if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
|
||||||
|
{
|
||||||
|
executedPositionIndices.Add(p.PositionIndex.Value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, progress, cts.Token)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// The expected sorted order of nodes by Index
|
||||||
|
var sortedNodes = nodes.OrderBy(n => n.Index).ToList();
|
||||||
|
|
||||||
|
// Verify: No more than cancelAfterK positions should have been executed.
|
||||||
|
// The cancellation is triggered after K SaveImageAsync calls complete,
|
||||||
|
// so at most K positions should have their images saved.
|
||||||
|
// The (K+1)th position might start (Running reported) but its SaveImageAsync
|
||||||
|
// should not be called because cancellation is checked at loop start.
|
||||||
|
bool noExcessiveSaves = executedNodeNames.Count <= cancelAfterK;
|
||||||
|
|
||||||
|
// Verify: Positions after the cancellation point should NOT be executed.
|
||||||
|
// The service checks cancellation at the start of each loop iteration,
|
||||||
|
// so after K positions complete and cancellation is triggered,
|
||||||
|
// subsequent positions should not receive Running state.
|
||||||
|
bool noSubsequentExecution = executedPositionIndices.Count <= cancelAfterK;
|
||||||
|
|
||||||
|
// Verify: The executed positions should be the first K positions in sorted order
|
||||||
|
bool correctPositionsExecuted = true;
|
||||||
|
for (int i = 0; i < executedNodeNames.Count && i < cancelAfterK; i++)
|
||||||
|
{
|
||||||
|
if (executedNodeNames[i] != sortedNodes[i].Name)
|
||||||
|
{
|
||||||
|
correctPositionsExecuted = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return noExcessiveSaves && noSubsequentExecution && correctPositionsExecuted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FsCheck property-based tests for cancellation summary correctness.
|
||||||
|
/// Property 11: 取消时摘要包含正确的完成/未执行计数
|
||||||
|
/// **Validates: Requirements 4.5**
|
||||||
|
/// </summary>
|
||||||
|
public class CancellationSummaryPropertyTests
|
||||||
|
{
|
||||||
|
// ── Helper: Create a frozen BitmapSource for mocking ──────────────
|
||||||
|
|
||||||
|
private static BitmapSource CreateFrozenBitmap()
|
||||||
|
{
|
||||||
|
var bitmap = BitmapSource.Create(1, 1, 96, 96, PixelFormats.Bgra32, null, new byte[4], 4);
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: Create CncExecutionService with mocks ────────────────
|
||||||
|
|
||||||
|
private static (CncExecutionService Service, Mock<IImagePersistenceService> ImagePersistence)
|
||||||
|
CreateServiceWithImagePersistence(BitmapSource sourceImage)
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Provide a source image so positions can succeed
|
||||||
|
mockMainViewportService.SetupGet(m => m.LatestManualImage).Returns(sourceImage);
|
||||||
|
mockMainViewportService.SetupGet(m => m.CurrentDisplayImage).Returns(sourceImage);
|
||||||
|
|
||||||
|
// SaveImageAsync returns success
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.bmp", 1024));
|
||||||
|
|
||||||
|
// WriteSummaryAsync returns success
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockImagePersistenceService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generator: N SavePositionNodes with cancellation point K ─────
|
||||||
|
|
||||||
|
private static Gen<(List<SavePositionNode> Nodes, int CancelAfterK)> SavePositionNodesWithCancelGen =>
|
||||||
|
from n in Gen.Choose(2, 10)
|
||||||
|
from k in Gen.Choose(0, n - 1)
|
||||||
|
from nodes in GenSavePositionNodes(n)
|
||||||
|
select (nodes, k);
|
||||||
|
|
||||||
|
private static Gen<List<SavePositionNode>> GenSavePositionNodes(int count)
|
||||||
|
{
|
||||||
|
Gen<List<SavePositionNode>> acc = Gen.Constant(new List<SavePositionNode>());
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var idx = i;
|
||||||
|
acc = from list in acc
|
||||||
|
let node = new SavePositionNode(
|
||||||
|
Guid.NewGuid(), idx, $"Position_{idx}",
|
||||||
|
MotionState.Default, SaveImage: true)
|
||||||
|
select new List<SavePositionNode>(list) { node };
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 11: 取消时摘要包含正确的完成/未执行计数 ─────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 4.5**
|
||||||
|
///
|
||||||
|
/// For any CNC program execution that is cancelled at position K (0-based)
|
||||||
|
/// out of N total positions, the summary SHALL have Status="Cancelled",
|
||||||
|
/// CompletedBeforeCancel=K, and NotExecutedAfterCancel=N-K,
|
||||||
|
/// where CompletedBeforeCancel + NotExecutedAfterCancel == TotalPositions.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property CancellationSummary_HasCorrectCompletedAndNotExecutedCounts()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
SavePositionNodesWithCancelGen.ToArbitrary(),
|
||||||
|
tuple =>
|
||||||
|
{
|
||||||
|
var (nodes, cancelAfterK) = tuple;
|
||||||
|
int totalPositions = nodes.Count;
|
||||||
|
|
||||||
|
var sourceImage = CreateFrozenBitmap();
|
||||||
|
var (service, mockImagePersistence) = CreateServiceWithImagePersistence(sourceImage);
|
||||||
|
|
||||||
|
// Create a CTS that we will cancel after K positions are processed
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
int processedCount = 0;
|
||||||
|
|
||||||
|
// Use progress callback to trigger cancellation after K positions complete
|
||||||
|
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
|
||||||
|
{
|
||||||
|
// Count positions that have completed (Succeeded or Failed state)
|
||||||
|
if (p.State == NodeExecutionState.Succeeded || p.State == NodeExecutionState.Failed)
|
||||||
|
{
|
||||||
|
if (p.PositionIndex.HasValue)
|
||||||
|
{
|
||||||
|
processedCount++;
|
||||||
|
if (processedCount >= cancelAfterK)
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(), "TestProgram",
|
||||||
|
DateTime.UtcNow, DateTime.UtcNow,
|
||||||
|
nodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, progress, cts.Token)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Capture the BatchCaptureResult passed to WriteSummaryAsync
|
||||||
|
BatchCaptureResult capturedResult = null;
|
||||||
|
mockImagePersistence.Verify(
|
||||||
|
s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
|
||||||
|
// Extract the captured argument
|
||||||
|
var invocation = mockImagePersistence.Invocations
|
||||||
|
.First(i => i.Method.Name == nameof(IImagePersistenceService.WriteSummaryAsync));
|
||||||
|
capturedResult = (BatchCaptureResult)invocation.Arguments[0];
|
||||||
|
|
||||||
|
// Verify the cancellation summary properties
|
||||||
|
bool statusIsCancelled = capturedResult.Status == "Cancelled";
|
||||||
|
bool completedBeforeCancelIsK = capturedResult.CompletedBeforeCancel == cancelAfterK;
|
||||||
|
bool notExecutedIsCorrect = capturedResult.NotExecutedAfterCancel == totalPositions - cancelAfterK;
|
||||||
|
bool sumEqualsTotal =
|
||||||
|
capturedResult.CompletedBeforeCancel + capturedResult.NotExecutedAfterCancel == totalPositions;
|
||||||
|
bool totalPositionsCorrect = capturedResult.TotalPositions == totalPositions;
|
||||||
|
|
||||||
|
return statusIsCancelled
|
||||||
|
&& completedBeforeCancelIsK
|
||||||
|
&& notExecutedIsCorrect
|
||||||
|
&& sumEqualsTotal
|
||||||
|
&& totalPositionsCorrect;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,6 +190,7 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
|
|||||||
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
var mockEventAggregator = new Mock<IEventAggregator>();
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
// Set up GetEvent<DetectorDisconnectedEvent>() so the constructor subscription doesn't throw
|
// Set up GetEvent<DetectorDisconnectedEvent>() so the constructor subscription doesn't throw
|
||||||
@@ -222,7 +223,8 @@ internal sealed class SynchronousProgress<T> : IProgress<T>
|
|||||||
mockAppStateService.Object,
|
mockAppStateService.Object,
|
||||||
mockPipelineExecutionService.Object,
|
mockPipelineExecutionService.Object,
|
||||||
mockImageProcessingService.Object,
|
mockImageProcessingService.Object,
|
||||||
mockEventAggregator.Object);
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService);
|
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Property 5: 多位置按 Index 升序执行
|
||||||
|
/// **Validates: Requirements 3.1**
|
||||||
|
///
|
||||||
|
/// For any CNC program containing multiple SavePositionNodes with arbitrary Index values,
|
||||||
|
/// the CNC_Execution_Service SHALL process them in strictly ascending Index order,
|
||||||
|
/// and progress reports SHALL reflect this ordering.
|
||||||
|
/// </summary>
|
||||||
|
public class ExecutionOrderPropertyTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a CncExecutionService with all dependencies mocked.
|
||||||
|
/// The MainViewportService returns a valid BitmapSource so execution proceeds.
|
||||||
|
/// </summary>
|
||||||
|
private static (CncExecutionService Service, Mock<IMainViewportService> MainViewport) CreateServiceWithImage()
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
// Logger setup
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
// EventAggregator setup - prevent NRE on constructor subscription
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
// InspectionResultStore setup
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Provide a valid BitmapSource from the viewport so image acquisition succeeds
|
||||||
|
var dummyBitmap = BitmapSource.Create(
|
||||||
|
1, 1, 96, 96, PixelFormats.Gray8, null, new byte[] { 128 }, 1);
|
||||||
|
dummyBitmap.Freeze();
|
||||||
|
mockMainViewportService
|
||||||
|
.Setup(m => m.LatestManualImage)
|
||||||
|
.Returns(dummyBitmap);
|
||||||
|
|
||||||
|
// ImagePersistenceService - return success for SaveImageAsync
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.bmp", 1024));
|
||||||
|
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockMainViewportService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FsCheck generator for a list of SavePositionNodes with random, unique Index values.
|
||||||
|
/// Generates 2-10 nodes with shuffled indices to ensure non-sequential ordering in the input.
|
||||||
|
/// </summary>
|
||||||
|
private static Arbitrary<List<SavePositionNode>> SavePositionNodesArb()
|
||||||
|
{
|
||||||
|
var gen =
|
||||||
|
from count in Gen.Choose(2, 10)
|
||||||
|
from indices in Gen.Shuffle(Enumerable.Range(0, 100).ToArray())
|
||||||
|
.Select(arr => arr.Take(count).ToList())
|
||||||
|
select indices.Select(idx =>
|
||||||
|
new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
idx,
|
||||||
|
$"Position_{idx}",
|
||||||
|
MotionState.Default,
|
||||||
|
SaveImage: false,
|
||||||
|
ManualImagePath: ""))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return gen.ToArbitrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature: cnc-multi-position-image-capture, Property 5: 多位置按 Index 升序执行
|
||||||
|
// **Validates: Requirements 3.1**
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property MultiPosition_ExecutedInStrictlyAscendingIndexOrder()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
SavePositionNodesArb(),
|
||||||
|
savePositionNodes =>
|
||||||
|
{
|
||||||
|
var (service, _) = CreateServiceWithImage();
|
||||||
|
|
||||||
|
// Build a CncProgram containing only the generated SavePositionNodes
|
||||||
|
// (in their original random order - the service should sort by Index)
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
savePositionNodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
// Capture progress reports to verify execution order
|
||||||
|
var reportedPositionIndices = new List<int>();
|
||||||
|
var reportedNodeIds = new List<Guid>();
|
||||||
|
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
|
||||||
|
{
|
||||||
|
// Capture the first Running report for each node (the initial progress report)
|
||||||
|
if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
|
||||||
|
{
|
||||||
|
reportedNodeIds.Add(p.NodeId);
|
||||||
|
reportedPositionIndices.Add(p.PositionIndex.Value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, progress, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Build a map from NodeId to Index
|
||||||
|
var nodeIndexMap = savePositionNodes.ToDictionary(n => n.Id, n => n.Index);
|
||||||
|
|
||||||
|
// Verify 1: All nodes were executed
|
||||||
|
if (reportedNodeIds.Count != savePositionNodes.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Verify 2: Nodes were processed in strictly ascending Index order
|
||||||
|
var executedIndices = reportedNodeIds
|
||||||
|
.Select(id => nodeIndexMap[id])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (int i = 1; i < executedIndices.Count; i++)
|
||||||
|
{
|
||||||
|
if (executedIndices[i] <= executedIndices[i - 1])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 3: Progress report PositionIndex values are strictly ascending from 0
|
||||||
|
for (int i = 0; i < reportedPositionIndices.Count; i++)
|
||||||
|
{
|
||||||
|
if (reportedPositionIndices[i] != i)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Property 3: 采集或保存失败不中断后续执行
|
||||||
|
/// **Validates: Requirements 1.5, 1.6, 3.3, 3.6**
|
||||||
|
///
|
||||||
|
/// For any CNC program with multiple SavePositionNodes, if image acquisition fails
|
||||||
|
/// (detector returns null) or image persistence fails (I/O error) at any position,
|
||||||
|
/// all subsequent positions SHALL still be attempted for execution.
|
||||||
|
/// </summary>
|
||||||
|
public class ExecutionResiliencePropertyTests
|
||||||
|
{
|
||||||
|
// ── Helper: Create a frozen BitmapSource for testing ──
|
||||||
|
private static BitmapSource CreateTestBitmap()
|
||||||
|
{
|
||||||
|
var stride = 4;
|
||||||
|
var pixels = new byte[stride * 1];
|
||||||
|
var bitmap = BitmapSource.Create(1, 1, 96, 96, PixelFormats.Bgra32, null, pixels, stride);
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: Create service with full mock access ──
|
||||||
|
private static (
|
||||||
|
CncExecutionService Service,
|
||||||
|
Mock<IInspectionResultStore> Store,
|
||||||
|
Mock<ILoggerService> Logger,
|
||||||
|
Mock<IMainViewportService> MainViewport,
|
||||||
|
Mock<IAppStateService> AppState,
|
||||||
|
Mock<IImagePersistenceService> ImagePersistence)
|
||||||
|
CreateServiceWithImagePersistence()
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Default: WriteSummaryAsync succeeds
|
||||||
|
mockImagePersistenceService.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockStore, mockLogger, mockMainViewportService, mockAppStateService, mockImagePersistenceService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generator: Produce N SavePositionNodes with unique ascending indices ──
|
||||||
|
private static Gen<List<SavePositionNode>> SavePositionNodesGen(int minCount, int maxCount)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
from count in Gen.Choose(minCount, maxCount)
|
||||||
|
from nodes in GenSavePositionNodes(count)
|
||||||
|
select nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gen<List<SavePositionNode>> GenSavePositionNodes(int count)
|
||||||
|
{
|
||||||
|
Gen<List<SavePositionNode>> acc = Gen.Constant(new List<SavePositionNode>());
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var idx = i;
|
||||||
|
acc = from list in acc
|
||||||
|
from saveImage in Gen.Elements(true, false)
|
||||||
|
select new List<SavePositionNode>(list)
|
||||||
|
{
|
||||||
|
new SavePositionNode(
|
||||||
|
Guid.NewGuid(), idx, $"Pos_{idx}",
|
||||||
|
MotionState.Default, SaveImage: saveImage)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generator for a set of failure indices (positions where acquisition or save will fail).
|
||||||
|
/// Ensures at least one failure position and that failures don't cover ALL positions
|
||||||
|
/// (so we can verify subsequent positions still execute).
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<(HashSet<int> DetectorFailIndices, HashSet<int> SaveFailIndices)> FailureIndicesGen(int totalPositions)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
from failCount in Gen.Choose(1, Math.Max(1, totalPositions - 1))
|
||||||
|
from failIndices in Gen.Shuffle(Enumerable.Range(0, totalPositions).ToArray())
|
||||||
|
.Select(arr => arr.Take(failCount).ToList())
|
||||||
|
from splitPoint in Gen.Choose(0, failCount)
|
||||||
|
let detectorFails = new HashSet<int>(failIndices.Take(splitPoint))
|
||||||
|
let saveFails = new HashSet<int>(failIndices.Skip(splitPoint))
|
||||||
|
select (detectorFails, saveFails);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property Test ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feature: cnc-multi-position-image-capture, Property 3: 采集或保存失败不中断后续执行
|
||||||
|
/// **Validates: Requirements 1.5, 1.6, 3.3, 3.6**
|
||||||
|
///
|
||||||
|
/// For any CNC program with N >= 2 SavePositionNodes, if image acquisition fails
|
||||||
|
/// (detector returns null) or image persistence fails (I/O error) at randomly selected
|
||||||
|
/// positions, ALL N positions SHALL still be attempted (progress reported for each).
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property AcquisitionOrSaveFailure_DoesNotInterruptSubsequentPositions()
|
||||||
|
{
|
||||||
|
var gen =
|
||||||
|
from nodes in SavePositionNodesGen(2, 8)
|
||||||
|
from failures in FailureIndicesGen(nodes.Count)
|
||||||
|
select (nodes, failures.DetectorFailIndices, failures.SaveFailIndices);
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
gen.ToArbitrary(),
|
||||||
|
tuple =>
|
||||||
|
{
|
||||||
|
var (saveNodes, detectorFailIndices, saveFailIndices) = tuple;
|
||||||
|
int totalPositions = saveNodes.Count;
|
||||||
|
|
||||||
|
var (service, _, _, mockMainViewport, _, mockImagePersistence) =
|
||||||
|
CreateServiceWithImagePersistence();
|
||||||
|
|
||||||
|
// ── Configure detector mock ──
|
||||||
|
// For positions where detector should fail, return null.
|
||||||
|
// For others, return a valid image.
|
||||||
|
var testBitmap = CreateTestBitmap();
|
||||||
|
|
||||||
|
// TryGetSourceImage reads from LatestManualImage first, then CurrentDisplayImage.
|
||||||
|
// We use CurrentDisplayImage to control per-position behavior.
|
||||||
|
mockMainViewport.SetupGet(m => m.LatestManualImage)
|
||||||
|
.Returns((System.Windows.Media.ImageSource)null);
|
||||||
|
|
||||||
|
// Use a callback-based setup to return null or valid image based on position
|
||||||
|
var imageSequence = new Queue<BitmapSource>();
|
||||||
|
foreach (var node in saveNodes)
|
||||||
|
{
|
||||||
|
int nodeIndex = saveNodes.IndexOf(node);
|
||||||
|
if (detectorFailIndices.Contains(nodeIndex))
|
||||||
|
imageSequence.Enqueue(null); // Detector fails
|
||||||
|
else
|
||||||
|
imageSequence.Enqueue(testBitmap); // Detector succeeds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also need an initial call for runSourceImage at the start of ExecuteAsync
|
||||||
|
mockMainViewport.Setup(m => m.CurrentDisplayImage)
|
||||||
|
.Returns(() =>
|
||||||
|
{
|
||||||
|
if (imageSequence.Count > 0)
|
||||||
|
return imageSequence.Dequeue();
|
||||||
|
return testBitmap;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Configure image persistence mock ──
|
||||||
|
// For positions where save should fail, throw IOException.
|
||||||
|
// For others, return success.
|
||||||
|
mockImagePersistence.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<byte[], string, string, CancellationToken>((_, nodeName, __, ___) =>
|
||||||
|
{
|
||||||
|
// Find the position index by node name
|
||||||
|
var posIdx = saveNodes.FindIndex(n => n.Name == nodeName);
|
||||||
|
if (posIdx >= 0 && saveFailIndices.Contains(posIdx))
|
||||||
|
{
|
||||||
|
// Simulate I/O failure
|
||||||
|
return Task.FromResult(new ImageSaveResult(
|
||||||
|
false, string.Empty, 0, "Simulated I/O error"));
|
||||||
|
}
|
||||||
|
return Task.FromResult(new ImageSaveResult(
|
||||||
|
true, $"C:\\Images\\{nodeName}.bmp", 1024, null));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Build CNC program ──
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(), "TestProgram",
|
||||||
|
DateTime.UtcNow, DateTime.UtcNow,
|
||||||
|
saveNodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
// ── Track progress reports ──
|
||||||
|
var reportedNodeIds = new List<Guid>();
|
||||||
|
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
|
||||||
|
{
|
||||||
|
// Count Running reports for SavePositionNodes (first report per position)
|
||||||
|
if (p.State == NodeExecutionState.Running && p.PositionIndex.HasValue)
|
||||||
|
{
|
||||||
|
if (!reportedNodeIds.Contains(p.NodeId))
|
||||||
|
reportedNodeIds.Add(p.NodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Execute ──
|
||||||
|
service.ExecuteAsync(program, progress, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// ── Verify: ALL positions were attempted ──
|
||||||
|
// Each SavePositionNode should have received at least one Running progress report
|
||||||
|
bool allPositionsAttempted = reportedNodeIds.Count == totalPositions;
|
||||||
|
|
||||||
|
return allPositionsAttempted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using XplorePlane.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Property 6: 采集图像传递给紧邻的 InspectionModuleNode
|
||||||
|
/// Feature: cnc-multi-position-image-capture
|
||||||
|
/// **Validates: Requirements 3.2**
|
||||||
|
///
|
||||||
|
/// For any CNC program where a SavePositionNode is immediately followed (by Index order)
|
||||||
|
/// by an InspectionModuleNode, the image acquired at that SavePositionNode SHALL be passed
|
||||||
|
/// as the source image to the InspectionModuleNode's pipeline execution.
|
||||||
|
/// </summary>
|
||||||
|
public class ImagePassingPropertyTests
|
||||||
|
{
|
||||||
|
// ── Helper: Create a 1x1 BitmapSource for testing ──────────────────────
|
||||||
|
|
||||||
|
private static BitmapSource CreateTestBitmap()
|
||||||
|
{
|
||||||
|
var bitmap = new WriteableBitmap(1, 1, 96, 96, PixelFormats.Bgr32, null);
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generator: CNC programs with SavePositionNodes optionally followed by InspectionModuleNodes ──
|
||||||
|
|
||||||
|
private static Gen<string> NonEmptyStringGen =>
|
||||||
|
ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get);
|
||||||
|
|
||||||
|
private static Gen<PipelineModel> PipelineGen =>
|
||||||
|
from name in NonEmptyStringGen
|
||||||
|
select new PipelineModel
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Nodes = new List<PipelineNodeModel>
|
||||||
|
{
|
||||||
|
new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a CNC program where some SavePositionNodes are followed by InspectionModuleNodes
|
||||||
|
/// and some are not. Returns the program along with metadata about which pairs exist.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<CncProgram> CncProgramWithMixedFollowersGen()
|
||||||
|
{
|
||||||
|
return from saveCount in Gen.Choose(1, 5)
|
||||||
|
from followFlags in Gen.ListOf<bool>(ArbMap.Default.GeneratorFor<bool>(), saveCount)
|
||||||
|
from programName in NonEmptyStringGen
|
||||||
|
let nodes = BuildMixedNodes(saveCount, followFlags)
|
||||||
|
select new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
programName,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.AsReadOnly());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CncNode> BuildMixedNodes(int saveCount, IReadOnlyList<bool> followFlags)
|
||||||
|
{
|
||||||
|
var nodes = new List<CncNode>();
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < saveCount; i++)
|
||||||
|
{
|
||||||
|
// Add a SavePositionNode
|
||||||
|
var spNode = new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index,
|
||||||
|
$"Position_{i}",
|
||||||
|
MotionState.Default,
|
||||||
|
SaveImage: false,
|
||||||
|
ManualImagePath: "");
|
||||||
|
nodes.Add(spNode);
|
||||||
|
index++;
|
||||||
|
|
||||||
|
// Optionally follow with an InspectionModuleNode
|
||||||
|
if (i < followFlags.Count && followFlags[i])
|
||||||
|
{
|
||||||
|
var pipeline = new PipelineModel
|
||||||
|
{
|
||||||
|
Name = $"Pipeline_{i}",
|
||||||
|
Nodes = new List<PipelineNodeModel>
|
||||||
|
{
|
||||||
|
new PipelineNodeModel { OperatorKey = "test_op", Order = 0, IsEnabled = true }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var inspNode = new InspectionModuleNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index,
|
||||||
|
$"Inspect_{i}",
|
||||||
|
pipeline);
|
||||||
|
nodes.Add(inspNode);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service Factory ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (
|
||||||
|
CncExecutionService Service,
|
||||||
|
Mock<IPipelineExecutionService> PipelineExec,
|
||||||
|
Mock<IMainViewportService> MainViewport,
|
||||||
|
Mock<IImagePersistenceService> ImagePersistence,
|
||||||
|
Mock<ILoggerService> Logger)
|
||||||
|
CreateServiceWithCapture(BitmapSource detectorImage)
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
// Return the detector image from the viewport service (simulates detector acquisition)
|
||||||
|
mockMainViewportService
|
||||||
|
.Setup(m => m.CurrentDisplayImage)
|
||||||
|
.Returns(detectorImage);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ImageSaveResult(true, "test.bmp", 100));
|
||||||
|
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockPipelineExecutionService, mockMainViewportService, mockImagePersistenceService, mockLogger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 6: 采集图像传递给紧邻的 InspectionModuleNode ──────────────
|
||||||
|
|
||||||
|
// Feature: cnc-multi-position-image-capture, Property 6: 采集图像传递给紧邻的 InspectionModuleNode
|
||||||
|
// Validates: Requirements 3.2
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property AcquiredImage_PassedToFollowingInspectionModuleNode()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
CncProgramWithMixedFollowersGen().ToArbitrary(),
|
||||||
|
program =>
|
||||||
|
{
|
||||||
|
var detectorImage = CreateTestBitmap();
|
||||||
|
var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage);
|
||||||
|
|
||||||
|
// Track all pipeline execution calls with their source images
|
||||||
|
var pipelineCalls = new List<(Guid NodeId, BitmapSource SourceImage)>();
|
||||||
|
|
||||||
|
mockPipelineExec
|
||||||
|
.Setup(p => p.ExecutePipelineAsync(
|
||||||
|
It.IsAny<IEnumerable<PipelineNodeViewModel>>(),
|
||||||
|
It.IsAny<BitmapSource>(),
|
||||||
|
It.IsAny<IProgress<PipelineProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<IEnumerable<PipelineNodeViewModel>, BitmapSource, IProgress<PipelineProgress>, CancellationToken>(
|
||||||
|
(nodes, source, progress, ct) =>
|
||||||
|
{
|
||||||
|
// We can't easily get the NodeId from here, but we track the source image
|
||||||
|
pipelineCalls.Add((Guid.Empty, source));
|
||||||
|
})
|
||||||
|
.ReturnsAsync(detectorImage);
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Determine expected behavior from the program structure
|
||||||
|
var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList();
|
||||||
|
var savePositionNodes = allNodesOrdered.OfType<SavePositionNode>().ToList();
|
||||||
|
|
||||||
|
int expectedPipelineCalls = 0;
|
||||||
|
foreach (var sp in savePositionNodes)
|
||||||
|
{
|
||||||
|
int spOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id);
|
||||||
|
if (spOrderIndex >= 0 && spOrderIndex + 1 < allNodesOrdered.Count)
|
||||||
|
{
|
||||||
|
var nextNode = allNodesOrdered[spOrderIndex + 1];
|
||||||
|
if (nextNode is InspectionModuleNode)
|
||||||
|
expectedPipelineCalls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: pipeline was called exactly for each SavePositionNode followed by InspectionModuleNode
|
||||||
|
bool correctCallCount = pipelineCalls.Count == expectedPipelineCalls;
|
||||||
|
|
||||||
|
// Verify: each pipeline call received a non-null source image (the detector image)
|
||||||
|
bool allReceivedImage = pipelineCalls.All(c => c.SourceImage != null);
|
||||||
|
|
||||||
|
// Verify: each pipeline call received the same detector image
|
||||||
|
bool allReceivedCorrectImage = pipelineCalls.All(c =>
|
||||||
|
ReferenceEquals(c.SourceImage, detectorImage));
|
||||||
|
|
||||||
|
return correctCallCount && allReceivedImage && allReceivedCorrectImage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 6 (negative case): SavePositionNode NOT followed by InspectionModuleNode ──
|
||||||
|
|
||||||
|
// Feature: cnc-multi-position-image-capture, Property 6 (negative): No pipeline execution for non-followed SavePositionNodes
|
||||||
|
// Validates: Requirements 3.2
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property NoPipelineExecution_WhenNoFollowingInspectionModuleNode()
|
||||||
|
{
|
||||||
|
// Generate programs where NO SavePositionNode is followed by an InspectionModuleNode
|
||||||
|
var gen = from saveCount in Gen.Choose(1, 5)
|
||||||
|
from programName in NonEmptyStringGen
|
||||||
|
let nodes = BuildNodesWithoutFollowingInspection(saveCount)
|
||||||
|
select new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
programName,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.AsReadOnly());
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
gen.ToArbitrary(),
|
||||||
|
program =>
|
||||||
|
{
|
||||||
|
var detectorImage = CreateTestBitmap();
|
||||||
|
var (service, mockPipelineExec, _, _, _) = CreateServiceWithCapture(detectorImage);
|
||||||
|
|
||||||
|
int pipelineCallCount = 0;
|
||||||
|
mockPipelineExec
|
||||||
|
.Setup(p => p.ExecutePipelineAsync(
|
||||||
|
It.IsAny<IEnumerable<PipelineNodeViewModel>>(),
|
||||||
|
It.IsAny<BitmapSource>(),
|
||||||
|
It.IsAny<IProgress<PipelineProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<IEnumerable<PipelineNodeViewModel>, BitmapSource, IProgress<PipelineProgress>, CancellationToken>(
|
||||||
|
(_, _, _, _) => Interlocked.Increment(ref pipelineCallCount))
|
||||||
|
.ReturnsAsync(detectorImage);
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// No pipeline execution should occur for SavePositionNodes
|
||||||
|
// (InspectionModuleNodes in the general loop may still trigger pipeline,
|
||||||
|
// but since we only have SavePositionNodes and non-inspection nodes, count should be 0)
|
||||||
|
return pipelineCallCount == 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CncNode> BuildNodesWithoutFollowingInspection(int saveCount)
|
||||||
|
{
|
||||||
|
var nodes = new List<CncNode>();
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < saveCount; i++)
|
||||||
|
{
|
||||||
|
// Add a SavePositionNode
|
||||||
|
nodes.Add(new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index,
|
||||||
|
$"Position_{i}",
|
||||||
|
MotionState.Default,
|
||||||
|
SaveImage: false,
|
||||||
|
ManualImagePath: ""));
|
||||||
|
index++;
|
||||||
|
|
||||||
|
// Follow with a non-inspection node (e.g., WaitDelayNode with 0ms)
|
||||||
|
nodes.Add(new WaitDelayNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index,
|
||||||
|
$"Wait_{i}",
|
||||||
|
0));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Feature: cnc-multi-position-image-capture, Property 2: 图像持久化对所有 SaveImage=true 节点触发
|
||||||
|
/// **Validates: Requirements 1.1, 2.5**
|
||||||
|
///
|
||||||
|
/// For any CNC program containing one or more SavePositionNode with SaveImage=true
|
||||||
|
/// (regardless of whether ManualImagePath is set or empty), the CNC_Execution_Service
|
||||||
|
/// SHALL invoke the Image_Persistence_Service exactly once per such node that is reached
|
||||||
|
/// during execution.
|
||||||
|
/// </summary>
|
||||||
|
public class ImagePersistenceCallPropertyTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a 1x1 pixel frozen BitmapSource for testing purposes.
|
||||||
|
/// </summary>
|
||||||
|
private static BitmapSource CreateTestBitmap()
|
||||||
|
{
|
||||||
|
var bitmap = BitmapSource.Create(
|
||||||
|
1, 1, 96, 96,
|
||||||
|
PixelFormats.Gray16,
|
||||||
|
null,
|
||||||
|
new byte[] { 0xFF, 0xFF },
|
||||||
|
2);
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a fully mocked CncExecutionService with IMainViewportService returning
|
||||||
|
/// a valid image (so TryGetSourceImage always succeeds) and IImagePersistenceService
|
||||||
|
/// tracking SaveImageAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
private static (CncExecutionService Service, Mock<IImagePersistenceService> ImagePersistence)
|
||||||
|
CreateServiceWithImageSupport()
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
// Set up GetEvent<DetectorDisconnectedEvent>() so the constructor subscription doesn't throw
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
// Mock IMainViewportService to return a valid image so TryGetSourceImage() succeeds
|
||||||
|
var testBitmap = CreateTestBitmap();
|
||||||
|
mockMainViewportService
|
||||||
|
.Setup(m => m.LatestManualImage)
|
||||||
|
.Returns(testBitmap);
|
||||||
|
|
||||||
|
// Mock IInspectionResultStore methods
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Mock SaveImageAsync to return success
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ImageSaveResult(true, @"C:\test\image.bmp", 1024));
|
||||||
|
|
||||||
|
// Mock WriteSummaryAsync to return success
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockImagePersistenceService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generator for a list of SavePositionNodes with random SaveImage flags.
|
||||||
|
/// Ensures at least one node has SaveImage=true.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<List<SavePositionNode>> SavePositionNodesGen =>
|
||||||
|
from count in Gen.Choose(1, 8)
|
||||||
|
from flags in Gen.ListOf<bool>(ArbMap.Default.GeneratorFor<bool>(), count)
|
||||||
|
let hasAtLeastOneTrue = flags.Any(f => f)
|
||||||
|
let adjustedFlags = hasAtLeastOneTrue ? flags : flags.Select((f, i) => i == 0 || f).ToList()
|
||||||
|
from names in Gen.ListOf<string>(ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get), count)
|
||||||
|
select Enumerable.Range(0, count)
|
||||||
|
.Select(i => new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
i,
|
||||||
|
names[i],
|
||||||
|
new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
SaveImage: adjustedFlags[i],
|
||||||
|
ManualImagePath: ""))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generator for SavePositionNodes where some have ManualImagePath set (non-empty)
|
||||||
|
/// and some don't, but all with SaveImage=true to test that persistence is called
|
||||||
|
/// regardless of ManualImagePath being set.
|
||||||
|
/// Note: ManualImagePath validation requires file to exist, so we leave it empty
|
||||||
|
/// in this test (the detector mock provides the image).
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<List<SavePositionNode>> SavePositionNodesAllSaveImageTrueGen =>
|
||||||
|
from count in Gen.Choose(1, 8)
|
||||||
|
from names in Gen.ListOf<string>(ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get), count)
|
||||||
|
select Enumerable.Range(0, count)
|
||||||
|
.Select(i => new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
i,
|
||||||
|
names[i],
|
||||||
|
new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
SaveImage: true,
|
||||||
|
ManualImagePath: ""))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ── Property 2: 图像持久化对所有 SaveImage=true 节点触发 ──────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feature: cnc-multi-position-image-capture, Property 2: 图像持久化对所有 SaveImage=true 节点触发
|
||||||
|
/// **Validates: Requirements 1.1, 2.5**
|
||||||
|
///
|
||||||
|
/// For any CNC program containing SavePositionNodes with mixed SaveImage flags,
|
||||||
|
/// SaveImageAsync is called exactly N times where N = count of nodes with SaveImage=true.
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SaveImageAsync_CalledExactlyOncePerSaveImageTrueNode()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
SavePositionNodesGen.ToArbitrary(),
|
||||||
|
nodes =>
|
||||||
|
{
|
||||||
|
var (service, mockImagePersistence) = CreateServiceWithImageSupport();
|
||||||
|
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
int expectedCalls = nodes.Count(n => n.SaveImage);
|
||||||
|
|
||||||
|
mockImagePersistence.Verify(
|
||||||
|
s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Exactly(expectedCalls));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feature: cnc-multi-position-image-capture, Property 2 (all true variant):
|
||||||
|
/// When all nodes have SaveImage=true, SaveImageAsync is called exactly N times (one per node).
|
||||||
|
/// **Validates: Requirements 1.1, 2.5**
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SaveImageAsync_CalledForEveryNode_WhenAllSaveImageTrue()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
SavePositionNodesAllSaveImageTrueGen.ToArbitrary(),
|
||||||
|
nodes =>
|
||||||
|
{
|
||||||
|
var (service, mockImagePersistence) = CreateServiceWithImageSupport();
|
||||||
|
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// All nodes have SaveImage=true, so SaveImageAsync should be called for each
|
||||||
|
mockImagePersistence.Verify(
|
||||||
|
s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Exactly(nodes.Count));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feature: cnc-multi-position-image-capture, Property 2 (none true variant):
|
||||||
|
/// When no nodes have SaveImage=true, SaveImageAsync is never called.
|
||||||
|
/// **Validates: Requirements 1.1, 2.5**
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property SaveImageAsync_NeverCalled_WhenNoSaveImageTrue()
|
||||||
|
{
|
||||||
|
var nodesGen =
|
||||||
|
from count in Gen.Choose(1, 8)
|
||||||
|
from names in Gen.ListOf<string>(ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get), count)
|
||||||
|
select Enumerable.Range(0, count)
|
||||||
|
.Select(i => new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
i,
|
||||||
|
names[i],
|
||||||
|
new MotionState(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
SaveImage: false,
|
||||||
|
ManualImagePath: ""))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
nodesGen.ToArbitrary(),
|
||||||
|
nodes =>
|
||||||
|
{
|
||||||
|
var (service, mockImagePersistence) = CreateServiceWithImageSupport();
|
||||||
|
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.Cast<CncNode>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// No nodes have SaveImage=true, so SaveImageAsync should never be called
|
||||||
|
mockImagePersistence.Verify(
|
||||||
|
s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Moq;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for ImagePersistenceService.
|
||||||
|
/// Validates: Requirements 1.3, 1.5, 1.7, 4.3
|
||||||
|
/// </summary>
|
||||||
|
public class ImagePersistenceServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
private readonly Mock<IXpDataPathService> _mockDataPathService;
|
||||||
|
private readonly Mock<ILoggerService> _mockLogger;
|
||||||
|
private readonly ImagePersistenceService _service;
|
||||||
|
|
||||||
|
public ImagePersistenceServiceTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), "ImagePersistenceTests_" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
|
||||||
|
_mockDataPathService = new Mock<IXpDataPathService>();
|
||||||
|
_mockDataPathService.Setup(s => s.DataPath).Returns(_tempDir);
|
||||||
|
|
||||||
|
_mockLogger = new Mock<ILoggerService>();
|
||||||
|
_mockLogger.Setup(l => l.ForModule<ImagePersistenceService>()).Returns(_mockLogger.Object);
|
||||||
|
|
||||||
|
_service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_tempDir))
|
||||||
|
Directory.Delete(_tempDir, recursive: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: Directory auto-creation ────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveImageAsync_CreatesDirectory_WhenItDoesNotExist()
|
||||||
|
{
|
||||||
|
// Arrange: use a sub-path that doesn't exist yet
|
||||||
|
var nestedDir = Path.Combine(_tempDir, "nested", "deep");
|
||||||
|
_mockDataPathService.Setup(s => s.DataPath).Returns(nestedDir);
|
||||||
|
var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
var imageData = new byte[] { 0x42, 0x4D, 0x00, 0x01 }; // minimal data
|
||||||
|
var programName = "TestProgram";
|
||||||
|
var nodeName = "Position1";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SaveImageAsync(imageData, nodeName, programName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(Directory.Exists(Path.GetDirectoryName(result.FilePath)));
|
||||||
|
Assert.True(File.Exists(result.FilePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: Successful save returns correct ImageSaveResult ─────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveImageAsync_ReturnsCorrectResult_OnSuccess()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var imageData = new byte[1024];
|
||||||
|
new Random(42).NextBytes(imageData);
|
||||||
|
var programName = "MyProgram";
|
||||||
|
var nodeName = "Node_A";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SaveImageAsync(imageData, nodeName, programName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(result.FilePath);
|
||||||
|
Assert.Contains(nodeName, result.FilePath);
|
||||||
|
Assert.EndsWith(".bmp", result.FilePath);
|
||||||
|
Assert.Equal(1024, result.FileSizeBytes);
|
||||||
|
Assert.Null(result.ErrorMessage);
|
||||||
|
|
||||||
|
// Verify file actually exists with correct content
|
||||||
|
var savedBytes = await File.ReadAllBytesAsync(result.FilePath);
|
||||||
|
Assert.Equal(imageData, savedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: Timeout returns failure result ──────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveImageAsync_ReturnsFailure_WhenCancelled()
|
||||||
|
{
|
||||||
|
// Arrange: pre-cancel the token to simulate timeout/cancellation scenario
|
||||||
|
var imageData = new byte[] { 0x01, 0x02, 0x03 };
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel(); // Pre-cancel to trigger OperationCanceledException immediately
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SaveImageAsync(imageData, "TestNode", "TestProgram", cts.Token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Null(result.FilePath);
|
||||||
|
Assert.Equal(0, result.FileSizeBytes);
|
||||||
|
Assert.NotNull(result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveImageAsync_ReturnsFailure_WhenTimeoutOccurs()
|
||||||
|
{
|
||||||
|
// Arrange: Use a read-only directory to force an IOException, simulating a timeout-like failure
|
||||||
|
// We test the timeout path by using a very short internal timeout via a linked token
|
||||||
|
// Since we can't easily trigger a real 10s timeout in a unit test,
|
||||||
|
// we verify the cancellation path works correctly with a pre-cancelled external token
|
||||||
|
// and verify the error message format for the external cancellation path.
|
||||||
|
|
||||||
|
// To truly test the timeout path, we'd need to mock File.WriteAllBytesAsync,
|
||||||
|
// but since the service uses static File methods, we test the cancellation behavior instead.
|
||||||
|
// The timeout mechanism uses CancellationTokenSource.CreateLinkedTokenSource + CancelAfter(10s).
|
||||||
|
|
||||||
|
// Verify that when external cancellation is NOT requested but operation is cancelled
|
||||||
|
// (simulating internal timeout), the error message mentions timeout.
|
||||||
|
// We achieve this by making the directory path invalid so the write fails.
|
||||||
|
var invalidPath = Path.Combine(_tempDir, new string('x', 300)); // Path too long
|
||||||
|
_mockDataPathService.Setup(s => s.DataPath).Returns(invalidPath);
|
||||||
|
var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
var imageData = new byte[] { 0x01, 0x02, 0x03 };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SaveImageAsync(imageData, "TestNode", "TestProgram");
|
||||||
|
|
||||||
|
// Assert - should fail gracefully (IOException path)
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.NotNull(result.ErrorMessage);
|
||||||
|
Assert.Equal(0, result.FileSizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: WriteSummaryAsync generates valid JSON ──────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteSummaryAsync_GeneratesValidJson()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var summary = new BatchCaptureResult
|
||||||
|
{
|
||||||
|
ProgramName = "TestProgram",
|
||||||
|
StartTime = "2025-01-15T14:30:25",
|
||||||
|
DurationSeconds = 45.5,
|
||||||
|
TotalPositions = 3,
|
||||||
|
SucceededPositions = 2,
|
||||||
|
FailedPositions = 1,
|
||||||
|
SavedImageCount = 2,
|
||||||
|
Status = "Completed",
|
||||||
|
Positions = new List<PositionResult>
|
||||||
|
{
|
||||||
|
new PositionResult { NodeName = "Pos1", NodeIndex = 0, Status = "Success", ImagePath = @"C:\img1.bmp" },
|
||||||
|
new PositionResult { NodeName = "Pos2", NodeIndex = 1, Status = "Success", ImagePath = @"C:\img2.bmp" },
|
||||||
|
new PositionResult { NodeName = "Pos3", NodeIndex = 2, Status = "Failed", ErrorMessage = "Detector timeout" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var programName = "TestProgram";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var success = await _service.WriteSummaryAsync(summary, programName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(success);
|
||||||
|
|
||||||
|
// Verify the JSON file exists and can be deserialized back
|
||||||
|
var directory = _service.GetBatchCaptureDirectory(programName);
|
||||||
|
var summaryPath = Path.Combine(directory, "summary.json");
|
||||||
|
Assert.True(File.Exists(summaryPath));
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(summaryPath);
|
||||||
|
var deserialized = JsonSerializer.Deserialize<BatchCaptureResult>(json);
|
||||||
|
|
||||||
|
Assert.NotNull(deserialized);
|
||||||
|
Assert.Equal(summary.ProgramName, deserialized.ProgramName);
|
||||||
|
Assert.Equal(summary.StartTime, deserialized.StartTime);
|
||||||
|
Assert.Equal(summary.DurationSeconds, deserialized.DurationSeconds);
|
||||||
|
Assert.Equal(summary.TotalPositions, deserialized.TotalPositions);
|
||||||
|
Assert.Equal(summary.SucceededPositions, deserialized.SucceededPositions);
|
||||||
|
Assert.Equal(summary.FailedPositions, deserialized.FailedPositions);
|
||||||
|
Assert.Equal(summary.SavedImageCount, deserialized.SavedImageCount);
|
||||||
|
Assert.Equal(summary.Status, deserialized.Status);
|
||||||
|
Assert.Equal(3, deserialized.Positions.Count);
|
||||||
|
Assert.Equal("Pos1", deserialized.Positions[0].NodeName);
|
||||||
|
Assert.Equal("Failed", deserialized.Positions[2].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: WriteSummaryAsync returns false on I/O failure ──────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteSummaryAsync_ReturnsFalse_OnIOFailure()
|
||||||
|
{
|
||||||
|
// Arrange: use an invalid path that will cause I/O failure
|
||||||
|
var invalidPath = Path.Combine(_tempDir, new string('x', 300));
|
||||||
|
_mockDataPathService.Setup(s => s.DataPath).Returns(invalidPath);
|
||||||
|
var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
var summary = new BatchCaptureResult
|
||||||
|
{
|
||||||
|
ProgramName = "TestProgram",
|
||||||
|
TotalPositions = 1,
|
||||||
|
Positions = new List<PositionResult>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.WriteSummaryAsync(summary, "TestProgram");
|
||||||
|
|
||||||
|
// Assert - should return false, not throw
|
||||||
|
Assert.False(result);
|
||||||
|
|
||||||
|
// Verify error was logged
|
||||||
|
_mockLogger.Verify(
|
||||||
|
l => l.Error(It.IsAny<Exception>(), It.IsAny<string>(), It.IsAny<object[]>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: GetBatchCaptureDirectory returns correct path format ────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBatchCaptureDirectory_ReturnsCorrectPathFormat()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var programName = "MyProgram";
|
||||||
|
var expectedDate = DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _service.GetBatchCaptureDirectory(programName);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Contains("CapturedImages", result);
|
||||||
|
Assert.Contains(expectedDate, result);
|
||||||
|
Assert.Contains(programName, result);
|
||||||
|
Assert.StartsWith(_tempDir, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: SaveImageAsync sanitizes node name in file path ─────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveImageAsync_SanitizesNodeName_InFilePath()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var imageData = new byte[] { 0x42, 0x4D };
|
||||||
|
var unsafeNodeName = "Node:With*Invalid?Chars";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SaveImageAsync(imageData, unsafeNodeName, "Program1");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
var fileName = Path.GetFileName(result.FilePath);
|
||||||
|
Assert.DoesNotContain(":", fileName);
|
||||||
|
Assert.DoesNotContain("*", fileName);
|
||||||
|
Assert.DoesNotContain("?", fileName);
|
||||||
|
Assert.Contains("Node_With_Invalid_Chars", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test: SaveImageAsync logs on success ──────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveImageAsync_LogsInfo_OnSuccess()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var imageData = new byte[512];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _service.SaveImageAsync(imageData, "TestNode", "TestProgram");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_mockLogger.Verify(
|
||||||
|
l => l.Info(It.IsAny<string>(), It.IsAny<object[]>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Property 7: 进度报告包含正确的索引和总数
|
||||||
|
/// **Validates: Requirements 3.4**
|
||||||
|
///
|
||||||
|
/// For any CNC program with N SavePositionNodes, the CNC_Execution_Service SHALL report
|
||||||
|
/// progress N times (or fewer if cancelled), each report containing a 0-based position index
|
||||||
|
/// and the total count N, with indices strictly increasing from 0.
|
||||||
|
/// </summary>
|
||||||
|
public class ProgressReportPropertyTests
|
||||||
|
{
|
||||||
|
private static (CncExecutionService Service, Mock<IMainViewportService> MainViewport)
|
||||||
|
CreateServiceWithImage()
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Provide a valid image so that image acquisition succeeds
|
||||||
|
var dummyImage = CreateDummyBitmap();
|
||||||
|
mockMainViewportService.SetupGet(m => m.LatestManualImage).Returns((ImageSource)null);
|
||||||
|
mockMainViewportService.SetupGet(m => m.CurrentDisplayImage).Returns(dummyImage);
|
||||||
|
|
||||||
|
// Image persistence returns success
|
||||||
|
mockImagePersistenceService.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ImageSaveResult(true, @"C:\test\image.bmp", 1024));
|
||||||
|
|
||||||
|
mockImagePersistenceService.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockMainViewportService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BitmapSource CreateDummyBitmap()
|
||||||
|
{
|
||||||
|
var stride = 4 * 4; // 4 pixels wide, 4 bytes per pixel
|
||||||
|
var pixels = new byte[stride * 4]; // 4x4 image
|
||||||
|
var bitmap = BitmapSource.Create(4, 4, 96, 96, PixelFormats.Bgra32, null, pixels, stride);
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a CncProgram with N SavePositionNodes (N between 1 and 10),
|
||||||
|
/// each with unique ascending indices.
|
||||||
|
/// </summary>
|
||||||
|
private static Arbitrary<CncProgram> SavePositionProgramArb()
|
||||||
|
{
|
||||||
|
var gen =
|
||||||
|
from count in Gen.Choose(1, 10)
|
||||||
|
from name in ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get)
|
||||||
|
from nodes in GenSavePositionNodes(count)
|
||||||
|
select new CncProgram(
|
||||||
|
Guid.NewGuid(), name,
|
||||||
|
DateTime.UtcNow, DateTime.UtcNow,
|
||||||
|
nodes.AsReadOnly());
|
||||||
|
return gen.ToArbitrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gen<List<CncNode>> GenSavePositionNodes(int count)
|
||||||
|
{
|
||||||
|
Gen<List<CncNode>> acc = Gen.Constant(new List<CncNode>());
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var idx = i;
|
||||||
|
acc = from list in acc
|
||||||
|
from nodeName in ArbMap.Default.GeneratorFor<NonEmptyString>().Select(s => s.Get)
|
||||||
|
let node = new SavePositionNode(
|
||||||
|
Guid.NewGuid(), idx, $"Pos_{nodeName}_{idx}",
|
||||||
|
MotionState.Default, SaveImage: false)
|
||||||
|
select new List<CncNode>(list) { node };
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 7: 进度报告包含正确的索引和总数 ──────────────────────────
|
||||||
|
|
||||||
|
// Feature: cnc-multi-position-image-capture, Property 7: 进度报告包含正确的索引和总数
|
||||||
|
// Validates: Requirements 3.4
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property ProgressReports_ContainCorrectIndexAndTotal()
|
||||||
|
{
|
||||||
|
return Prop.ForAll(
|
||||||
|
SavePositionProgramArb(),
|
||||||
|
program =>
|
||||||
|
{
|
||||||
|
var (service, _) = CreateServiceWithImage();
|
||||||
|
|
||||||
|
var savePositionCount = program.Nodes.OfType<SavePositionNode>().Count();
|
||||||
|
|
||||||
|
// Capture all progress reports that have PositionIndex set (Running state)
|
||||||
|
var runningReports = new List<(int PositionIndex, int TotalPositions)>();
|
||||||
|
var progress = new SynchronousProgress<CncNodeExecutionProgress>(p =>
|
||||||
|
{
|
||||||
|
if (p.State == NodeExecutionState.Running
|
||||||
|
&& p.PositionIndex.HasValue
|
||||||
|
&& p.TotalPositions.HasValue)
|
||||||
|
{
|
||||||
|
runningReports.Add((p.PositionIndex.Value, p.TotalPositions.Value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, progress, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Verify: at least N Running progress reports (one per position)
|
||||||
|
if (runningReports.Count < savePositionCount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Verify: all TotalPositions values equal N
|
||||||
|
if (runningReports.Any(r => r.TotalPositions != savePositionCount))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Verify: PositionIndex values in Running reports are strictly increasing from 0
|
||||||
|
// Extract unique position indices in order of first appearance
|
||||||
|
var seenIndices = new List<int>();
|
||||||
|
foreach (var report in runningReports)
|
||||||
|
{
|
||||||
|
if (seenIndices.Count == 0 || seenIndices.Last() != report.PositionIndex)
|
||||||
|
seenIndices.Add(report.PositionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have exactly N distinct position indices
|
||||||
|
if (seenIndices.Count != savePositionCount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Indices must be 0, 1, 2, ..., N-1
|
||||||
|
for (int i = 0; i < savePositionCount; i++)
|
||||||
|
{
|
||||||
|
if (seenIndices[i] != i)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
#pragma warning disable xUnit1031 // FsCheck property tests require synchronous execution via GetAwaiter().GetResult()
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using FsCheck;
|
||||||
|
using FsCheck.Fluent;
|
||||||
|
using FsCheck.Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Prism.Events;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Cnc;
|
||||||
|
using XplorePlane.Services.InspectionResults;
|
||||||
|
using XplorePlane.Services.MainViewport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace XplorePlane.Tests.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Property 9: 运行摘要字段完整性与一致性
|
||||||
|
/// Validates: Requirements 4.1, 4.2
|
||||||
|
///
|
||||||
|
/// For any completed (non-cancelled) batch execution with known position results,
|
||||||
|
/// the generated BatchCaptureResult SHALL satisfy:
|
||||||
|
/// SucceededPositions + FailedPositions == TotalPositions,
|
||||||
|
/// and a position is marked "Success" if and only if its image capture completed
|
||||||
|
/// AND its inspection pipeline (if present) executed without error.
|
||||||
|
/// </summary>
|
||||||
|
public class SummaryCorrectnessPropertyTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a CncExecutionService with mocks configured for multi-position testing.
|
||||||
|
/// Returns the service and the mock for IImagePersistenceService to capture WriteSummaryAsync calls.
|
||||||
|
/// The failurePattern parameter controls which positions fail image acquisition (detector returns null).
|
||||||
|
/// The pipelineFailurePattern controls which positions fail pipeline execution.
|
||||||
|
/// </summary>
|
||||||
|
private static (CncExecutionService Service, Mock<IImagePersistenceService> ImagePersistence)
|
||||||
|
CreateServiceWithFailurePatterns(
|
||||||
|
bool[] imageAcquisitionFailures,
|
||||||
|
bool[] pipelineFailures)
|
||||||
|
{
|
||||||
|
var mockStore = new Mock<IInspectionResultStore>();
|
||||||
|
var mockLogger = new Mock<ILoggerService>();
|
||||||
|
var mockMainViewportService = new Mock<IMainViewportService>();
|
||||||
|
var mockAppStateService = new Mock<IAppStateService>();
|
||||||
|
var mockPipelineExecutionService = new Mock<IPipelineExecutionService>();
|
||||||
|
var mockImageProcessingService = new Mock<IImageProcessingService>();
|
||||||
|
var mockEventAggregator = new Mock<IEventAggregator>();
|
||||||
|
var mockImagePersistenceService = new Mock<IImagePersistenceService>();
|
||||||
|
|
||||||
|
mockLogger.Setup(l => l.ForModule<CncExecutionService>()).Returns(mockLogger.Object);
|
||||||
|
|
||||||
|
mockEventAggregator
|
||||||
|
.Setup(ea => ea.GetEvent<DetectorDisconnectedEvent>())
|
||||||
|
.Returns(new DetectorDisconnectedEvent());
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.BeginRunAsync(
|
||||||
|
It.IsAny<InspectionRunRecord>(),
|
||||||
|
It.IsAny<InspectionAssetWriteRequest>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.AppendNodeResultAsync(
|
||||||
|
It.IsAny<InspectionNodeResult>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionMetricResult>>(),
|
||||||
|
It.IsAny<PipelineExecutionSnapshot>(),
|
||||||
|
It.IsAny<IEnumerable<InspectionAssetWriteRequest>>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
mockStore.Setup(s => s.CompleteRunAsync(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<bool?>(),
|
||||||
|
It.IsAny<DateTime?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Track which position index is being processed to control failure patterns
|
||||||
|
int callIndex = 0;
|
||||||
|
|
||||||
|
// Control image acquisition: when imageAcquisitionFailures[i] is true,
|
||||||
|
// the detector returns null for that position (causing failure).
|
||||||
|
// We use LatestManualImage to provide/deny images.
|
||||||
|
mockMainViewportService
|
||||||
|
.Setup(m => m.LatestManualImage)
|
||||||
|
.Returns(() =>
|
||||||
|
{
|
||||||
|
// Return null to simulate detector failure for positions marked to fail
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockMainViewportService
|
||||||
|
.Setup(m => m.CurrentDisplayImage)
|
||||||
|
.Returns(() => null);
|
||||||
|
|
||||||
|
// Use AppStateService.LatestDetectorFrame to control image availability per position
|
||||||
|
// We'll use a sequence-based approach via callback
|
||||||
|
var positionCallCount = 0;
|
||||||
|
mockAppStateService
|
||||||
|
.Setup(a => a.LatestDetectorFrame)
|
||||||
|
.Returns(() =>
|
||||||
|
{
|
||||||
|
int currentPos = positionCallCount++;
|
||||||
|
if (currentPos < imageAcquisitionFailures.Length && imageAcquisitionFailures[currentPos])
|
||||||
|
{
|
||||||
|
// Return null frame to simulate acquisition failure
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Return a valid frame
|
||||||
|
return new DetectorFrame
|
||||||
|
{
|
||||||
|
ImageData = new ushort[4],
|
||||||
|
Width = 2,
|
||||||
|
Height = 2
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Control pipeline execution: throw exception for positions marked to fail
|
||||||
|
var pipelineCallCount = 0;
|
||||||
|
mockPipelineExecutionService
|
||||||
|
.Setup(p => p.ExecutePipelineAsync(
|
||||||
|
It.IsAny<IEnumerable<PipelineNodeViewModel>>(),
|
||||||
|
It.IsAny<BitmapSource>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<IEnumerable<PipelineNodeViewModel>, BitmapSource, CancellationToken>((nodes, img, ct) =>
|
||||||
|
{
|
||||||
|
int currentPipelinePos = pipelineCallCount++;
|
||||||
|
if (currentPipelinePos < pipelineFailures.Length && pipelineFailures[currentPipelinePos])
|
||||||
|
{
|
||||||
|
throw new Exception("Simulated pipeline failure");
|
||||||
|
}
|
||||||
|
return Task.FromResult(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image persistence always succeeds
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.SaveImageAsync(
|
||||||
|
It.IsAny<byte[]>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ImageSaveResult(true, "C:\\test\\image.bmp", 1024));
|
||||||
|
|
||||||
|
mockImagePersistenceService
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var service = new CncExecutionService(
|
||||||
|
mockStore.Object,
|
||||||
|
mockLogger.Object,
|
||||||
|
mockMainViewportService.Object,
|
||||||
|
mockAppStateService.Object,
|
||||||
|
mockPipelineExecutionService.Object,
|
||||||
|
mockImageProcessingService.Object,
|
||||||
|
mockEventAggregator.Object,
|
||||||
|
mockImagePersistenceService.Object);
|
||||||
|
|
||||||
|
return (service, mockImagePersistenceService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a list of SavePositionNodes with optional trailing InspectionModuleNodes.
|
||||||
|
/// </summary>
|
||||||
|
private static Gen<(List<CncNode> Nodes, bool[] HasInspection)> GenSavePositionProgram(int count)
|
||||||
|
{
|
||||||
|
return from hasInspections in Gen.ListOf(count, ArbMap.Default.GeneratorFor<bool>())
|
||||||
|
let nodes = BuildNodes(count, hasInspections.ToArray())
|
||||||
|
select (nodes, hasInspections.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CncNode> BuildNodes(int positionCount, bool[] hasInspection)
|
||||||
|
{
|
||||||
|
var nodes = new List<CncNode>();
|
||||||
|
int index = 0;
|
||||||
|
for (int i = 0; i < positionCount; i++)
|
||||||
|
{
|
||||||
|
var spNode = new SavePositionNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index,
|
||||||
|
$"Position_{i}",
|
||||||
|
MotionState.Default,
|
||||||
|
SaveImage: true,
|
||||||
|
ManualImagePath: "");
|
||||||
|
nodes.Add(spNode);
|
||||||
|
index++;
|
||||||
|
|
||||||
|
if (i < hasInspection.Length && hasInspection[i])
|
||||||
|
{
|
||||||
|
var inspNode = new InspectionModuleNode(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index,
|
||||||
|
$"Inspect_{i}",
|
||||||
|
new PipelineModel { Name = $"Pipeline_{i}" });
|
||||||
|
nodes.Add(inspNode);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property 9: 运行摘要字段完整性与一致性 ──────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 4.1, 4.2**
|
||||||
|
///
|
||||||
|
/// For any completed (non-cancelled) batch execution:
|
||||||
|
/// SucceededPositions + FailedPositions == TotalPositions == N
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property Summary_SucceededPlusFailedEqualsTotalPositions()
|
||||||
|
{
|
||||||
|
// Generate 1-8 positions with random failure patterns
|
||||||
|
var gen =
|
||||||
|
from posCount in Gen.Choose(1, 8)
|
||||||
|
from imageFailures in Gen.ListOf(posCount, Gen.Elements(true, false))
|
||||||
|
from pipelineFailures in Gen.ListOf(posCount, Gen.Elements(true, false))
|
||||||
|
from hasInspections in Gen.ListOf(posCount, ArbMap.Default.GeneratorFor<bool>())
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
PositionCount = posCount,
|
||||||
|
ImageFailures = imageFailures.ToArray(),
|
||||||
|
PipelineFailures = pipelineFailures.ToArray(),
|
||||||
|
HasInspections = hasInspections.ToArray()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
gen.ToArbitrary(),
|
||||||
|
testCase =>
|
||||||
|
{
|
||||||
|
var nodes = BuildNodes(testCase.PositionCount, testCase.HasInspections);
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.AsReadOnly());
|
||||||
|
|
||||||
|
// Build pipeline failure pattern: only positions with inspections can have pipeline failures
|
||||||
|
// Map pipeline failures to only those positions that have an inspection node following them
|
||||||
|
var effectivePipelineFailures = new bool[testCase.PositionCount];
|
||||||
|
for (int i = 0; i < testCase.PositionCount; i++)
|
||||||
|
{
|
||||||
|
effectivePipelineFailures[i] = testCase.HasInspections[i] && testCase.PipelineFailures[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
var (service, mockImagePersistence) = CreateServiceWithFailurePatterns(
|
||||||
|
testCase.ImageFailures,
|
||||||
|
effectivePipelineFailures);
|
||||||
|
|
||||||
|
// Capture the BatchCaptureResult passed to WriteSummaryAsync
|
||||||
|
BatchCaptureResult capturedResult = null;
|
||||||
|
mockImagePersistence
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<BatchCaptureResult, string, CancellationToken>((result, _, _) =>
|
||||||
|
{
|
||||||
|
capturedResult = result;
|
||||||
|
})
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Verify invariants
|
||||||
|
if (capturedResult == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Invariant 1: SucceededPositions + FailedPositions == TotalPositions
|
||||||
|
bool sumCorrect = capturedResult.SucceededPositions + capturedResult.FailedPositions
|
||||||
|
== capturedResult.TotalPositions;
|
||||||
|
|
||||||
|
// Invariant 2: TotalPositions == N (number of SavePositionNodes)
|
||||||
|
bool totalCorrect = capturedResult.TotalPositions == testCase.PositionCount;
|
||||||
|
|
||||||
|
// Invariant 3: Status == "Completed" (not cancelled)
|
||||||
|
bool statusCorrect = capturedResult.Status == "Completed";
|
||||||
|
|
||||||
|
// Invariant 4: Each position's Status matches whether it succeeded or failed
|
||||||
|
bool positionsCorrect = capturedResult.Positions.Count == testCase.PositionCount;
|
||||||
|
if (positionsCorrect)
|
||||||
|
{
|
||||||
|
int succeededCount = capturedResult.Positions.Count(p => p.Status == "Success");
|
||||||
|
int failedCount = capturedResult.Positions.Count(p => p.Status == "Failed");
|
||||||
|
positionsCorrect = succeededCount == capturedResult.SucceededPositions
|
||||||
|
&& failedCount == capturedResult.FailedPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sumCorrect && totalCorrect && statusCorrect && positionsCorrect;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// **Validates: Requirements 4.1, 4.2**
|
||||||
|
///
|
||||||
|
/// A position is marked "Success" if and only if its image capture completed
|
||||||
|
/// AND its inspection pipeline (if present) executed without error.
|
||||||
|
/// A position with image acquisition failure must be "Failed".
|
||||||
|
/// </summary>
|
||||||
|
[Property(MaxTest = 100)]
|
||||||
|
public Property Summary_PositionStatusReflectsActualOutcome()
|
||||||
|
{
|
||||||
|
var gen =
|
||||||
|
from posCount in Gen.Choose(1, 6)
|
||||||
|
from imageFailures in Gen.ListOf(posCount, Gen.Elements(true, false))
|
||||||
|
from pipelineFailures in Gen.ListOf(posCount, Gen.Elements(true, false))
|
||||||
|
from hasInspections in Gen.ListOf(posCount, ArbMap.Default.GeneratorFor<bool>())
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
PositionCount = posCount,
|
||||||
|
ImageFailures = imageFailures.ToArray(),
|
||||||
|
PipelineFailures = pipelineFailures.ToArray(),
|
||||||
|
HasInspections = hasInspections.ToArray()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Prop.ForAll(
|
||||||
|
gen.ToArbitrary(),
|
||||||
|
testCase =>
|
||||||
|
{
|
||||||
|
var nodes = BuildNodes(testCase.PositionCount, testCase.HasInspections);
|
||||||
|
var program = new CncProgram(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TestProgram",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
nodes.AsReadOnly());
|
||||||
|
|
||||||
|
var effectivePipelineFailures = new bool[testCase.PositionCount];
|
||||||
|
for (int i = 0; i < testCase.PositionCount; i++)
|
||||||
|
{
|
||||||
|
effectivePipelineFailures[i] = testCase.HasInspections[i] && testCase.PipelineFailures[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
var (service, mockImagePersistence) = CreateServiceWithFailurePatterns(
|
||||||
|
testCase.ImageFailures,
|
||||||
|
effectivePipelineFailures);
|
||||||
|
|
||||||
|
BatchCaptureResult capturedResult = null;
|
||||||
|
mockImagePersistence
|
||||||
|
.Setup(s => s.WriteSummaryAsync(
|
||||||
|
It.IsAny<BatchCaptureResult>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<BatchCaptureResult, string, CancellationToken>((result, _, _) =>
|
||||||
|
{
|
||||||
|
capturedResult = result;
|
||||||
|
})
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
service.ExecuteAsync(program, null, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (capturedResult == null || capturedResult.Positions.Count != testCase.PositionCount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Verify each position's status matches expected outcome
|
||||||
|
for (int i = 0; i < testCase.PositionCount; i++)
|
||||||
|
{
|
||||||
|
var posResult = capturedResult.Positions[i];
|
||||||
|
bool imageAcquired = !testCase.ImageFailures[i];
|
||||||
|
bool pipelineFailed = effectivePipelineFailures[i];
|
||||||
|
|
||||||
|
// A position is "Success" iff image capture completed AND pipeline (if present) succeeded
|
||||||
|
bool expectedSuccess = imageAcquired && !pipelineFailed;
|
||||||
|
string expectedStatus = expectedSuccess ? "Success" : "Failed";
|
||||||
|
|
||||||
|
if (posResult.Status != expectedStatus)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
// Validates: Requirements 6.1, 6.2
|
// Validates: Requirements 6.1, 6.2
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
using FsCheck;
|
using FsCheck;
|
||||||
using FsCheck.Fluent;
|
using FsCheck.Fluent;
|
||||||
using FsCheck.Xunit;
|
using FsCheck.Xunit;
|
||||||
@@ -65,5 +67,65 @@ namespace XplorePlane.Tests.ViewModels
|
|||||||
return runningOk && succeededOk && failedOk && idleOk;
|
return runningOk && succeededOk && failedOk && idleOk;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region ManualImagePath Binding Tests - Validates: Requirements 2.4
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowseImageCommand_IsNotNull_AndCanExecute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default);
|
||||||
|
var vm = new CncNodeViewModel(node, (_, __) => { });
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.NotNull(vm.BrowseImageCommand);
|
||||||
|
Assert.True(vm.BrowseImageCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualImagePath_Set_RaisesPropertyChangedNotification()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default);
|
||||||
|
var vm = new CncNodeViewModel(node, (_, __) => { });
|
||||||
|
var changedProperties = new List<string>();
|
||||||
|
((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
vm.ManualImagePath = @"C:\Images\test.bmp";
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Contains(nameof(vm.ManualImagePath), changedProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualImagePath_Getter_ReturnsValueFromUnderlyingModel()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expectedPath = @"D:\TestImages\sample.png";
|
||||||
|
var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default, ManualImagePath: expectedPath);
|
||||||
|
var vm = new CncNodeViewModel(node, (_, __) => { });
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.Equal(expectedPath, vm.ManualImagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualImagePath_Set_UpdatesUnderlyingModel()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var node = new SavePositionNode(Guid.NewGuid(), 0, "Pos_0", MotionState.Default);
|
||||||
|
var vm = new CncNodeViewModel(node, (_, __) => { });
|
||||||
|
var newPath = @"C:\Images\updated.tiff";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
vm.ManualImagePath = newPath;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updatedNode = Assert.IsType<SavePositionNode>(vm.Model);
|
||||||
|
Assert.Equal(newPath, updatedNode.ManualImagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace XplorePlane.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名清理工具,将文件系统非法字符替换为下划线
|
||||||
|
/// </summary>
|
||||||
|
public static class FileNameSanitizer
|
||||||
|
{
|
||||||
|
private static readonly char[] InvalidChars =
|
||||||
|
new[] { '\\', '/', ':', '*', '?', '"', '<', '>', '|' };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将文件名中的非法字符替换为下划线。
|
||||||
|
/// 空字符串或 null 返回 "_"。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileName">待清理的文件名</param>
|
||||||
|
/// <returns>清理后的安全文件名</returns>
|
||||||
|
public static string Sanitize(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(fileName)) return "_";
|
||||||
|
var result = fileName;
|
||||||
|
foreach (var c in InvalidChars)
|
||||||
|
result = result.Replace(c, '_');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace XplorePlane.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 手动图像路径验证结果枚举
|
||||||
|
/// </summary>
|
||||||
|
public enum ManualImageValidationResult
|
||||||
|
{
|
||||||
|
Valid,
|
||||||
|
Empty,
|
||||||
|
PathTooLong,
|
||||||
|
FileNotFound,
|
||||||
|
UnsupportedFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手动图像路径验证工具,检查路径有效性和文件格式支持
|
||||||
|
/// </summary>
|
||||||
|
public static class ManualImageValidator
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> SupportedExtensions =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { ".bmp", ".png", ".tiff", ".tif" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证手动图像路径是否有效。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">待验证的文件路径</param>
|
||||||
|
/// <returns>验证结果分类</returns>
|
||||||
|
public static ManualImageValidationResult Validate(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return ManualImageValidationResult.Empty;
|
||||||
|
|
||||||
|
if (path.Length > 260)
|
||||||
|
return ManualImageValidationResult.PathTooLong;
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return ManualImageValidationResult.FileNotFound;
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
if (!SupportedExtensions.Contains(ext))
|
||||||
|
return ManualImageValidationResult.UnsupportedFormat;
|
||||||
|
|
||||||
|
return ManualImageValidationResult.Valid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,8 @@ namespace XplorePlane.Models
|
|||||||
int Index,
|
int Index,
|
||||||
string Name,
|
string Name,
|
||||||
MotionState MotionState,
|
MotionState MotionState,
|
||||||
bool SaveImage = false) : CncNode(Id, Index, CncNodeType.SavePosition, Name);
|
bool SaveImage = false,
|
||||||
|
string ManualImagePath = "") : CncNode(Id, Index, CncNodeType.SavePosition, Name);
|
||||||
|
|
||||||
/// <summary>检测模块节点 | Inspection module node</summary>
|
/// <summary>检测模块节点 | Inspection module node</summary>
|
||||||
public record InspectionModuleNode(
|
public record InspectionModuleNode(
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace XplorePlane.Models
|
||||||
|
{
|
||||||
|
// ── 图像采集结果模型 | Image Capture Result Models ────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图像保存操作的结果 | Result of an image save operation
|
||||||
|
/// </summary>
|
||||||
|
public record ImageSaveResult(
|
||||||
|
bool Success,
|
||||||
|
string FilePath,
|
||||||
|
long FileSizeBytes,
|
||||||
|
string ErrorMessage = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 多位置批量采集运行摘要,序列化为 summary.json
|
||||||
|
/// | Batch capture run summary, serialized to summary.json
|
||||||
|
/// </summary>
|
||||||
|
public class BatchCaptureResult
|
||||||
|
{
|
||||||
|
public string ProgramName { get; set; } = string.Empty;
|
||||||
|
public string StartTime { get; set; } = string.Empty; // ISO 8601
|
||||||
|
public double DurationSeconds { get; set; }
|
||||||
|
public int TotalPositions { get; set; }
|
||||||
|
public int SucceededPositions { get; set; }
|
||||||
|
public int FailedPositions { get; set; }
|
||||||
|
public int SavedImageCount { get; set; }
|
||||||
|
public string Status { get; set; } = "Completed"; // Completed / Cancelled
|
||||||
|
public int? CompletedBeforeCancel { get; set; }
|
||||||
|
public int? NotExecutedAfterCancel { get; set; }
|
||||||
|
public List<PositionResult> Positions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单个位置的执行结果 | Execution result for a single position
|
||||||
|
/// </summary>
|
||||||
|
public class PositionResult
|
||||||
|
{
|
||||||
|
public string NodeName { get; set; } = string.Empty;
|
||||||
|
public int NodeIndex { get; set; }
|
||||||
|
public string Status { get; set; } = "Success"; // Success / Failed
|
||||||
|
public string ErrorMessage { get; set; }
|
||||||
|
public string ImagePath { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -10,6 +11,7 @@ using Prism.Events;
|
|||||||
using XP.Common.Converters;
|
using XP.Common.Converters;
|
||||||
using XP.Common.Logging.Interfaces;
|
using XP.Common.Logging.Interfaces;
|
||||||
using XplorePlane.Events;
|
using XplorePlane.Events;
|
||||||
|
using XplorePlane.Helpers;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
using XplorePlane.Services.AppState;
|
using XplorePlane.Services.AppState;
|
||||||
using XplorePlane.Services.InspectionResults;
|
using XplorePlane.Services.InspectionResults;
|
||||||
@@ -30,6 +32,7 @@ namespace XplorePlane.Services.Cnc
|
|||||||
private readonly IPipelineExecutionService _pipelineExecutionService;
|
private readonly IPipelineExecutionService _pipelineExecutionService;
|
||||||
private readonly IImageProcessingService _imageProcessingService;
|
private readonly IImageProcessingService _imageProcessingService;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
|
private readonly IImagePersistenceService _imagePersistenceService;
|
||||||
|
|
||||||
// Task 4.2: volatile field so reads/writes are not reordered across threads
|
// Task 4.2: volatile field so reads/writes are not reordered across threads
|
||||||
private volatile CancellationTokenSource _executionCts;
|
private volatile CancellationTokenSource _executionCts;
|
||||||
@@ -41,7 +44,8 @@ namespace XplorePlane.Services.Cnc
|
|||||||
IAppStateService appStateService,
|
IAppStateService appStateService,
|
||||||
IPipelineExecutionService pipelineExecutionService,
|
IPipelineExecutionService pipelineExecutionService,
|
||||||
IImageProcessingService imageProcessingService,
|
IImageProcessingService imageProcessingService,
|
||||||
IEventAggregator eventAggregator)
|
IEventAggregator eventAggregator,
|
||||||
|
IImagePersistenceService imagePersistenceService)
|
||||||
{
|
{
|
||||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
@@ -50,6 +54,7 @@ namespace XplorePlane.Services.Cnc
|
|||||||
_pipelineExecutionService = pipelineExecutionService;
|
_pipelineExecutionService = pipelineExecutionService;
|
||||||
_imageProcessingService = imageProcessingService;
|
_imageProcessingService = imageProcessingService;
|
||||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||||
|
_imagePersistenceService = imagePersistenceService ?? throw new ArgumentNullException(nameof(imagePersistenceService));
|
||||||
|
|
||||||
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
|
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
|
||||||
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
|
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
|
||||||
@@ -121,6 +126,178 @@ namespace XplorePlane.Services.Cnc
|
|||||||
bool allSucceeded = true;
|
bool allSucceeded = true;
|
||||||
BitmapSource lastResultImage = null;
|
BitmapSource lastResultImage = null;
|
||||||
|
|
||||||
|
// Task 5.1: Get all SavePositionNodes ordered by Index ascending for multi-position execution
|
||||||
|
var savePositionNodes = program.Nodes
|
||||||
|
.OfType<SavePositionNode>()
|
||||||
|
.OrderBy(n => n.Index)
|
||||||
|
.ToList();
|
||||||
|
int totalPositions = savePositionNodes.Count;
|
||||||
|
|
||||||
|
// Task 5.1: Multi-position execution loop with progress reporting and cancellation
|
||||||
|
for (int positionIndex = 0; positionIndex < totalPositions; positionIndex++)
|
||||||
|
{
|
||||||
|
// Task 5.1: Check CancellationToken at the start of each iteration
|
||||||
|
if (linkedCts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
cancelled = true;
|
||||||
|
_logger.ForModule<CncExecutionService>().Info(
|
||||||
|
"Multi-position execution cancelled at position {0}/{1}",
|
||||||
|
positionIndex, totalPositions);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sp = savePositionNodes[positionIndex];
|
||||||
|
|
||||||
|
// Task 5.1: Report progress (current 0-based index, total count)
|
||||||
|
progress?.Report(new CncNodeExecutionProgress(
|
||||||
|
sp.Id,
|
||||||
|
NodeExecutionState.Running,
|
||||||
|
ProgressPercent: 0,
|
||||||
|
PositionIndex: positionIndex,
|
||||||
|
TotalPositions: totalPositions));
|
||||||
|
|
||||||
|
_logger.ForModule<CncExecutionService>().Info(
|
||||||
|
"Executing save-position node [{Index}] {Name} (position {Current}/{Total}) | " +
|
||||||
|
"StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
|
||||||
|
"DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
|
||||||
|
"StageRotation={StageRotation} FixtureRotation={FixtureRotation} SaveImage={SaveImage}",
|
||||||
|
sp.Index, sp.Name, positionIndex + 1, totalPositions,
|
||||||
|
sp.MotionState.StageX, sp.MotionState.StageY,
|
||||||
|
sp.MotionState.SourceZ, sp.MotionState.DetectorZ,
|
||||||
|
sp.MotionState.DetectorSwing, sp.MotionState.FDD,
|
||||||
|
sp.MotionState.FOD, sp.MotionState.Magnification,
|
||||||
|
sp.MotionState.StageRotation, sp.MotionState.FixtureRotation,
|
||||||
|
sp.SaveImage);
|
||||||
|
|
||||||
|
bool nodeSucceeded = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Task 5.2: Manual image source loading logic
|
||||||
|
BitmapSource positionImage = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(sp.ManualImagePath))
|
||||||
|
{
|
||||||
|
// ManualImagePath is set - validate and load from file
|
||||||
|
var validationResult = ManualImageValidator.Validate(sp.ManualImagePath);
|
||||||
|
|
||||||
|
if (validationResult == ManualImageValidationResult.Valid)
|
||||||
|
{
|
||||||
|
// Load image from file
|
||||||
|
positionImage = LoadImageFromFile(sp.ManualImagePath);
|
||||||
|
if (positionImage != null)
|
||||||
|
{
|
||||||
|
currentSourceImage = positionImage;
|
||||||
|
_logger.ForModule<CncExecutionService>().Info(
|
||||||
|
"Loaded manual image from '{0}' for node '{1}'",
|
||||||
|
sp.ManualImagePath, sp.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.ForModule<CncExecutionService>().Warn(
|
||||||
|
"Failed to decode image file '{0}' for node '{1}'",
|
||||||
|
sp.ManualImagePath, sp.Name);
|
||||||
|
nodeSucceeded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Validation failed - show error dialog and abort current node
|
||||||
|
var errorMessage = validationResult switch
|
||||||
|
{
|
||||||
|
ManualImageValidationResult.PathTooLong =>
|
||||||
|
$"图像路径过长(超过260字符):\n{sp.ManualImagePath}",
|
||||||
|
ManualImageValidationResult.FileNotFound =>
|
||||||
|
$"图像文件不存在:\n{sp.ManualImagePath}",
|
||||||
|
ManualImageValidationResult.UnsupportedFormat =>
|
||||||
|
$"不支持的图像格式(仅支持 BMP、PNG、TIFF):\n{sp.ManualImagePath}",
|
||||||
|
_ => $"图像路径无效:\n{sp.ManualImagePath}"
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.ForModule<CncExecutionService>().Warn(
|
||||||
|
"Manual image validation failed for node '{0}': {1} - Path: '{2}'",
|
||||||
|
sp.Name, validationResult, sp.ManualImagePath);
|
||||||
|
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
MessageBox.Show(errorMessage, "手动图像加载失败", MessageBoxButton.OK, MessageBoxImage.Error));
|
||||||
|
|
||||||
|
nodeSucceeded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ManualImagePath is empty - use detector acquisition
|
||||||
|
var capturedImage = TryGetSourceImage();
|
||||||
|
if (capturedImage != null)
|
||||||
|
{
|
||||||
|
positionImage = capturedImage;
|
||||||
|
currentSourceImage = capturedImage;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.ForModule<CncExecutionService>().Warn(
|
||||||
|
"Save-position node '{0}' requested image capture, but no current image was available.",
|
||||||
|
sp.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 5.3: Image persistence - save image when SaveImage=true
|
||||||
|
if (sp.SaveImage && currentSourceImage != null)
|
||||||
|
{
|
||||||
|
var imageBytes = EncodeBitmapToBmp(currentSourceImage);
|
||||||
|
var saveResult = await _imagePersistenceService.SaveImageAsync(
|
||||||
|
imageBytes, sp.Name, program.Name, linkedCts.Token);
|
||||||
|
|
||||||
|
if (saveResult.Success)
|
||||||
|
{
|
||||||
|
_logger.ForModule<CncExecutionService>().Info(
|
||||||
|
"Image saved for node '{0}': Path={1}, Size={2} bytes",
|
||||||
|
sp.Name, saveResult.FilePath, saveResult.FileSizeBytes);
|
||||||
|
}
|
||||||
|
// Note: saveResult.Success == false will be handled by Task 5.4's error tolerance logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 5.3: Check if the next node (by Index order) is an InspectionModuleNode
|
||||||
|
// and pass the acquired image as source to the pipeline execution
|
||||||
|
if (currentSourceImage != null)
|
||||||
|
{
|
||||||
|
var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList();
|
||||||
|
int currentNodeOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id);
|
||||||
|
if (currentNodeOrderIndex >= 0 && currentNodeOrderIndex + 1 < allNodesOrdered.Count)
|
||||||
|
{
|
||||||
|
var nextNode = allNodesOrdered[currentNodeOrderIndex + 1];
|
||||||
|
if (nextNode is InspectionModuleNode inspectionNode)
|
||||||
|
{
|
||||||
|
_logger.ForModule<CncExecutionService>().Info(
|
||||||
|
"Passing captured image from node '{0}' to inspection module '{1}'",
|
||||||
|
sp.Name, inspectionNode.Name);
|
||||||
|
|
||||||
|
var resultImage = await ExecuteInspectionNodeAsync(
|
||||||
|
runId, inspectionNode, currentSourceImage, linkedCts.Token);
|
||||||
|
if (resultImage != null)
|
||||||
|
lastResultImage = resultImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO (Task 5.4): Implement error tolerance and failure-continue logic
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.ForModule<CncExecutionService>().Error(ex,
|
||||||
|
"Unexpected error executing save-position node '{0}' at position {1}/{2}",
|
||||||
|
sp.Name, positionIndex + 1, totalPositions);
|
||||||
|
nodeSucceeded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed;
|
||||||
|
progress?.Report(new CncNodeExecutionProgress(sp.Id, finalState));
|
||||||
|
|
||||||
|
if (!nodeSucceeded)
|
||||||
|
allSucceeded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remaining non-SavePosition nodes in order
|
||||||
foreach (var node in program.Nodes.OrderBy(n => n.Index))
|
foreach (var node in program.Nodes.OrderBy(n => n.Index))
|
||||||
{
|
{
|
||||||
if (linkedCts.Token.IsCancellationRequested)
|
if (linkedCts.Token.IsCancellationRequested)
|
||||||
@@ -129,6 +306,10 @@ namespace XplorePlane.Services.Cnc
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip SavePositionNodes - already processed in multi-position loop above
|
||||||
|
if (node is SavePositionNode)
|
||||||
|
continue;
|
||||||
|
|
||||||
progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running, ProgressPercent: 0));
|
progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running, ProgressPercent: 0));
|
||||||
|
|
||||||
bool nodeSucceeded = true;
|
bool nodeSucceeded = true;
|
||||||
@@ -151,36 +332,6 @@ namespace XplorePlane.Services.Cnc
|
|||||||
rp.IsRayOn, rp.Voltage, rp.Current);
|
rp.IsRayOn, rp.Voltage, rp.Current);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SavePositionNode sp:
|
|
||||||
_logger.ForModule<CncExecutionService>().Info(
|
|
||||||
"Executing save-position node [{Index}] {Name} | " +
|
|
||||||
"StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
|
|
||||||
"DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
|
|
||||||
"StageRotation={StageRotation} FixtureRotation={FixtureRotation} SaveImage={SaveImage}",
|
|
||||||
sp.Index, sp.Name,
|
|
||||||
sp.MotionState.StageX, sp.MotionState.StageY,
|
|
||||||
sp.MotionState.SourceZ, sp.MotionState.DetectorZ,
|
|
||||||
sp.MotionState.DetectorSwing, sp.MotionState.FDD,
|
|
||||||
sp.MotionState.FOD, sp.MotionState.Magnification,
|
|
||||||
sp.MotionState.StageRotation, sp.MotionState.FixtureRotation,
|
|
||||||
sp.SaveImage);
|
|
||||||
|
|
||||||
if (sp.SaveImage)
|
|
||||||
{
|
|
||||||
var capturedImage = TryGetSourceImage();
|
|
||||||
if (capturedImage != null)
|
|
||||||
{
|
|
||||||
currentSourceImage = capturedImage;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.ForModule<CncExecutionService>().Warn(
|
|
||||||
"Save-position node '{0}' requested image capture, but no current image was available.",
|
|
||||||
sp.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SaveNodeNode sn:
|
case SaveNodeNode sn:
|
||||||
_logger.ForModule<CncExecutionService>().Info(
|
_logger.ForModule<CncExecutionService>().Info(
|
||||||
"Executing save node [{Index}] {Name} | " +
|
"Executing save node [{Index}] {Name} | " +
|
||||||
@@ -317,6 +468,27 @@ namespace XplorePlane.Services.Cnc
|
|||||||
return bitmap;
|
return bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a BitmapSource from a local file path. Returns null if loading fails.
|
||||||
|
/// </summary>
|
||||||
|
private static BitmapSource LoadImageFromFile(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bitmap = new BitmapImage();
|
||||||
|
bitmap.BeginInit();
|
||||||
|
bitmap.UriSource = new Uri(filePath, UriKind.Absolute);
|
||||||
|
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
|
bitmap.EndInit();
|
||||||
|
bitmap.Freeze();
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<BitmapSource> ExecuteInspectionNodeAsync(
|
private async Task<BitmapSource> ExecuteInspectionNodeAsync(
|
||||||
Guid runId,
|
Guid runId,
|
||||||
InspectionModuleNode inspectionNode,
|
InspectionModuleNode inspectionNode,
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ namespace XplorePlane.Services.Cnc
|
|||||||
/// Progress report for a single CNC node execution.
|
/// Progress report for a single CNC node execution.
|
||||||
/// ResultImage is non-null when an InspectionModuleNode produces output.
|
/// ResultImage is non-null when an InspectionModuleNode produces output.
|
||||||
/// ProgressPercent is used by long-running nodes such as WaitDelayNode.
|
/// ProgressPercent is used by long-running nodes such as WaitDelayNode.
|
||||||
|
/// PositionIndex and TotalPositions are used for multi-position execution progress.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CncNodeExecutionProgress(
|
public record CncNodeExecutionProgress(
|
||||||
Guid NodeId,
|
Guid NodeId,
|
||||||
NodeExecutionState State,
|
NodeExecutionState State,
|
||||||
BitmapSource ResultImage = null,
|
BitmapSource ResultImage = null,
|
||||||
double? ProgressPercent = null);
|
double? ProgressPercent = null,
|
||||||
|
int? PositionIndex = null,
|
||||||
|
int? TotalPositions = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.Cnc
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 图像持久化服务接口,负责将采集图像保存到 Batch_Capture_Directory。
|
||||||
|
/// | Image persistence service interface for saving captured images to the batch capture directory.
|
||||||
|
/// </summary>
|
||||||
|
public interface IImagePersistenceService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将图像保存到批量采集目录。
|
||||||
|
/// | Save an image to the batch capture directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="imageData">BMP 编码的图像字节数组</param>
|
||||||
|
/// <param name="nodeName">节点名称(用于文件命名)</param>
|
||||||
|
/// <param name="programName">CNC 程序名称(用于目录结构)</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>保存结果,包含成功状态、文件路径和大小</returns>
|
||||||
|
Task<ImageSaveResult> SaveImageAsync(
|
||||||
|
byte[] imageData,
|
||||||
|
string nodeName,
|
||||||
|
string programName,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前批次的采集目录路径。
|
||||||
|
/// | Get the batch capture directory path for the given program.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="programName">CNC 程序名称</param>
|
||||||
|
/// <returns>批量采集目录的完整路径</returns>
|
||||||
|
string GetBatchCaptureDirectory(string programName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将运行摘要写入 summary.json。
|
||||||
|
/// | Write the batch run summary to summary.json.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="summary">批量采集结果摘要</param>
|
||||||
|
/// <param name="programName">CNC 程序名称</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌</param>
|
||||||
|
/// <returns>写入成功返回 true,失败返回 false</returns>
|
||||||
|
Task<bool> WriteSummaryAsync(
|
||||||
|
BatchCaptureResult summary,
|
||||||
|
string programName,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using XP.Common.Logging.Interfaces;
|
||||||
|
using XplorePlane.Helpers;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services.Storage;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.Cnc
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 图像持久化服务实现,负责将采集图像保存到 Batch_Capture_Directory。
|
||||||
|
/// | Image persistence service implementation for saving captured images to the batch capture directory.
|
||||||
|
/// </summary>
|
||||||
|
public class ImagePersistenceService : IImagePersistenceService
|
||||||
|
{
|
||||||
|
private readonly IXpDataPathService _dataPathService;
|
||||||
|
private readonly ILoggerService _logger;
|
||||||
|
private static readonly TimeSpan WriteTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public ImagePersistenceService(
|
||||||
|
IXpDataPathService dataPathService,
|
||||||
|
ILoggerService logger)
|
||||||
|
{
|
||||||
|
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetBatchCaptureDirectory(string programName)
|
||||||
|
{
|
||||||
|
var dataPath = _dataPathService.DataPath;
|
||||||
|
var datePart = DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
|
return Path.Combine(dataPath, "CapturedImages", datePart, programName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ImageSaveResult> SaveImageAsync(
|
||||||
|
byte[] imageData,
|
||||||
|
string nodeName,
|
||||||
|
string programName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = GetBatchCaptureDirectory(programName);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
var sanitizedName = FileNameSanitizer.Sanitize(nodeName);
|
||||||
|
var timestamp = DateTime.Now.ToString("HHmmss_fff");
|
||||||
|
var fileName = $"{sanitizedName}_{timestamp}.bmp";
|
||||||
|
var filePath = Path.Combine(directory, fileName);
|
||||||
|
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(WriteTimeout);
|
||||||
|
|
||||||
|
await File.WriteAllBytesAsync(filePath, imageData, timeoutCts.Token);
|
||||||
|
|
||||||
|
var fileSize = new FileInfo(filePath).Length;
|
||||||
|
|
||||||
|
_logger.ForModule<ImagePersistenceService>().Info(
|
||||||
|
"Image saved: {0} ({1} bytes)", filePath, fileSize);
|
||||||
|
|
||||||
|
return new ImageSaveResult(true, filePath, fileSize);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Timeout occurred (not external cancellation)
|
||||||
|
var errorMsg = $"Image save timed out after {WriteTimeout.TotalSeconds}s for node '{nodeName}'";
|
||||||
|
_logger.ForModule<ImagePersistenceService>().Error(null, errorMsg);
|
||||||
|
return new ImageSaveResult(false, null, 0, errorMsg);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// External cancellation
|
||||||
|
var errorMsg = $"Image save cancelled for node '{nodeName}'";
|
||||||
|
_logger.ForModule<ImagePersistenceService>().Warn(errorMsg);
|
||||||
|
return new ImageSaveResult(false, null, 0, errorMsg);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var errorMsg = $"Failed to save image for node '{nodeName}': {ex.Message}";
|
||||||
|
_logger.ForModule<ImagePersistenceService>().Error(ex,
|
||||||
|
"Failed to save image for node '{0}'", nodeName);
|
||||||
|
return new ImageSaveResult(false, null, 0, errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> WriteSummaryAsync(
|
||||||
|
BatchCaptureResult summary,
|
||||||
|
string programName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = GetBatchCaptureDirectory(programName);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
var filePath = Path.Combine(directory, "summary.json");
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(summary, options);
|
||||||
|
await File.WriteAllTextAsync(filePath, json, cancellationToken);
|
||||||
|
|
||||||
|
_logger.ForModule<ImagePersistenceService>().Info(
|
||||||
|
"Summary written to: {0}", filePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.ForModule<ImagePersistenceService>().Error(ex,
|
||||||
|
"Failed to write summary for program '{0}'", programName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using Microsoft.Win32;
|
||||||
|
using Prism.Commands;
|
||||||
using Prism.Mvvm;
|
using Prism.Mvvm;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
using XplorePlane.Models;
|
using XplorePlane.Models;
|
||||||
|
|
||||||
@@ -361,6 +364,35 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string ManualImagePath
|
||||||
|
{
|
||||||
|
get => _model is SavePositionNode sp ? sp.ManualImagePath : string.Empty;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_model is SavePositionNode sp)
|
||||||
|
{
|
||||||
|
UpdateModel(sp with { ManualImagePath = value ?? string.Empty });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand BrowseImageCommand => _browseImageCommand ??= new DelegateCommand(ExecuteBrowseImage);
|
||||||
|
private DelegateCommand _browseImageCommand;
|
||||||
|
|
||||||
|
private void ExecuteBrowseImage()
|
||||||
|
{
|
||||||
|
var dialog = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "选择图像文件",
|
||||||
|
Filter = "Image Files|*.bmp;*.png;*.tiff;*.tif|BMP Files|*.bmp|PNG Files|*.png|TIFF Files|*.tiff;*.tif"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
ManualImagePath = dialog.FileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string PipelineName
|
public string PipelineName
|
||||||
{
|
{
|
||||||
get => _model is InspectionModuleNode im ? im.Pipeline?.Name ?? string.Empty : string.Empty;
|
get => _model is InspectionModuleNode im ? im.Pipeline?.Name ?? string.Empty : string.Empty;
|
||||||
@@ -649,6 +681,7 @@ namespace XplorePlane.ViewModels.Cnc
|
|||||||
RaisePropertyChanged(nameof(Resolution));
|
RaisePropertyChanged(nameof(Resolution));
|
||||||
RaisePropertyChanged(nameof(ImageFileName));
|
RaisePropertyChanged(nameof(ImageFileName));
|
||||||
RaisePropertyChanged(nameof(SaveImage));
|
RaisePropertyChanged(nameof(SaveImage));
|
||||||
|
RaisePropertyChanged(nameof(ManualImagePath));
|
||||||
RaisePropertyChanged(nameof(Pipeline));
|
RaisePropertyChanged(nameof(Pipeline));
|
||||||
RaisePropertyChanged(nameof(PipelineName));
|
RaisePropertyChanged(nameof(PipelineName));
|
||||||
RaisePropertyChanged(nameof(MarkerType));
|
RaisePropertyChanged(nameof(MarkerType));
|
||||||
|
|||||||
Reference in New Issue
Block a user