* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储 * 新增 RecordPersistenceModels 定义板件检测记录数据结构 * 在 WorkflowHostedService 中集成检测完成后的记录持久化 * 更新 MainWindowViewModel 支持记录查询与异常标记 * 更新 AppStateStore 添加记录存储相关状态管理 * 新增 DashboardPage UI 元素展示记录存储状态 * 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置 * 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器 * 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests * 配置 Release 模式 portable PDB 并在发布时自动移除
237 lines
8.2 KiB
C#
237 lines
8.2 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 Xunit;
|
|
|
|
namespace AxiOmron.PcbCheck.Tests;
|
|
|
|
/// <summary>
|
|
/// 验证首页视图模型对处理记录和统计区域的绑定行为。
|
|
/// </summary>
|
|
public sealed class MainWindowViewModelTests
|
|
{
|
|
/// <summary>
|
|
/// 构造时应使用运行态快照中的统计值,而不是仅根据最近 100 条列表重新计算。
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 构造时应从快照恢复最近 100 条记录,并按完成时间倒序展示。
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当 UI 调度已被取消时,快照事件不应将取消异常继续抛到同步上下文。
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当 UI 调度已被取消时,日志事件不应将取消异常继续抛到同步上下文。
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 创建测试用首页视图模型。
|
|
/// </summary>
|
|
/// <param name="stateStore">运行态存储。</param>
|
|
/// <returns>首页视图模型。</returns>
|
|
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());
|
|
}
|
|
|
|
/// <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>
|
|
/// 提供立即执行的测试调度服务。
|
|
/// </summary>
|
|
private sealed class ImmediateDispatcherService : IDispatcherService
|
|
{
|
|
public Task InvokeAsync(Action action)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(action);
|
|
action();
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
|
|
/// </summary>
|
|
private sealed class CanceledDispatcherService : IDispatcherService
|
|
{
|
|
public Task InvokeAsync(Action action)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(action);
|
|
return Task.FromCanceled(new CancellationToken(canceled: true));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 提供空实现的流程控制服务。
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 提供空实现的管理员解锁弹窗服务。
|
|
/// </summary>
|
|
private sealed class FakeAdminUnlockDialogService : IAdminUnlockDialogService
|
|
{
|
|
public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) => true;
|
|
}
|
|
}
|