using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.ViewModels; using Xunit; namespace AxiOmron.PcbCheck.Tests; /// /// 验证首页视图模型对处理记录和统计区域的绑定行为。 /// public sealed class MainWindowViewModelTests { /// /// 构造时应使用运行态快照中的统计值,而不是仅根据最近 100 条列表重新计算。 /// [Fact] public void Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount() { var stateStore = new AppStateStore(); stateStore.UpdateSnapshot(snapshot => { snapshot.RecordStatisticDate = new DateOnly(2026, 4, 19); snapshot.TodayProcessCount = 150; snapshot.TodayOkCount = 120; snapshot.TodayNgCount = 30; }); var viewModel = CreateViewModel(stateStore); Assert.Equal(150, viewModel.TodayProcessCount); Assert.Equal(120, viewModel.TodayOkCount); Assert.Equal(30, viewModel.TodayNgCount); Assert.Equal("2026-04-19", viewModel.RecordStatisticDateText); } /// /// 构造时应从快照恢复最近 100 条记录,并按完成时间倒序展示。 /// [Fact] public void Constructor_ShouldLoadRecentRecords_FromSnapshot_AndLimitTo100() { var stateStore = new AppStateStore(); var records = Enumerable.Range(1, 120) .Select(index => new BoardProcessRecord { StartedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)).AddMinutes(index).AddSeconds(-10), CompletedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)).AddMinutes(index), Barcode = $"PCB-{index:D3}", ScanTryCount = 1, SftpTryCount = 1, ResultCode = index % 2 == 0 ? (ushort)WorkflowResultCode.Passed : (ushort)WorkflowResultCode.NgReleased, ResultDescription = index % 2 == 0 ? "OK" : "NG", ReleaseSent = true, AlarmRaised = false }) .ToList(); stateStore.ReplaceRecords( records, new BoardRecordStatistics { StatisticDate = new DateOnly(2026, 4, 19), TotalCount = 120, OkCount = 60, NgCount = 60 }); var viewModel = CreateViewModel(stateStore); Assert.Equal(100, viewModel.RecentProcessRecords.Count); Assert.Equal("PCB-120", viewModel.RecentProcessRecords[0].Barcode); Assert.Equal("PCB-021", viewModel.RecentProcessRecords[^1].Barcode); } /// /// 当 UI 调度已被取消时,快照事件不应将取消异常继续抛到同步上下文。 /// [Fact] public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask() { var stateStore = new AppStateStore(); var viewModel = CreateViewModel(stateStore, new CanceledDispatcherService()); var synchronizationContext = new ExceptionCapturingSynchronizationContext(); SynchronizationContext? originalContext = SynchronizationContext.Current; try { SynchronizationContext.SetSynchronizationContext(synchronizationContext); stateStore.UpdateSnapshot(snapshot => snapshot.WorkflowStateText = "运行中"); Assert.Null(synchronizationContext.CapturedException); Assert.Equal("空闲等待", viewModel.WorkflowStateText); } finally { SynchronizationContext.SetSynchronizationContext(originalContext); } } /// /// 当 UI 调度已被取消时,日志事件不应将取消异常继续抛到同步上下文。 /// [Fact] public void LogAdded_ShouldIgnoreCanceledDispatcherTask() { var stateStore = new AppStateStore(); var viewModel = CreateViewModel(stateStore, new CanceledDispatcherService()); var synchronizationContext = new ExceptionCapturingSynchronizationContext(); SynchronizationContext? originalContext = SynchronizationContext.Current; try { SynchronizationContext.SetSynchronizationContext(synchronizationContext); stateStore.AddLog(new UiLogEntry { Timestamp = DateTimeOffset.Now, Level = "Information", Message = "测试日志" }); Assert.Null(synchronizationContext.CapturedException); Assert.Empty(viewModel.Logs); } finally { SynchronizationContext.SetSynchronizationContext(originalContext); } } /// /// 创建测试用首页视图模型。 /// /// 运行态存储。 /// 首页视图模型。 private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null) { return new MainWindowViewModel( stateStore, dispatcherService ?? new ImmediateDispatcherService(), new FakeWorkflowControlService(), new AppConfig { Workflow = new WorkflowOptions { MaxBoardRecords = 100, MaxUiLogEntries = 200 }, Security = new SecurityOptions { AdminPassword = "test" } }, new FakeAdminUnlockDialogService()); } /// /// 捕获 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; } } } /// /// 提供立即执行的测试调度服务。 /// private sealed class ImmediateDispatcherService : IDispatcherService { public Task InvokeAsync(Action action) { ArgumentNullException.ThrowIfNull(action); action(); return Task.CompletedTask; } } /// /// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。 /// private sealed class CanceledDispatcherService : IDispatcherService { public Task InvokeAsync(Action action) { ArgumentNullException.ThrowIfNull(action); return Task.FromCanceled(new CancellationToken(canceled: true)); } } /// /// 提供空实现的流程控制服务。 /// private sealed class FakeWorkflowControlService : IWorkflowControlService { public Task ReconnectPlcAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task ReconnectScannerAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task ResetAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task TestAndonAsync(CancellationToken cancellationToken) => Task.CompletedTask; } /// /// 提供空实现的管理员解锁弹窗服务。 /// private sealed class FakeAdminUnlockDialogService : IAdminUnlockDialogService { public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) => true; } }