Files
Axi_Omron/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
yunxiao.zhu 9a2211ccaa feat(*): 添加 PCB 检测记录的 CSV 持久化存储功能
* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储
* 新增 RecordPersistenceModels 定义板件检测记录数据结构
* 在 WorkflowHostedService 中集成检测完成后的记录持久化
* 更新 MainWindowViewModel 支持记录查询与异常标记
* 更新 AppStateStore 添加记录存储相关状态管理
* 新增 DashboardPage UI 元素展示记录存储状态
* 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置
* 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器
* 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests
* 配置 Release 模式 portable PDB 并在发布时自动移除
2026-04-19 15:39:31 +08:00

373 lines
13 KiB
C#
Raw Permalink 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>
/// 当 UI 调度已被取消时,系统设置页的快照事件不应将取消异常继续抛到同步上下文。
/// </summary>
[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);
}
}
/// <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>
/// 捕获 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>
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
/// </summary>
private sealed class CanceledDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
return Task.FromCanceled(new CancellationToken(canceled: true));
}
}
/// <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;
}
}
}