Files
Axi_Omron/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
yunxiao.zhu d70b94e904 feat(*): 添加扫码枪启动探活、全局退出助手及 README
- 添加扫码枪串口启动探活,检测端口占用并更新 UI 状态
- 新增 ShutdownHelper 安全停止 Host 扩展方法
- 新增 README.md 项目说明文档
- 更新 WorkflowHostedService 启动探活逻辑
- 补充 ShutdownHelper 与 WorkflowHostedService 单元测试
- 优化 DashboardPage 与 SystemSettingsPage 界面布局
- 调整 ModbusTcpPlcService 监控镜像读取逻辑
2026-04-19 14:29:07 +08:00

291 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
/// 验证系统设置页视图模型中的 SFTP 测试连接行为。
/// </summary>
public sealed class SystemSettingViewModelTests
{
/// <summary>
/// 当 SFTP 测试成功时,应更新成功状态文本。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestSftpConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
var sftpLookupService = new FakeSftpLookupService
{
TestOutcome = new SftpConnectionTestOutcome
{
IsSuccess = true,
RootPathAccessible = true,
StatusMessage = "SFTP 连接成功,根目录可访问。"
}
};
var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
Assert.Equal("SFTP 连接成功,根目录可访问。", viewModel.StatusMessage);
}
/// <summary>
/// 当 SFTP 测试失败时,应将失败原因展示到状态文本。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestSftpConnectionAsync_ShouldReportFailure_WhenConnectionFails()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
var sftpLookupService = new FakeSftpLookupService
{
TestOutcome = new SftpConnectionTestOutcome
{
IsSuccess = false,
IsSystemError = true,
StatusMessage = "SFTP 连接失败: timeout"
}
};
var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
await viewModel.TestSftpConnectionCommand.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 FakeSftpLookupService(), stateStore, dispatcherService);
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 FakeSftpLookupService(), stateStore, dispatcherService);
_ = 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>
/// 在 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 FakeSftpLookupService : ISftpLookupService
{
public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);
}
}
/// <summary>
/// 提供立即执行的测试用 Dispatcher实现与设计时调度一致的同步行为。
/// </summary>
private sealed class ImmediateDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
action();
return Task.CompletedTask;
}
}
/// <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;
}
}
}