#0039 全局数据结构设计

This commit is contained in:
zhengxuan.zhang
2026-03-18 20:14:08 +08:00
parent c6144fae89
commit 67898edc3f
19 changed files with 1490 additions and 16 deletions
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FsCheck;
using XplorePlane.Models;
namespace XplorePlane.Tests.Generators
{
/// <summary>
/// FsCheck 自定义生成器:为配方相关模型定义 Arbitrary 生成器。
/// 使用 Arb.Register&lt;RecipeGenerators&gt;() 注册。
/// </summary>
public class RecipeGenerators
{
// ── PipelineModel: class with settable properties ──
public static Arbitrary<PipelineModel> PipelineModelArb()
{
var gen = from id in Arb.Default.Guid().Generator
from name in Arb.Default.NonEmptyString().Generator
from deviceId in Arb.Default.NonEmptyString().Generator
from ticks in Gen.Choose(0, int.MaxValue)
let createdAt = DateTime.MinValue.AddTicks((long)ticks * 10000)
let updatedAt = createdAt
select new PipelineModel
{
Id = id,
Name = name.Get,
DeviceId = deviceId.Get,
CreatedAt = createdAt,
UpdatedAt = updatedAt,
Nodes = new List<PipelineNodeModel>()
};
return Arb.From(gen);
}
// ── RecipeStep: reuses StateGenerators for hardware states ──
public static Arbitrary<RecipeStep> RecipeStepArb()
{
var gen = from stepIndex in Gen.Choose(0, 100)
from motion in StateGenerators.MotionStateArb().Generator
from ray in StateGenerators.RaySourceStateArb().Generator
from detector in StateGenerators.DetectorStateArb().Generator
from pipeline in PipelineModelArb().Generator
select new RecipeStep(stepIndex, motion, ray, detector, pipeline);
return Arb.From(gen);
}
// ── InspectionRecipe: 0-10 steps with sequential StepIndex ──
public static Arbitrary<InspectionRecipe> InspectionRecipeArb()
{
var gen = from id in Arb.Default.Guid().Generator
from name in Arb.Default.NonEmptyString().Generator
from ticks in Gen.Choose(0, int.MaxValue)
let createdAt = DateTime.MinValue.AddTicks((long)ticks * 10000)
let updatedAt = createdAt
from stepCount in Gen.Choose(0, 10)
from steps in GenSequentialSteps(stepCount)
select new InspectionRecipe(
id,
name.Get,
createdAt,
updatedAt,
steps.AsReadOnly());
return Arb.From(gen);
}
// ── RecipeExecutionState ──
public static Arbitrary<RecipeExecutionState> RecipeExecutionStateArb()
{
var gen = from totalSteps in Gen.Choose(0, 50)
from currentStep in Gen.Choose(0, Math.Max(totalSteps, 1) - 1)
from status in Gen.Elements(
RecipeExecutionStatus.Idle,
RecipeExecutionStatus.Running,
RecipeExecutionStatus.Paused,
RecipeExecutionStatus.Completed,
RecipeExecutionStatus.Error)
from recipeName in Arb.Default.NonEmptyString().Generator
select new RecipeExecutionState(currentStep, totalSteps, status, recipeName.Get);
return Arb.From(gen);
}
// ── Helper: generate a list of steps with sequential StepIndex 0..n-1 ──
private static Gen<List<RecipeStep>> GenSequentialSteps(int count)
{
if (count == 0)
return Gen.Constant(new List<RecipeStep>());
return Gen.Sequence(
Enumerable.Range(0, count).Select(i =>
from motion in StateGenerators.MotionStateArb().Generator
from ray in StateGenerators.RaySourceStateArb().Generator
from detector in StateGenerators.DetectorStateArb().Generator
from pipeline in PipelineModelArb().Generator
select new RecipeStep(i, motion, ray, detector, pipeline)
)
).Select(steps => steps.ToList());
}
}
}
@@ -0,0 +1,146 @@
using System;
using FsCheck;
using XplorePlane.Models;
namespace XplorePlane.Tests.Generators
{
/// <summary>
/// FsCheck 自定义生成器:为所有状态模型定义 Arbitrary 生成器。
/// 使用 Arb.Register&lt;StateGenerators&gt;() 注册。
/// </summary>
public class StateGenerators
{
// ── 位置范围: -10000 ~ 10000, 速度范围: 0 ~ 1000 ──
private static Gen<double> PositionGen =>
Gen.Choose(-10000000, 10000000).Select(i => i / 1000.0);
private static Gen<double> SpeedGen =>
Gen.Choose(0, 1000000).Select(i => i / 1000.0);
private static Gen<double> VoltageGen =>
Gen.Choose(0, 450000).Select(i => i / 1000.0);
private static Gen<double> PowerGen =>
Gen.Choose(0, 3000000).Select(i => i / 1000.0);
private static Gen<double> FrameRateGen =>
Gen.Choose(0, 120000).Select(i => i / 1000.0);
private static Gen<double> MatrixGen =>
Gen.Choose(-10000000, 10000000).Select(i => i / 1000.0);
// ── MotionState: 6 positions + 6 speeds ──
public static Arbitrary<MotionState> MotionStateArb()
{
var gen = from xm in PositionGen
from ym in PositionGen
from zt in PositionGen
from zd in PositionGen
from tiltD in PositionGen
from dist in PositionGen
from xmSpd in SpeedGen
from ymSpd in SpeedGen
from ztSpd in SpeedGen
from zdSpd in SpeedGen
from tiltDSpd in SpeedGen
from distSpd in SpeedGen
select new MotionState(xm, ym, zt, zd, tiltD, dist,
xmSpd, ymSpd, ztSpd, zdSpd, tiltDSpd, distSpd);
return Arb.From(gen);
}
// ── RaySourceState: bool + 2 doubles ──
public static Arbitrary<RaySourceState> RaySourceStateArb()
{
var gen = from isOn in Arb.Default.Bool().Generator
from voltage in VoltageGen
from power in PowerGen
select new RaySourceState(isOn, voltage, power);
return Arb.From(gen);
}
// ── DetectorState: 2 bools + double + string ──
public static Arbitrary<DetectorState> DetectorStateArb()
{
var gen = from isConnected in Arb.Default.Bool().Generator
from isAcquiring in Arb.Default.Bool().Generator
from frameRate in FrameRateGen
from res in Gen.Elements("1024x1024", "2048x2048", "2880x2880", "3072x3072", "4260x4260")
select new DetectorState(isConnected, isAcquiring, frameRate, res);
return Arb.From(gen);
}
// ── SystemState: OperationMode enum + bool + string ──
public static Arbitrary<SystemState> SystemStateArb()
{
var gen = from mode in Gen.Elements(
OperationMode.Idle,
OperationMode.Scanning,
OperationMode.CTAcquire,
OperationMode.RecipeRun)
from hasError in Arb.Default.Bool().Generator
from msg in Arb.Default.NonEmptyString().Generator
let errorMessage = hasError ? msg.Get : string.Empty
select new SystemState(mode, hasError, errorMessage);
return Arb.From(gen);
}
// ── CameraState: 2 bools + null CurrentFrame + 2 ints + double ──
public static Arbitrary<CameraState> CameraStateArb()
{
var gen = from isConnected in Arb.Default.Bool().Generator
from isStreaming in Arb.Default.Bool().Generator
from width in Gen.Choose(0, 8192)
from height in Gen.Choose(0, 8192)
from frameRate in FrameRateGen
select new CameraState(isConnected, isStreaming, null, width, height, frameRate);
return Arb.From(gen);
}
// ── CalibrationMatrix: 9 doubles ──
public static Arbitrary<CalibrationMatrix> CalibrationMatrixArb()
{
var gen = from m11 in MatrixGen
from m12 in MatrixGen
from m13 in MatrixGen
from m21 in MatrixGen
from m22 in MatrixGen
from m23 in MatrixGen
from m31 in MatrixGen
from m32 in MatrixGen
from m33 in MatrixGen
select new CalibrationMatrix(m11, m12, m13, m21, m22, m23, m31, m32, m33);
return Arb.From(gen);
}
// ── PhysicalPosition: 3 doubles ──
public static Arbitrary<PhysicalPosition> PhysicalPositionArb()
{
var gen = from x in PositionGen
from y in PositionGen
from z in PositionGen
select new PhysicalPosition(x, y, z);
return Arb.From(gen);
}
// ── LinkedViewState: PhysicalPosition + bool + DateTime ──
public static Arbitrary<LinkedViewState> LinkedViewStateArb()
{
var gen = from pos in PhysicalPositionArb().Generator
from isExecuting in Arb.Default.Bool().Generator
from ticks in Gen.Choose(0, int.MaxValue)
let dt = DateTime.MinValue.AddTicks((long)ticks * 10000)
select new LinkedViewState(pos, isExecuting, dt);
return Arb.From(gen);
}
}
}
@@ -0,0 +1,153 @@
using System;
using Xunit;
using XplorePlane.Models;
namespace XplorePlane.Tests.Models
{
public class StateModelsTests
{
// ── Default Value Tests ───────────────────────────────────────
[Fact]
public void MotionState_Default_AllZeros()
{
var state = MotionState.Default;
Assert.Equal(0, state.XM);
Assert.Equal(0, state.YM);
Assert.Equal(0, state.ZT);
Assert.Equal(0, state.ZD);
Assert.Equal(0, state.TiltD);
Assert.Equal(0, state.Dist);
Assert.Equal(0, state.XMSpeed);
Assert.Equal(0, state.YMSpeed);
Assert.Equal(0, state.ZTSpeed);
Assert.Equal(0, state.ZDSpeed);
Assert.Equal(0, state.TiltDSpeed);
Assert.Equal(0, state.DistSpeed);
}
[Fact]
public void RaySourceState_Default_IsOffAndZeros()
{
var state = RaySourceState.Default;
Assert.False(state.IsOn);
Assert.Equal(0, state.Voltage);
Assert.Equal(0, state.Power);
}
[Fact]
public void DetectorState_Default_DisconnectedAndZeros()
{
var state = DetectorState.Default;
Assert.False(state.IsConnected);
Assert.False(state.IsAcquiring);
Assert.Equal(0, state.FrameRate);
Assert.Equal(string.Empty, state.Resolution);
}
[Fact]
public void SystemState_Default_IdleNoError()
{
var state = SystemState.Default;
Assert.Equal(OperationMode.Idle, state.OperationMode);
Assert.False(state.HasError);
Assert.Equal(string.Empty, state.ErrorMessage);
}
[Fact]
public void CameraState_Default_DisconnectedAndZeros()
{
var state = CameraState.Default;
Assert.False(state.IsConnected);
Assert.False(state.IsStreaming);
Assert.Null(state.CurrentFrame);
Assert.Equal(0, state.Width);
Assert.Equal(0, state.Height);
Assert.Equal(0, state.FrameRate);
}
[Fact]
public void LinkedViewState_Default_ZeroPositionNotExecuting()
{
var state = LinkedViewState.Default;
Assert.Equal(0, state.TargetPosition.X);
Assert.Equal(0, state.TargetPosition.Y);
Assert.Equal(0, state.TargetPosition.Z);
Assert.False(state.IsExecuting);
Assert.Equal(DateTime.MinValue, state.LastRequestTime);
}
[Fact]
public void RecipeExecutionState_Default_IdleAndZeros()
{
var state = RecipeExecutionState.Default;
Assert.Equal(0, state.CurrentStepIndex);
Assert.Equal(0, state.TotalSteps);
Assert.Equal(RecipeExecutionStatus.Idle, state.Status);
Assert.Equal(string.Empty, state.CurrentRecipeName);
}
// ── Immutability Test ─────────────────────────────────────────
[Fact]
public void MotionState_WithExpression_ProducesNewInstance()
{
var original = MotionState.Default;
var modified = original with { XM = 100 };
// New instance is different from original
Assert.NotSame(original, modified);
Assert.Equal(100, modified.XM);
// Original is unchanged
Assert.Equal(0, original.XM);
}
// ── CalibrationMatrix Transform Tests ─────────────────────────
[Fact]
public void CalibrationMatrix_Transform_CorrectCalculation()
{
// Known matrix: scaling X by 2, Y by 3, with offsets
var matrix = new CalibrationMatrix(
M11: 2, M12: 0, M13: 10,
M21: 0, M22: 3, M23: 20,
M31: 0, M32: 0, M33: 0
);
var (x, y, z) = matrix.Transform(pixelX: 5, pixelY: 8);
// x = 2*5 + 0*8 + 10 = 20
Assert.Equal(20, x);
// y = 0*5 + 3*8 + 20 = 44
Assert.Equal(44, y);
// z = 0*5 + 0*8 + 0 = 0
Assert.Equal(0, z);
}
[Fact]
public void CalibrationMatrix_Transform_IdentityMatrix()
{
// Identity-like matrix: should return (pixelX, pixelY, 0) approximately
var identity = new CalibrationMatrix(
M11: 1, M12: 0, M13: 0,
M21: 0, M22: 1, M23: 0,
M31: 0, M32: 0, M33: 0
);
var (x, y, z) = identity.Transform(pixelX: 42.5, pixelY: 99.1);
Assert.Equal(42.5, x, precision: 10);
Assert.Equal(99.1, y, precision: 10);
Assert.Equal(0, z, precision: 10);
}
}
}
@@ -0,0 +1,121 @@
using System;
using System.Windows;
using Moq;
using Serilog;
using Xunit;
using XP.Hardware.RaySource.Services;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// AppStateService 单元测试。
/// 验证默认状态值、Dispose 后行为、null 参数校验、CalibrationMatrix 缺失时的错误处理。
/// </summary>
public class AppStateServiceTests : IDisposable
{
private readonly AppStateService _service;
private readonly Mock<IRaySourceService> _mockRaySource;
private readonly Mock<ILogger> _mockLogger;
public AppStateServiceTests()
{
// Ensure WPF Application exists for Dispatcher
if (Application.Current == null)
{
new Application();
}
_mockRaySource = new Mock<IRaySourceService>();
_mockLogger = new Mock<ILogger>();
_service = new AppStateService(_mockRaySource.Object, _mockLogger.Object);
}
public void Dispose()
{
_service.Dispose();
}
// ── 默认状态值验证 ──
[Fact]
public void DefaultState_MotionState_IsDefault()
{
Assert.Same(MotionState.Default, _service.MotionState);
}
[Fact]
public void DefaultState_RaySourceState_IsDefault()
{
Assert.Same(RaySourceState.Default, _service.RaySourceState);
}
[Fact]
public void DefaultState_SystemState_IsDefault()
{
Assert.Same(SystemState.Default, _service.SystemState);
}
[Fact]
public void DefaultState_CalibrationMatrix_IsNull()
{
Assert.Null(_service.CalibrationMatrix);
}
// ── null 参数抛出 ArgumentNullException ──
[Fact]
public void UpdateMotionState_NullArgument_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _service.UpdateMotionState(null!));
}
[Fact]
public void UpdateRaySourceState_NullArgument_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _service.UpdateRaySourceState(null!));
}
[Fact]
public void UpdateDetectorState_NullArgument_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _service.UpdateDetectorState(null!));
}
[Fact]
public void UpdateSystemState_NullArgument_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _service.UpdateSystemState(null!));
}
// ── Dispose 后 Update 被忽略 ──
[Fact]
public void Dispose_ThenUpdate_IsIgnored()
{
var originalState = _service.MotionState;
_service.Dispose();
// Should not throw, and state should remain unchanged
var newState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
_service.UpdateMotionState(newState);
Assert.Same(originalState, _service.MotionState);
}
// ── CalibrationMatrix 为 null 时 RequestLinkedView 设置错误状态 ──
[Fact]
public void RequestLinkedView_NoCalibrationMatrix_SetsErrorState()
{
// CalibrationMatrix is null by default
Assert.Null(_service.CalibrationMatrix);
_service.RequestLinkedView(100.0, 200.0);
Assert.True(_service.SystemState.HasError);
Assert.NotEmpty(_service.SystemState.ErrorMessage);
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Moq;
using Serilog;
using Xunit;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Recipe;
namespace XplorePlane.Tests.Services
{
/// <summary>
/// RecipeService 单元测试。
/// 验证配方创建、步骤记录、文件加载错误处理和名称校验。
/// </summary>
public class RecipeServiceTests
{
private readonly Mock<IAppStateService> _mockAppState;
private readonly Mock<IPipelineExecutionService> _mockPipeline;
private readonly Mock<ILogger> _mockLogger;
private readonly RecipeService _service;
public RecipeServiceTests()
{
_mockAppState = new Mock<IAppStateService>();
_mockPipeline = new Mock<IPipelineExecutionService>();
_mockLogger = new Mock<ILogger>();
// Setup default state returns
_mockAppState.Setup(s => s.MotionState).Returns(MotionState.Default);
_mockAppState.Setup(s => s.RaySourceState).Returns(RaySourceState.Default);
_mockAppState.Setup(s => s.DetectorState).Returns(DetectorState.Default);
_service = new RecipeService(
_mockAppState.Object,
_mockPipeline.Object,
_mockLogger.Object);
}
// ── CreateRecipe 验证 ──
[Fact]
public void CreateRecipe_ValidName_ReturnsEmptyRecipe()
{
var recipe = _service.CreateRecipe("TestRecipe");
Assert.Equal("TestRecipe", recipe.Name);
Assert.Empty(recipe.Steps);
Assert.NotEqual(Guid.Empty, recipe.Id);
Assert.True(recipe.CreatedAt <= DateTime.UtcNow);
Assert.True(recipe.UpdatedAt <= DateTime.UtcNow);
}
[Fact]
public void CreateRecipe_EmptyName_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => _service.CreateRecipe(string.Empty));
}
[Fact]
public void CreateRecipe_NullName_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => _service.CreateRecipe(null!));
}
[Fact]
public void CreateRecipe_WhitespaceName_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => _service.CreateRecipe(" "));
}
// ── RecordCurrentStep 验证 ──
[Fact]
public void RecordCurrentStep_CapturesCurrentState()
{
var motionState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
var raySourceState = new RaySourceState(true, 120.0, 80.0);
var detectorState = new DetectorState(true, true, 30.0, "2048x2048");
_mockAppState.Setup(s => s.MotionState).Returns(motionState);
_mockAppState.Setup(s => s.RaySourceState).Returns(raySourceState);
_mockAppState.Setup(s => s.DetectorState).Returns(detectorState);
var recipe = _service.CreateRecipe("TestRecipe");
var pipeline = new PipelineModel { Name = "TestPipeline" };
var step = _service.RecordCurrentStep(recipe, pipeline);
Assert.Equal(0, step.StepIndex);
Assert.Same(motionState, step.MotionState);
Assert.Same(raySourceState, step.RaySourceState);
Assert.Same(detectorState, step.DetectorState);
Assert.Same(pipeline, step.Pipeline);
}
// ── LoadAsync 错误处理 ──
[Fact]
public async Task LoadAsync_FileNotExists_ThrowsFileNotFoundException()
{
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".json");
await Assert.ThrowsAsync<FileNotFoundException>(
() => _service.LoadAsync(nonExistentPath));
}
[Fact]
public async Task LoadAsync_InvalidJson_ThrowsInvalidDataException()
{
var tempFile = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempFile, "{ this is not valid json !!! }");
await Assert.ThrowsAsync<InvalidDataException>(
() => _service.LoadAsync(tempFile));
}
finally
{
if (File.Exists(tempFile))
File.Delete(tempFile);
}
}
}
}
+15 -14
View File
@@ -1,33 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RootNamespace>XplorePlane.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<RootNamespace>XplorePlane.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FsCheck" Version="3.1.0" />
<PackageReference Include="FsCheck.Xunit" Version="3.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="FsCheck" Version="2.*" />
<PackageReference Include="FsCheck.Xunit" Version="2.*" />
<PackageReference Include="Moq" Version="4.*" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
</ItemGroup>
<!-- 直接引用主项目依赖的本地 DLL -->
<ItemGroup>
<Reference Include="ImageProcessing.Core">
<HintPath>..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll</HintPath>
<Reference Include="XP.Hardware.RaySource">
<HintPath>..\XplorePlane\Libs\Hardware\XP.Hardware.RaySource.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
</Project>