#0039 全局数据结构设计
This commit is contained in:
@@ -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<RecipeGenerators>() 注册。
|
||||||
|
/// </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<StateGenerators>() 注册。
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,34 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<RootNamespace>XplorePlane.Tests</RootNamespace>
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<Nullable>enable</Nullable>
|
<RootNamespace>XplorePlane.Tests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="xunit" Version="2.9.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
<PackageReference Include="xunit" Version="2.*" />
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PackageReference Include="FsCheck" Version="2.*" />
|
||||||
</PackageReference>
|
<PackageReference Include="FsCheck.Xunit" Version="2.*" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageReference Include="Moq" Version="4.*" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
</ItemGroup>
|
||||||
<PackageReference Include="FsCheck" Version="3.1.0" />
|
|
||||||
<PackageReference Include="FsCheck.Xunit" Version="3.1.0" />
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
|
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- 直接引用主项目依赖的本地 DLL -->
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="ImageProcessing.Core">
|
<Reference Include="XP.Hardware.RaySource">
|
||||||
<HintPath>..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll</HintPath>
|
<HintPath>..\XplorePlane\Libs\Hardware\XP.Hardware.RaySource.dll</HintPath>
|
||||||
<Private>True</Private>
|
<Private>True</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ using System.Windows;
|
|||||||
using XplorePlane.Views;
|
using XplorePlane.Views;
|
||||||
using XplorePlane.ViewModels;
|
using XplorePlane.ViewModels;
|
||||||
using XplorePlane.Services;
|
using XplorePlane.Services;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
using XplorePlane.Services.Recipe;
|
||||||
using Prism.Ioc;
|
using Prism.Ioc;
|
||||||
using Prism.DryIoc;
|
using Prism.DryIoc;
|
||||||
using Prism.Modularity;
|
using Prism.Modularity;
|
||||||
@@ -88,6 +90,22 @@ namespace XplorePlane
|
|||||||
Log.Information("XplorePlane 应用程序退出");
|
Log.Information("XplorePlane 应用程序退出");
|
||||||
Log.Information("========================================");
|
Log.Information("========================================");
|
||||||
|
|
||||||
|
// 释放全局状态服务资源
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bootstrapper = AppBootstrapper.Instance;
|
||||||
|
if (bootstrapper != null)
|
||||||
|
{
|
||||||
|
var appStateService = bootstrapper.Container.Resolve<IAppStateService>();
|
||||||
|
appStateService?.Dispose();
|
||||||
|
Log.Information("全局状态服务资源已释放");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "全局状态服务资源释放失败");
|
||||||
|
}
|
||||||
|
|
||||||
// 释放射线源资源
|
// 释放射线源资源
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -179,6 +197,12 @@ namespace XplorePlane
|
|||||||
containerRegistry.RegisterSingleton<IPipelineExecutionService, PipelineExecutionService>();
|
containerRegistry.RegisterSingleton<IPipelineExecutionService, PipelineExecutionService>();
|
||||||
containerRegistry.RegisterSingleton<IPipelinePersistenceService, PipelinePersistenceService>();
|
containerRegistry.RegisterSingleton<IPipelinePersistenceService, PipelinePersistenceService>();
|
||||||
|
|
||||||
|
// 注册全局状态服务(单例)
|
||||||
|
containerRegistry.RegisterSingleton<IAppStateService, AppStateService>();
|
||||||
|
|
||||||
|
// 注册检测配方服务(单例)
|
||||||
|
containerRegistry.RegisterSingleton<IRecipeService, RecipeService>();
|
||||||
|
|
||||||
// 注册流水线 ViewModel(每次解析创建新实例)
|
// 注册流水线 ViewModel(每次解析创建新实例)
|
||||||
containerRegistry.Register<PipelineEditorViewModel>();
|
containerRegistry.Register<PipelineEditorViewModel>();
|
||||||
containerRegistry.Register<OperatorToolboxViewModel>();
|
containerRegistry.Register<OperatorToolboxViewModel>();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 279 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace XplorePlane.Models
|
||||||
|
{
|
||||||
|
/// <summary>检测配方中的单个步骤</summary>
|
||||||
|
public record RecipeStep(
|
||||||
|
int StepIndex,
|
||||||
|
MotionState MotionState,
|
||||||
|
RaySourceState RaySourceState,
|
||||||
|
DetectorState DetectorState,
|
||||||
|
PipelineModel Pipeline
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>检测配方(CNC 自动编程)</summary>
|
||||||
|
public record InspectionRecipe(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt,
|
||||||
|
IReadOnlyList<RecipeStep> Steps
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <summary>追加步骤,返回新的 InspectionRecipe</summary>
|
||||||
|
public InspectionRecipe AddStep(RecipeStep step) =>
|
||||||
|
this with
|
||||||
|
{
|
||||||
|
Steps = Steps.Append(step).ToList().AsReadOnly(),
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>配方执行状态(不可变)</summary>
|
||||||
|
public record RecipeExecutionState(
|
||||||
|
int CurrentStepIndex,
|
||||||
|
int TotalSteps,
|
||||||
|
RecipeExecutionStatus Status,
|
||||||
|
string CurrentRecipeName
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly RecipeExecutionState Default = new(0, 0, RecipeExecutionStatus.Idle, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace XplorePlane.Models
|
||||||
|
{
|
||||||
|
// ── Enumerations ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>系统操作模式</summary>
|
||||||
|
public enum OperationMode
|
||||||
|
{
|
||||||
|
Idle, // 空闲
|
||||||
|
Scanning, // 扫描
|
||||||
|
CTAcquire, // CT 采集
|
||||||
|
RecipeRun // 配方执行中
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>配方执行状态</summary>
|
||||||
|
public enum RecipeExecutionStatus
|
||||||
|
{
|
||||||
|
Idle, // 空闲
|
||||||
|
Running, // 运行中
|
||||||
|
Paused, // 暂停
|
||||||
|
Completed, // 已完成
|
||||||
|
Error // 出错
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State Records ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>运动控制状态(不可变)</summary>
|
||||||
|
public record MotionState(
|
||||||
|
double XM, // X 轴位置 (μm)
|
||||||
|
double YM, // Y 轴位置 (μm)
|
||||||
|
double ZT, // Z 上轴位置 (μm)
|
||||||
|
double ZD, // Z 下轴位置 (μm)
|
||||||
|
double TiltD, // 倾斜角度 (m°)
|
||||||
|
double Dist, // 距离 (μm)
|
||||||
|
double XMSpeed, // X 轴速度 (μm/s)
|
||||||
|
double YMSpeed, // Y 轴速度 (μm/s)
|
||||||
|
double ZTSpeed, // Z 上轴速度 (μm/s)
|
||||||
|
double ZDSpeed, // Z 下轴速度 (μm/s)
|
||||||
|
double TiltDSpeed, // 倾斜速度 (m°/s)
|
||||||
|
double DistSpeed // 距离速度 (μm/s)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly MotionState Default = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>射线源状态(不可变)</summary>
|
||||||
|
public record RaySourceState(
|
||||||
|
bool IsOn, // 开关状态
|
||||||
|
double Voltage, // 电压 (kV)
|
||||||
|
double Power // 功率 (W)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly RaySourceState Default = new(false, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>探测器状态(不可变)</summary>
|
||||||
|
public record DetectorState(
|
||||||
|
bool IsConnected, // 连接状态
|
||||||
|
bool IsAcquiring, // 是否正在采集
|
||||||
|
double FrameRate, // 当前帧率 (fps)
|
||||||
|
string Resolution // 分辨率描述,如 "2048x2048"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly DetectorState Default = new(false, false, 0, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>系统级状态(不可变)</summary>
|
||||||
|
public record SystemState(
|
||||||
|
OperationMode OperationMode, // 当前操作模式
|
||||||
|
bool HasError, // 是否存在系统错误
|
||||||
|
string ErrorMessage // 错误描述
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly SystemState Default = new(OperationMode.Idle, false, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>摄像头视频流状态(不可变)</summary>
|
||||||
|
public record CameraState(
|
||||||
|
bool IsConnected, // 连接状态
|
||||||
|
bool IsStreaming, // 是否正在推流
|
||||||
|
object CurrentFrame, // 当前帧数据引用(BitmapSource 或 byte[],Frozen)
|
||||||
|
int Width, // 分辨率宽
|
||||||
|
int Height, // 分辨率高
|
||||||
|
double FrameRate // 帧率 (fps)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly CameraState Default = new(false, false, null, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>物理坐标</summary>
|
||||||
|
public record PhysicalPosition(double X, double Y, double Z);
|
||||||
|
|
||||||
|
/// <summary>图像标定矩阵,像素坐标 → 物理坐标映射</summary>
|
||||||
|
public record CalibrationMatrix(
|
||||||
|
double M11, double M12, double M13, // 3x3 仿射变换矩阵
|
||||||
|
double M21, double M22, double M23,
|
||||||
|
double M31, double M32, double M33
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <summary>将像素坐标转换为物理坐标</summary>
|
||||||
|
public (double X, double Y, double Z) Transform(double pixelX, double pixelY)
|
||||||
|
{
|
||||||
|
double x = M11 * pixelX + M12 * pixelY + M13;
|
||||||
|
double y = M21 * pixelX + M22 * pixelY + M23;
|
||||||
|
double z = M31 * pixelX + M32 * pixelY + M33;
|
||||||
|
return (x, y, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>画面联动状态(不可变)</summary>
|
||||||
|
public record LinkedViewState(
|
||||||
|
PhysicalPosition TargetPosition, // 目标物理坐标
|
||||||
|
bool IsExecuting, // 联动是否正在执行
|
||||||
|
DateTime LastRequestTime // 最近一次联动请求时间
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static readonly LinkedViewState Default = new(new PhysicalPosition(0, 0, 0), false, DateTime.MinValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Prism.Mvvm;
|
||||||
|
using Serilog;
|
||||||
|
using XP.Hardware.RaySource.Services;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.AppState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 全局应用状态管理服务实现。
|
||||||
|
/// 继承 BindableBase 以支持 WPF 数据绑定,使用 Interlocked.Exchange 保证线程安全写入,
|
||||||
|
/// 通过 Dispatcher.BeginInvoke 将事件调度到 UI 线程。
|
||||||
|
/// </summary>
|
||||||
|
public class AppStateService : BindableBase, IAppStateService
|
||||||
|
{
|
||||||
|
private readonly Dispatcher _dispatcher;
|
||||||
|
private readonly IRaySourceService _raySourceService;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
// ── 状态字段(通过 Interlocked.Exchange 原子替换)──
|
||||||
|
private MotionState _motionState = MotionState.Default;
|
||||||
|
private RaySourceState _raySourceState = RaySourceState.Default;
|
||||||
|
private DetectorState _detectorState = DetectorState.Default;
|
||||||
|
private SystemState _systemState = SystemState.Default;
|
||||||
|
private CameraState _cameraState = CameraState.Default;
|
||||||
|
private CalibrationMatrix _calibrationMatrix;
|
||||||
|
private LinkedViewState _linkedViewState = LinkedViewState.Default;
|
||||||
|
private RecipeExecutionState _recipeExecutionState = RecipeExecutionState.Default;
|
||||||
|
|
||||||
|
// ── 类型化状态变更事件 ──
|
||||||
|
public event EventHandler<StateChangedEventArgs<MotionState>> MotionStateChanged;
|
||||||
|
public event EventHandler<StateChangedEventArgs<RaySourceState>> RaySourceStateChanged;
|
||||||
|
public event EventHandler<StateChangedEventArgs<DetectorState>> DetectorStateChanged;
|
||||||
|
public event EventHandler<StateChangedEventArgs<SystemState>> SystemStateChanged;
|
||||||
|
public event EventHandler<StateChangedEventArgs<CameraState>> CameraStateChanged;
|
||||||
|
public event EventHandler<StateChangedEventArgs<LinkedViewState>> LinkedViewStateChanged;
|
||||||
|
public event EventHandler<StateChangedEventArgs<RecipeExecutionState>> RecipeExecutionStateChanged;
|
||||||
|
public event EventHandler<LinkedViewRequestEventArgs> LinkedViewRequested;
|
||||||
|
|
||||||
|
// ── 状态属性(只读)──
|
||||||
|
public MotionState MotionState => _motionState;
|
||||||
|
public RaySourceState RaySourceState => _raySourceState;
|
||||||
|
public DetectorState DetectorState => _detectorState;
|
||||||
|
public SystemState SystemState => _systemState;
|
||||||
|
public CameraState CameraState => _cameraState;
|
||||||
|
public CalibrationMatrix CalibrationMatrix => _calibrationMatrix;
|
||||||
|
public LinkedViewState LinkedViewState => _linkedViewState;
|
||||||
|
public RecipeExecutionState RecipeExecutionState => _recipeExecutionState;
|
||||||
|
|
||||||
|
public AppStateService(
|
||||||
|
IRaySourceService raySourceService,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(raySourceService);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
|
_raySourceService = raySourceService;
|
||||||
|
_logger = logger;
|
||||||
|
_dispatcher = Application.Current.Dispatcher;
|
||||||
|
|
||||||
|
SubscribeToExistingServices();
|
||||||
|
_logger.Information("AppStateService 已初始化");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 状态更新方法 ──
|
||||||
|
|
||||||
|
public void UpdateMotionState(MotionState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateMotionState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _motionState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateRaySourceState(RaySourceState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateRaySourceState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _raySourceState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, RaySourceStateChanged, nameof(RaySourceState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateDetectorState(DetectorState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateDetectorState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _detectorState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, DetectorStateChanged, nameof(DetectorState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateSystemState(SystemState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateSystemState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _systemState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, SystemStateChanged, nameof(SystemState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateCameraState(CameraState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateCameraState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _cameraState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, CameraStateChanged, nameof(CameraState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateCalibrationMatrix(CalibrationMatrix newMatrix)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newMatrix);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateCalibrationMatrix 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _calibrationMatrix, newMatrix);
|
||||||
|
if (ReferenceEquals(old, newMatrix)) return;
|
||||||
|
|
||||||
|
_dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
RaisePropertyChanged(nameof(CalibrationMatrix));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateLinkedViewState(LinkedViewState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateLinkedViewState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _linkedViewState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, LinkedViewStateChanged, nameof(LinkedViewState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateRecipeExecutionState(RecipeExecutionState newState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newState);
|
||||||
|
if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateRecipeExecutionState 调用"); return; }
|
||||||
|
|
||||||
|
var old = Interlocked.Exchange(ref _recipeExecutionState, newState);
|
||||||
|
if (ReferenceEquals(old, newState)) return;
|
||||||
|
RaiseOnDispatcher(old, newState, RecipeExecutionStateChanged, nameof(RecipeExecutionState));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 画面联动 ──
|
||||||
|
|
||||||
|
public void RequestLinkedView(double pixelX, double pixelY)
|
||||||
|
{
|
||||||
|
var matrix = _calibrationMatrix;
|
||||||
|
if (matrix is null)
|
||||||
|
{
|
||||||
|
_logger.Warning("CalibrationMatrix 未设置,无法执行画面联动 (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY);
|
||||||
|
UpdateSystemState(SystemState with
|
||||||
|
{
|
||||||
|
HasError = true,
|
||||||
|
ErrorMessage = "CalibrationMatrix 未设置,无法执行画面联动"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (x, y, z) = matrix.Transform(pixelX, pixelY);
|
||||||
|
var newLinkedState = new LinkedViewState(
|
||||||
|
new PhysicalPosition(x, y, z),
|
||||||
|
IsExecuting: true,
|
||||||
|
LastRequestTime: DateTime.UtcNow);
|
||||||
|
|
||||||
|
UpdateLinkedViewState(newLinkedState);
|
||||||
|
|
||||||
|
_dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
LinkedViewRequested?.Invoke(this, new LinkedViewRequestEventArgs(x, y, z));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部辅助方法 ──
|
||||||
|
|
||||||
|
private void RaiseOnDispatcher<T>(T oldVal, T newVal,
|
||||||
|
EventHandler<StateChangedEventArgs<T>> handler, string propertyName)
|
||||||
|
{
|
||||||
|
_dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
RaisePropertyChanged(propertyName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
handler?.Invoke(this, new StateChangedEventArgs<T>(oldVal, newVal));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "状态变更事件处理器抛出异常 (property={PropertyName})", propertyName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubscribeToExistingServices()
|
||||||
|
{
|
||||||
|
// IRaySourceService 是基于命令的服务(没有直接的状态变更事件),
|
||||||
|
// 状态同步由外部调用者通过 UpdateRaySourceState 完成。
|
||||||
|
// TODO: 当 IRaySourceService 添加状态变更事件时,在此处订阅并同步到 RaySourceState
|
||||||
|
_logger.Information("AppStateService 已准备好接收外部服务状态更新");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispose ──
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
// 清除所有事件订阅
|
||||||
|
MotionStateChanged = null;
|
||||||
|
RaySourceStateChanged = null;
|
||||||
|
DetectorStateChanged = null;
|
||||||
|
SystemStateChanged = null;
|
||||||
|
CameraStateChanged = null;
|
||||||
|
LinkedViewStateChanged = null;
|
||||||
|
RecipeExecutionStateChanged = null;
|
||||||
|
LinkedViewRequested = null;
|
||||||
|
|
||||||
|
_logger.Information("AppStateService 已释放");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.AppState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 全局应用状态管理服务接口。
|
||||||
|
/// 以单例模式注册,聚合所有硬件和系统状态,支持 WPF 数据绑定和类型化事件订阅。
|
||||||
|
/// </summary>
|
||||||
|
public interface IAppStateService : INotifyPropertyChanged, IDisposable
|
||||||
|
{
|
||||||
|
// ── 状态属性(只读,通过 Update 方法更新)──
|
||||||
|
|
||||||
|
MotionState MotionState { get; }
|
||||||
|
RaySourceState RaySourceState { get; }
|
||||||
|
DetectorState DetectorState { get; }
|
||||||
|
SystemState SystemState { get; }
|
||||||
|
CameraState CameraState { get; }
|
||||||
|
CalibrationMatrix CalibrationMatrix { get; }
|
||||||
|
LinkedViewState LinkedViewState { get; }
|
||||||
|
RecipeExecutionState RecipeExecutionState { get; }
|
||||||
|
|
||||||
|
// ── 状态更新方法(线程安全,可从任意线程调用)──
|
||||||
|
|
||||||
|
void UpdateMotionState(MotionState newState);
|
||||||
|
void UpdateRaySourceState(RaySourceState newState);
|
||||||
|
void UpdateDetectorState(DetectorState newState);
|
||||||
|
void UpdateSystemState(SystemState newState);
|
||||||
|
void UpdateCameraState(CameraState newState);
|
||||||
|
void UpdateCalibrationMatrix(CalibrationMatrix newMatrix);
|
||||||
|
void UpdateLinkedViewState(LinkedViewState newState);
|
||||||
|
void UpdateRecipeExecutionState(RecipeExecutionState newState);
|
||||||
|
|
||||||
|
// ── 画面联动 ──
|
||||||
|
|
||||||
|
/// <summary>将像素坐标通过 CalibrationMatrix 转换为物理坐标并发布 LinkedViewRequest</summary>
|
||||||
|
void RequestLinkedView(double pixelX, double pixelY);
|
||||||
|
|
||||||
|
// ── 类型化状态变更事件 ──
|
||||||
|
|
||||||
|
event EventHandler<StateChangedEventArgs<MotionState>> MotionStateChanged;
|
||||||
|
event EventHandler<StateChangedEventArgs<RaySourceState>> RaySourceStateChanged;
|
||||||
|
event EventHandler<StateChangedEventArgs<DetectorState>> DetectorStateChanged;
|
||||||
|
event EventHandler<StateChangedEventArgs<SystemState>> SystemStateChanged;
|
||||||
|
event EventHandler<StateChangedEventArgs<CameraState>> CameraStateChanged;
|
||||||
|
event EventHandler<StateChangedEventArgs<LinkedViewState>> LinkedViewStateChanged;
|
||||||
|
event EventHandler<StateChangedEventArgs<RecipeExecutionState>> RecipeExecutionStateChanged;
|
||||||
|
|
||||||
|
// ── 画面联动请求事件 ──
|
||||||
|
|
||||||
|
event EventHandler<LinkedViewRequestEventArgs> LinkedViewRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.AppState
|
||||||
|
{
|
||||||
|
/// <summary>类型化状态变更事件参数,携带旧值和新值</summary>
|
||||||
|
public sealed class StateChangedEventArgs<T> : EventArgs
|
||||||
|
{
|
||||||
|
public T OldValue { get; }
|
||||||
|
public T NewValue { get; }
|
||||||
|
|
||||||
|
public StateChangedEventArgs(T oldValue, T newValue)
|
||||||
|
{
|
||||||
|
OldValue = oldValue;
|
||||||
|
NewValue = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>画面联动请求事件参数,携带目标物理坐标</summary>
|
||||||
|
public sealed class LinkedViewRequestEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public double TargetX { get; }
|
||||||
|
public double TargetY { get; }
|
||||||
|
public double TargetZ { get; }
|
||||||
|
|
||||||
|
public LinkedViewRequestEventArgs(double targetX, double targetY, double targetZ)
|
||||||
|
{
|
||||||
|
TargetX = targetX;
|
||||||
|
TargetY = targetY;
|
||||||
|
TargetZ = targetZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.Recipe
|
||||||
|
{
|
||||||
|
/// <summary>检测配方管理服务接口,负责配方的创建、序列化/反序列化和执行编排</summary>
|
||||||
|
public interface IRecipeService
|
||||||
|
{
|
||||||
|
/// <summary>创建空的检测配方</summary>
|
||||||
|
InspectionRecipe CreateRecipe(string name);
|
||||||
|
|
||||||
|
/// <summary>从当前全局状态快照 + PipelineModel 记录一个步骤</summary>
|
||||||
|
RecipeStep RecordCurrentStep(InspectionRecipe recipe, PipelineModel pipeline);
|
||||||
|
|
||||||
|
/// <summary>序列化配方到 JSON 文件</summary>
|
||||||
|
Task SaveAsync(InspectionRecipe recipe, string filePath);
|
||||||
|
|
||||||
|
/// <summary>从 JSON 文件反序列化配方</summary>
|
||||||
|
Task<InspectionRecipe> LoadAsync(string filePath);
|
||||||
|
|
||||||
|
/// <summary>按步骤顺序执行配方</summary>
|
||||||
|
Task ExecuteAsync(InspectionRecipe recipe, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>暂停当前配方执行(当前步骤完成后停止)</summary>
|
||||||
|
void Pause();
|
||||||
|
|
||||||
|
/// <summary>恢复已暂停的配方执行</summary>
|
||||||
|
void Resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Serilog;
|
||||||
|
using XplorePlane.Models;
|
||||||
|
using XplorePlane.Services.AppState;
|
||||||
|
|
||||||
|
namespace XplorePlane.Services.Recipe
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 检测配方管理服务实现。
|
||||||
|
/// 负责配方的创建、序列化/反序列化和执行编排,通过 ManualResetEventSlim 支持暂停/恢复。
|
||||||
|
/// </summary>
|
||||||
|
public class RecipeService : IRecipeService
|
||||||
|
{
|
||||||
|
private readonly IAppStateService _appStateService;
|
||||||
|
private readonly IPipelineExecutionService _pipelineExecutionService;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly ManualResetEventSlim _pauseHandle = new(true); // initially signaled (not paused)
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public RecipeService(
|
||||||
|
IAppStateService appStateService,
|
||||||
|
IPipelineExecutionService pipelineExecutionService,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(appStateService);
|
||||||
|
ArgumentNullException.ThrowIfNull(pipelineExecutionService);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
|
_appStateService = appStateService;
|
||||||
|
_pipelineExecutionService = pipelineExecutionService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
_logger.Information("RecipeService 已初始化");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InspectionRecipe CreateRecipe(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
throw new ArgumentException("配方名称不能为空", nameof(name));
|
||||||
|
|
||||||
|
var recipe = new InspectionRecipe(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: DateTime.UtcNow,
|
||||||
|
UpdatedAt: DateTime.UtcNow,
|
||||||
|
Steps: new List<RecipeStep>().AsReadOnly());
|
||||||
|
|
||||||
|
_logger.Information("已创建配方: {RecipeName}, Id={RecipeId}", name, recipe.Id);
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public RecipeStep RecordCurrentStep(InspectionRecipe recipe, PipelineModel pipeline)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(recipe);
|
||||||
|
ArgumentNullException.ThrowIfNull(pipeline);
|
||||||
|
|
||||||
|
var motionState = _appStateService.MotionState;
|
||||||
|
var raySourceState = _appStateService.RaySourceState;
|
||||||
|
var detectorState = _appStateService.DetectorState;
|
||||||
|
|
||||||
|
var step = new RecipeStep(
|
||||||
|
StepIndex: recipe.Steps.Count,
|
||||||
|
MotionState: motionState,
|
||||||
|
RaySourceState: raySourceState,
|
||||||
|
DetectorState: detectorState,
|
||||||
|
Pipeline: pipeline);
|
||||||
|
|
||||||
|
_logger.Information("已记录配方步骤: StepIndex={StepIndex}, Recipe={RecipeName}",
|
||||||
|
step.StepIndex, recipe.Name);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SaveAsync(InspectionRecipe recipe, string filePath)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(recipe);
|
||||||
|
ArgumentNullException.ThrowIfNull(filePath);
|
||||||
|
|
||||||
|
// 路径安全性验证:检查路径遍历
|
||||||
|
if (filePath.Contains("..", StringComparison.Ordinal))
|
||||||
|
throw new UnauthorizedAccessException($"文件路径包含不安全的路径遍历字符: {filePath}");
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(recipe, _jsonOptions);
|
||||||
|
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.Information("已保存配方到文件: {FilePath}, Recipe={RecipeName}", filePath, recipe.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<InspectionRecipe> LoadAsync(string filePath)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(filePath);
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
throw new FileNotFoundException($"配方文件不存在: {filePath}", filePath);
|
||||||
|
|
||||||
|
string json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not FileNotFoundException)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"无法读取配方文件: {filePath}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
InspectionRecipe recipe;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
recipe = JsonSerializer.Deserialize<InspectionRecipe>(json, _jsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"配方文件 JSON 格式无效: {filePath}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe is null)
|
||||||
|
throw new InvalidDataException($"配方文件反序列化结果为 null: {filePath}");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(recipe.Name))
|
||||||
|
throw new InvalidDataException($"配方文件数据不完整,缺少名称: {filePath}");
|
||||||
|
|
||||||
|
if (recipe.Steps is null)
|
||||||
|
throw new InvalidDataException($"配方文件数据不完整,缺少步骤列表: {filePath}");
|
||||||
|
|
||||||
|
_logger.Information("已加载配方: {FilePath}, Recipe={RecipeName}, Steps={StepCount}",
|
||||||
|
filePath, recipe.Name, recipe.Steps.Count);
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ExecuteAsync(InspectionRecipe recipe, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(recipe);
|
||||||
|
|
||||||
|
_logger.Information("开始执行配方: {RecipeName}, 共 {StepCount} 步", recipe.Name, recipe.Steps.Count);
|
||||||
|
|
||||||
|
// 重置暂停状态
|
||||||
|
_pauseHandle.Set();
|
||||||
|
|
||||||
|
_appStateService.UpdateRecipeExecutionState(new RecipeExecutionState(
|
||||||
|
CurrentStepIndex: 0,
|
||||||
|
TotalSteps: recipe.Steps.Count,
|
||||||
|
Status: RecipeExecutionStatus.Running,
|
||||||
|
CurrentRecipeName: recipe.Name));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 0; i < recipe.Steps.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// 暂停检查:等待恢复信号
|
||||||
|
_pauseHandle.Wait(cancellationToken);
|
||||||
|
|
||||||
|
var step = recipe.Steps[i];
|
||||||
|
|
||||||
|
_appStateService.UpdateRecipeExecutionState(new RecipeExecutionState(
|
||||||
|
CurrentStepIndex: i,
|
||||||
|
TotalSteps: recipe.Steps.Count,
|
||||||
|
Status: RecipeExecutionStatus.Running,
|
||||||
|
CurrentRecipeName: recipe.Name));
|
||||||
|
|
||||||
|
_logger.Information("执行配方步骤 {StepIndex}/{TotalSteps}: Recipe={RecipeName}",
|
||||||
|
i + 1, recipe.Steps.Count, recipe.Name);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteStepAsync(step, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw; // 让取消异常向上传播
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "配方步骤 {StepIndex} 执行失败: Recipe={RecipeName}", i, recipe.Name);
|
||||||
|
|
||||||
|
_appStateService.UpdateRecipeExecutionState(new RecipeExecutionState(
|
||||||
|
CurrentStepIndex: i,
|
||||||
|
TotalSteps: recipe.Steps.Count,
|
||||||
|
Status: RecipeExecutionStatus.Error,
|
||||||
|
CurrentRecipeName: recipe.Name));
|
||||||
|
|
||||||
|
_appStateService.UpdateSystemState(new SystemState(
|
||||||
|
OperationMode: OperationMode.Idle,
|
||||||
|
HasError: true,
|
||||||
|
ErrorMessage: $"配方 '{recipe.Name}' 步骤 {i} 执行失败: {ex.Message}"));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_appStateService.UpdateRecipeExecutionState(new RecipeExecutionState(
|
||||||
|
CurrentStepIndex: recipe.Steps.Count,
|
||||||
|
TotalSteps: recipe.Steps.Count,
|
||||||
|
Status: RecipeExecutionStatus.Completed,
|
||||||
|
CurrentRecipeName: recipe.Name));
|
||||||
|
|
||||||
|
_logger.Information("配方执行完成: {RecipeName}", recipe.Name);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.Information("配方执行已取消: {RecipeName}", recipe.Name);
|
||||||
|
|
||||||
|
_appStateService.UpdateRecipeExecutionState(new RecipeExecutionState(
|
||||||
|
CurrentStepIndex: 0,
|
||||||
|
TotalSteps: recipe.Steps.Count,
|
||||||
|
Status: RecipeExecutionStatus.Idle,
|
||||||
|
CurrentRecipeName: recipe.Name));
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Pause()
|
||||||
|
{
|
||||||
|
_pauseHandle.Reset();
|
||||||
|
|
||||||
|
var current = _appStateService.RecipeExecutionState;
|
||||||
|
if (current.Status == RecipeExecutionStatus.Running)
|
||||||
|
{
|
||||||
|
_appStateService.UpdateRecipeExecutionState(current with
|
||||||
|
{
|
||||||
|
Status = RecipeExecutionStatus.Paused
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Information("配方执行已暂停");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Resume()
|
||||||
|
{
|
||||||
|
var current = _appStateService.RecipeExecutionState;
|
||||||
|
if (current.Status == RecipeExecutionStatus.Paused)
|
||||||
|
{
|
||||||
|
_appStateService.UpdateRecipeExecutionState(current with
|
||||||
|
{
|
||||||
|
Status = RecipeExecutionStatus.Running
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_pauseHandle.Set();
|
||||||
|
_logger.Information("配方执行已恢复");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部辅助方法 ──
|
||||||
|
|
||||||
|
private async Task ExecuteStepAsync(RecipeStep step, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 更新运动状态
|
||||||
|
_appStateService.UpdateMotionState(step.MotionState);
|
||||||
|
|
||||||
|
// 更新射线源状态
|
||||||
|
_appStateService.UpdateRaySourceState(step.RaySourceState);
|
||||||
|
|
||||||
|
// 更新探测器状态
|
||||||
|
_appStateService.UpdateDetectorState(step.DetectorState);
|
||||||
|
|
||||||
|
// TODO: 通过 IPipelineExecutionService 执行 Pipeline
|
||||||
|
// IPipelineExecutionService.ExecutePipelineAsync 需要 IEnumerable<PipelineNodeViewModel> 和 BitmapSource,
|
||||||
|
// 而 RecipeStep 持有 PipelineModel。此处记录日志作为占位,待 ViewModel 层适配后完善。
|
||||||
|
_logger.Information("步骤 {StepIndex}: 已更新设备状态,Pipeline '{PipelineName}' 执行待实现",
|
||||||
|
step.StepIndex, step.Pipeline?.Name ?? "N/A");
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,9 @@
|
|||||||
|
|
||||||
2026.3.18
|
2026.3.18
|
||||||
----------------------
|
----------------------
|
||||||
1、全局数据结构的考虑与设计
|
1、全局数据结构的考虑与设计(多个窗体可以调用公共的数据,如射线源状态,探测器状态,运动位置,图像等)
|
||||||
2、将计划窗体默认隐藏,只有CNC状态下展开
|
2、将计划窗体默认隐藏,只有CNC状态下展开 √
|
||||||
|
3、日志该用XP.Common库和多语言的学习
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user