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); } /// /// 在 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; } } /// /// 提供绑定到指定 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; } } }