Files
Axi_Omron/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
yunxiao.zhu c67509ec82 ✨ feat(file-check): 添加 PLC 应答后命中文件删除
* 新增默认关闭的删除开关,仅在收到 PLC 放行应答后执行

* 本地与 SFTP Provider 校验根目录并支持幂等删除

* 补充工作流、设置页和文件删除相关测试与文档
2026-08-01 16:42:28 +08:00

410 lines
14 KiB
C#
Raw 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>
/// 验证系统设置页视图模型中的文件校验通道测试行为。
/// </summary>
public sealed class SystemSettingViewModelTests
{
/// <summary>
/// 当文件校验通道测试成功时,应更新成功状态文本。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestFileCheckConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
var fileCheckService = new FakeFileCheckService
{
TestOutcome = new FileCheckTestOutcome
{
IsSuccess = true,
RootPathAccessible = true,
StatusMessage = "SFTP 连接成功,根目录可访问。"
}
};
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
Assert.Equal("SFTP 连接成功,根目录可访问。", viewModel.StatusMessage);
}
/// <summary>
/// 当文件校验通道测试失败时,应将失败原因展示到状态文本。
/// </summary>
/// <returns>异步测试任务。</returns>
[Fact]
public async Task TestFileCheckConnectionAsync_ShouldReportFailure_WhenConnectionFails()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new ImmediateDispatcherService();
var fileCheckService = new FakeFileCheckService
{
TestOutcome = new FileCheckTestOutcome
{
IsSuccess = false,
IsSystemError = true,
StatusMessage = "SFTP 连接失败: timeout"
}
};
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
await viewModel.TestFileCheckConnectionCommand.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 FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
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 FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
_ = 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 FakeFileCheckService(),
stateStore,
new CanceledDispatcherService(),
new FakePlcService());
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 FakeFileCheckService : IFileCheckService
{
public FileCheckTestOutcome TestOutcome { get; set; } = new();
public Task<FileCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task<FileDeleteOutcome> DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig? config, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);
}
}
/// <summary>
/// 提供系统设置页测试用 PLC 服务替身。
/// </summary>
private sealed class FakePlcService : IPlcService
{
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcSignalSnapshot());
}
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcMonitorSnapshot());
}
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ForceReconnectAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
}
/// <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;
}
}
}