- 在 appConfig.json 中添加 EndianFormat 配置项并更新相关输入输出地址 - 修改 ResultCode 类型为 short,确保与 PLC 通信一致 - 新增 DesignTimePlcService 以支持设计时的 PLC 测试写入功能 - 更新 SystemSettingViewModel 和 DashboardPage 以支持手动测试写入 - 调整多个视图模型和服务以适应新的 PLC 写入逻辑 - 更新相关单元测试以验证新功能的正确性
405 lines
14 KiB
C#
405 lines
14 KiB
C#
using AxiOmron.PcbCheck.Models;
|
||
using AxiOmron.PcbCheck.Options;
|
||
using AxiOmron.PcbCheck.Services.Implementations;
|
||
using AxiOmron.PcbCheck.Services.Interfaces;
|
||
using AxiOmron.PcbCheck.ViewModels;
|
||
using System.Runtime.ExceptionServices;
|
||
using System.Windows.Data;
|
||
using System.Windows.Threading;
|
||
using Xunit;
|
||
|
||
namespace AxiOmron.PcbCheck.Tests;
|
||
|
||
/// <summary>
|
||
/// 验证系统设置页视图模型中的文件校验通道测试行为。
|
||
/// </summary>
|
||
public sealed class SystemSettingViewModelTests
|
||
{
|
||
/// <summary>
|
||
/// 当文件校验通道测试成功时,应更新成功状态文本。
|
||
/// </summary>
|
||
/// <returns>异步测试任务。</returns>
|
||
[Fact]
|
||
public async Task TestFileCheckConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
|
||
{
|
||
var configService = new FakeAppConfigService();
|
||
var stateStore = new AppStateStore();
|
||
var dispatcherService = new ImmediateDispatcherService();
|
||
var fileCheckService = new FakeFileCheckService
|
||
{
|
||
TestOutcome = new FileCheckTestOutcome
|
||
{
|
||
IsSuccess = true,
|
||
RootPathAccessible = true,
|
||
StatusMessage = "SFTP 连接成功,根目录可访问。"
|
||
}
|
||
};
|
||
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
|
||
|
||
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
|
||
|
||
Assert.Equal("SFTP 连接成功,根目录可访问。", viewModel.StatusMessage);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当文件校验通道测试失败时,应将失败原因展示到状态文本。
|
||
/// </summary>
|
||
/// <returns>异步测试任务。</returns>
|
||
[Fact]
|
||
public async Task TestFileCheckConnectionAsync_ShouldReportFailure_WhenConnectionFails()
|
||
{
|
||
var configService = new FakeAppConfigService();
|
||
var stateStore = new AppStateStore();
|
||
var dispatcherService = new ImmediateDispatcherService();
|
||
var fileCheckService = new FakeFileCheckService
|
||
{
|
||
TestOutcome = new FileCheckTestOutcome
|
||
{
|
||
IsSuccess = false,
|
||
IsSystemError = true,
|
||
StatusMessage = "SFTP 连接失败: timeout"
|
||
}
|
||
};
|
||
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
|
||
|
||
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
|
||
|
||
Assert.Equal("SFTP 连接失败: timeout", viewModel.StatusMessage);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当运行态快照包含 PLC 监控项时,应同步到系统设置页监控列表。
|
||
/// </summary>
|
||
[Fact]
|
||
public void Constructor_ShouldLoadPlcMonitorItems_FromStateStoreSnapshot()
|
||
{
|
||
var configService = new FakeAppConfigService();
|
||
var stateStore = new AppStateStore();
|
||
var dispatcherService = new ImmediateDispatcherService();
|
||
stateStore.UpdateSnapshot(snapshot =>
|
||
{
|
||
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
|
||
{
|
||
GroupName = "Inputs",
|
||
Name = "PcbArrived",
|
||
CurrentValue = "True",
|
||
LastUpdatedAt = DateTimeOffset.Now
|
||
});
|
||
});
|
||
|
||
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
|
||
|
||
Assert.Single(viewModel.PlcMonitorItems);
|
||
Assert.Equal("PcbArrived", viewModel.PlcMonitorItems[0].Name);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当后台线程发布运行态快照时,不应直接在后台线程修改已绑定到 CollectionView 的 PLC 监控集合。
|
||
/// </summary>
|
||
[Fact]
|
||
public void UpdateSnapshot_ShouldNotThrow_WhenSnapshotChangesFromBackgroundThread()
|
||
{
|
||
Exception? backgroundException = RunInSta(() =>
|
||
{
|
||
var configService = new FakeAppConfigService();
|
||
var stateStore = new AppStateStore();
|
||
var dispatcherService = new CapturedDispatcherService(Dispatcher.CurrentDispatcher);
|
||
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
|
||
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
|
||
|
||
Exception? capturedException = null;
|
||
using var completed = new ManualResetEventSlim(false);
|
||
var workerThread = new Thread(() =>
|
||
{
|
||
try
|
||
{
|
||
stateStore.UpdateSnapshot(snapshot =>
|
||
{
|
||
snapshot.PlcMonitorItems.Clear();
|
||
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
|
||
{
|
||
GroupName = "Inputs",
|
||
Name = "PcbArrived",
|
||
CurrentValue = "True",
|
||
LastUpdatedAt = DateTimeOffset.Now
|
||
});
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
capturedException = ex;
|
||
}
|
||
finally
|
||
{
|
||
completed.Set();
|
||
}
|
||
});
|
||
|
||
workerThread.IsBackground = true;
|
||
workerThread.Start();
|
||
|
||
Assert.True(completed.Wait(TimeSpan.FromSeconds(5)), "后台线程未在预期时间内完成快照更新。");
|
||
workerThread.Join();
|
||
PumpDispatcherUntil(() => viewModel.PlcMonitorItems.Count == 1, TimeSpan.FromSeconds(5));
|
||
Assert.Single(viewModel.PlcMonitorItems);
|
||
return capturedException;
|
||
});
|
||
|
||
Assert.Null(backgroundException);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当 UI 调度已被取消时,系统设置页的快照事件不应将取消异常继续抛到同步上下文。
|
||
/// </summary>
|
||
[Fact]
|
||
public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask()
|
||
{
|
||
var configService = new FakeAppConfigService();
|
||
var stateStore = new AppStateStore();
|
||
var viewModel = new SystemSettingViewModel(
|
||
configService,
|
||
new FakeFileCheckService(),
|
||
stateStore,
|
||
new CanceledDispatcherService(),
|
||
new FakePlcService());
|
||
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
||
SynchronizationContext? originalContext = SynchronizationContext.Current;
|
||
|
||
try
|
||
{
|
||
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
|
||
|
||
stateStore.UpdateSnapshot(snapshot =>
|
||
{
|
||
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
|
||
{
|
||
GroupName = "Inputs",
|
||
Name = "PcbArrived",
|
||
CurrentValue = "True",
|
||
LastUpdatedAt = DateTimeOffset.Now
|
||
});
|
||
});
|
||
|
||
Assert.Null(synchronizationContext.CapturedException);
|
||
Assert.Empty(viewModel.PlcMonitorItems);
|
||
}
|
||
finally
|
||
{
|
||
SynchronizationContext.SetSynchronizationContext(originalContext);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。
|
||
/// </summary>
|
||
/// <typeparam name="T">委托返回值类型。</typeparam>
|
||
/// <param name="action">待执行的委托。</param>
|
||
/// <returns>委托返回结果。</returns>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
|
||
private static T RunInSta<T>(Func<T> action)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(action);
|
||
|
||
T? result = default;
|
||
Exception? capturedException = null;
|
||
using var completed = new ManualResetEventSlim(false);
|
||
var thread = new Thread(() =>
|
||
{
|
||
try
|
||
{
|
||
result = action();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
capturedException = ex;
|
||
}
|
||
finally
|
||
{
|
||
completed.Set();
|
||
}
|
||
});
|
||
|
||
thread.SetApartmentState(ApartmentState.STA);
|
||
thread.Start();
|
||
|
||
Assert.True(completed.Wait(TimeSpan.FromSeconds(5)), "STA 测试线程未在预期时间内完成。");
|
||
thread.Join();
|
||
|
||
if (capturedException is not null)
|
||
{
|
||
ExceptionDispatchInfo.Capture(capturedException).Throw();
|
||
}
|
||
|
||
return result!;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在当前 STA 线程中循环处理 Dispatcher 队列,直到满足指定条件。
|
||
/// </summary>
|
||
/// <param name="condition">停止等待的条件。</param>
|
||
/// <param name="timeout">最大等待时长。</param>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="condition"/> 为 <see langword="null"/> 时抛出。</exception>
|
||
/// <exception cref="TimeoutException">当超时仍未满足条件时抛出。</exception>
|
||
private static void PumpDispatcherUntil(Func<bool> condition, TimeSpan timeout)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(condition);
|
||
|
||
DateTime deadline = DateTime.UtcNow.Add(timeout);
|
||
while (!condition())
|
||
{
|
||
if (DateTime.UtcNow > deadline)
|
||
{
|
||
throw new TimeoutException("Dispatcher 队列在预期时间内未完成处理。");
|
||
}
|
||
|
||
var frame = new DispatcherFrame();
|
||
_ = Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, new DispatcherOperationCallback(_ =>
|
||
{
|
||
frame.Continue = false;
|
||
return null;
|
||
}), null);
|
||
Dispatcher.PushFrame(frame);
|
||
}
|
||
}
|
||
|
||
private sealed class FakeAppConfigService : IAppConfigService
|
||
{
|
||
public AppConfig Config { get; } = new();
|
||
|
||
public AppConfig Load()
|
||
{
|
||
return Config;
|
||
}
|
||
|
||
public void Save(AppConfig config)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(config);
|
||
}
|
||
|
||
public string GetConfigPath()
|
||
{
|
||
return "appConfig.json";
|
||
}
|
||
}
|
||
|
||
private sealed class FakeFileCheckService : IFileCheckService
|
||
{
|
||
public FileCheckTestOutcome TestOutcome { get; set; } = new();
|
||
|
||
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
|
||
{
|
||
throw new NotSupportedException();
|
||
}
|
||
|
||
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
|
||
{
|
||
return Task.FromResult(TestOutcome);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提供系统设置页测试用 PLC 服务替身。
|
||
/// </summary>
|
||
private sealed class FakePlcService : IPlcService
|
||
{
|
||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
|
||
{
|
||
return Task.FromResult(new PlcSignalSnapshot());
|
||
}
|
||
|
||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||
{
|
||
return Task.FromResult(new PlcMonitorSnapshot());
|
||
}
|
||
|
||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
|
||
{
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public Task ForceReconnectAsync(CancellationToken cancellationToken)
|
||
{
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||
{
|
||
return Task.FromResult(PlcWriteResult.Success(address, type, value));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。
|
||
/// </summary>
|
||
private sealed class ImmediateDispatcherService : IDispatcherService
|
||
{
|
||
public Task InvokeAsync(Action action)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(action);
|
||
action();
|
||
return Task.CompletedTask;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 捕获 async void 事件处理器回抛到同步上下文中的异常。
|
||
/// </summary>
|
||
private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext
|
||
{
|
||
/// <summary>
|
||
/// 获取或设置捕获到的异常。
|
||
/// </summary>
|
||
public Exception? CapturedException { get; private set; }
|
||
|
||
/// <summary>
|
||
/// 立即执行回调并记录内部异常,避免测试进程直接崩溃。
|
||
/// </summary>
|
||
/// <param name="d">待执行回调。</param>
|
||
/// <param name="state">回调状态。</param>
|
||
public override void Post(SendOrPostCallback d, object? state)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(d);
|
||
|
||
try
|
||
{
|
||
d(state);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CapturedException = ex;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
|
||
/// </summary>
|
||
private sealed class CanceledDispatcherService : IDispatcherService
|
||
{
|
||
public Task InvokeAsync(Action action)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(action);
|
||
return Task.FromCanceled(new CancellationToken(canceled: true));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。
|
||
/// </summary>
|
||
private sealed class CapturedDispatcherService : IDispatcherService
|
||
{
|
||
private readonly Dispatcher _dispatcher;
|
||
|
||
public CapturedDispatcherService(Dispatcher dispatcher)
|
||
{
|
||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||
}
|
||
|
||
public Task InvokeAsync(Action action)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(action);
|
||
return _dispatcher.InvokeAsync(action).Task;
|
||
}
|
||
}
|
||
}
|