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;
///
/// 验证系统设置页视图模型中的 SFTP 测试连接行为。
///
public sealed class SystemSettingViewModelTests
{
///
/// 当 SFTP 测试成功时,应更新成功状态文本。
///
/// 异步测试任务。
[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);
}
///
/// 当 SFTP 测试失败时,应将失败原因展示到状态文本。
///
/// 异步测试任务。
[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);
}
///
/// 当运行态快照包含 PLC 监控项时,应同步到系统设置页监控列表。
///
[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);
}
///
/// 当后台线程发布运行态快照时,不应直接在后台线程修改已绑定到 CollectionView 的 PLC 监控集合。
///
[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);
}
///
/// 当 UI 调度已被取消时,系统设置页的快照事件不应将取消异常继续抛到同步上下文。
///
[Fact]
public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var viewModel = new SystemSettingViewModel(
configService,
new FakeSftpLookupService(),
stateStore,
new CanceledDispatcherService());
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);
}
}
///
/// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。
///
/// 委托返回值类型。
/// 待执行的委托。
/// 委托返回结果。
/// 当 为 时抛出。
private static T RunInSta(Func 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!;
}
///
/// 在当前 STA 线程中循环处理 Dispatcher 队列,直到满足指定条件。
///
/// 停止等待的条件。
/// 最大等待时长。
/// 当 为 时抛出。
/// 当超时仍未满足条件时抛出。
private static void PumpDispatcherUntil(Func 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 CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);
}
}
///
/// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。
///
private sealed class ImmediateDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
action();
return Task.CompletedTask;
}
}
///
/// 捕获 async void 事件处理器回抛到同步上下文中的异常。
///
private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext
{
///
/// 获取或设置捕获到的异常。
///
public Exception? CapturedException { get; private set; }
///
/// 立即执行回调并记录内部异常,避免测试进程直接崩溃。
///
/// 待执行回调。
/// 回调状态。
public override void Post(SendOrPostCallback d, object? state)
{
ArgumentNullException.ThrowIfNull(d);
try
{
d(state);
}
catch (Exception ex)
{
CapturedException = ex;
}
}
}
///
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
///
private sealed class CanceledDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
return Task.FromCanceled(new CancellationToken(canceled: true));
}
}
///
/// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。
///
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;
}
}
}