✨ feat(*): 添加扫码枪启动探活、全局退出助手及 README
- 添加扫码枪串口启动探活,检测端口占用并更新 UI 状态 - 新增 ShutdownHelper 安全停止 Host 扩展方法 - 新增 README.md 项目说明文档 - 更新 WorkflowHostedService 启动探活逻辑 - 补充 ShutdownHelper 与 WorkflowHostedService 单元测试 - 优化 DashboardPage 与 SystemSettingsPage 界面布局 - 调整 ModbusTcpPlcService 监控镜像读取逻辑
This commit is contained in:
149
tests/AxiOmron.PcbCheck.Tests/ShutdownHelperTests.cs
Normal file
149
tests/AxiOmron.PcbCheck.Tests/ShutdownHelperTests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System.Collections.Concurrent;
|
||||
using AxiOmron.PcbCheck.Utils;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace AxiOmron.PcbCheck.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证应用退出阶段的异步清理流程不会丢失原始线程上下文。
|
||||
/// </summary>
|
||||
public sealed class ShutdownHelperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 停止 Host 后,后续收尾回调应继续在捕获到的同步上下文线程上执行。
|
||||
/// </summary>
|
||||
/// <returns>异步测试任务。</returns>
|
||||
[Fact]
|
||||
public async Task StopHostAsync_ShouldResumeOnCapturedSynchronizationContext()
|
||||
{
|
||||
await SingleThreadSynchronizationContext.RunAsync(async () =>
|
||||
{
|
||||
int expectedThreadId = Environment.CurrentManagedThreadId;
|
||||
int callbackThreadId = -1;
|
||||
var host = new FakeHost();
|
||||
|
||||
await ShutdownHelper.StopHostAsync(
|
||||
host,
|
||||
TimeSpan.FromMilliseconds(200),
|
||||
() => callbackThreadId = Environment.CurrentManagedThreadId);
|
||||
|
||||
Assert.True(host.StopAsyncCalled);
|
||||
Assert.Equal(expectedThreadId, callbackThreadId);
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class FakeHost : IHost
|
||||
{
|
||||
public IServiceProvider Services => throw new NotSupportedException();
|
||||
|
||||
public bool StopAsyncCalled { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
StopAsyncCalled = true;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提供仅在单线程内串行泵送回调的同步上下文,便于验证 await 后的线程恢复行为。
|
||||
/// </summary>
|
||||
private sealed class SingleThreadSynchronizationContext : SynchronizationContext
|
||||
{
|
||||
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
|
||||
|
||||
/// <summary>
|
||||
/// 在专用线程同步上下文中执行异步委托。
|
||||
/// </summary>
|
||||
/// <param name="action">待执行的异步委托。</param>
|
||||
/// <returns>表示执行完成的任务。</returns>
|
||||
public static Task RunAsync(Func<Task> action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
var context = new SingleThreadSynchronizationContext();
|
||||
SynchronizationContext.SetSynchronizationContext(context);
|
||||
Task task;
|
||||
|
||||
try
|
||||
{
|
||||
task = action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completionSource.SetException(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
task.ContinueWith(
|
||||
completedTask =>
|
||||
{
|
||||
if (completedTask.IsFaulted)
|
||||
{
|
||||
completionSource.SetException(completedTask.Exception!.InnerExceptions);
|
||||
}
|
||||
else if (completedTask.IsCanceled)
|
||||
{
|
||||
completionSource.SetCanceled();
|
||||
}
|
||||
else
|
||||
{
|
||||
completionSource.SetResult();
|
||||
}
|
||||
|
||||
context._queue.CompleteAdding();
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default);
|
||||
|
||||
foreach ((SendOrPostCallback callback, object? state) in context._queue.GetConsumingEnumerable())
|
||||
{
|
||||
callback(state);
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true
|
||||
};
|
||||
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
return completionSource.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将委托异步排入当前上下文队列。
|
||||
/// </summary>
|
||||
/// <param name="d">待执行回调。</param>
|
||||
/// <param name="state">状态对象。</param>
|
||||
public override void Post(SendOrPostCallback d, object? state)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(d);
|
||||
_queue.Add((d, state));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在当前线程直接执行同步回调。
|
||||
/// </summary>
|
||||
/// <param name="d">待执行回调。</param>
|
||||
/// <param name="state">状态对象。</param>
|
||||
public override void Send(SendOrPostCallback d, object? state)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(d);
|
||||
d(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
|
||||
@@ -18,6 +23,8 @@ public sealed class SystemSettingViewModelTests
|
||||
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
|
||||
@@ -27,7 +34,7 @@ public sealed class SystemSettingViewModelTests
|
||||
StatusMessage = "SFTP 连接成功,根目录可访问。"
|
||||
}
|
||||
};
|
||||
var viewModel = new SystemSettingViewModel(configService, sftpLookupService);
|
||||
var viewModel = new SystemSettingViewModel(configService, sftpLookupService, stateStore, dispatcherService);
|
||||
|
||||
await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
|
||||
|
||||
@@ -42,6 +49,8 @@ public sealed class SystemSettingViewModelTests
|
||||
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
|
||||
@@ -51,13 +60,167 @@ public sealed class SystemSettingViewModelTests
|
||||
StatusMessage = "SFTP 连接失败: timeout"
|
||||
}
|
||||
};
|
||||
var viewModel = new SystemSettingViewModel(configService, sftpLookupService);
|
||||
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>
|
||||
/// 在 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();
|
||||
@@ -92,4 +255,36 @@ public sealed class SystemSettingViewModelTests
|
||||
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>
|
||||
/// 提供绑定到指定 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using AxiOmron.PcbCheck.Models;
|
||||
using AxiOmron.PcbCheck.Options;
|
||||
using AxiOmron.PcbCheck.Services.Implementations;
|
||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace AxiOmron.PcbCheck.Tests;
|
||||
|
||||
@@ -40,13 +41,312 @@ public sealed class WorkflowHostedServiceTests
|
||||
Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动探活 PLC 成功时,应立即将 PLC 状态更新为已连接。
|
||||
/// </summary>
|
||||
/// <returns>异步测试任务。</returns>
|
||||
[Fact]
|
||||
public async Task ProbePlcOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionSucceeds()
|
||||
{
|
||||
var stateStore = new AppStateStore();
|
||||
var plcService = new ProbingPlcService();
|
||||
var service = new WorkflowHostedService(
|
||||
plcService,
|
||||
new FakeScannerService(),
|
||||
new FakeSftpLookupService(),
|
||||
new FakeAndonService(),
|
||||
stateStore,
|
||||
new AppConfig(),
|
||||
new FakeAppLogger<WorkflowHostedService>());
|
||||
|
||||
await service.ProbePlcOnStartupAsync(CancellationToken.None);
|
||||
|
||||
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
|
||||
Assert.Equal("已连接", snapshot.PlcStatus);
|
||||
Assert.True(plcService.ForceReconnectCalled);
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcbArrived" && item.CurrentValue == "True");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动探活 PLC 失败时,应将状态更新为连接失败而不是保留默认未连接。
|
||||
/// </summary>
|
||||
/// <returns>异步测试任务。</returns>
|
||||
[Fact]
|
||||
public async Task ProbePlcOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails()
|
||||
{
|
||||
var stateStore = new AppStateStore();
|
||||
var service = new WorkflowHostedService(
|
||||
new FailingPlcService(),
|
||||
new FakeScannerService(),
|
||||
new FakeSftpLookupService(),
|
||||
new FakeAndonService(),
|
||||
stateStore,
|
||||
new AppConfig(),
|
||||
new FakeAppLogger<WorkflowHostedService>());
|
||||
|
||||
await service.ProbePlcOnStartupAsync(CancellationToken.None);
|
||||
|
||||
Assert.StartsWith("连接失败:", stateStore.GetSnapshot().PlcStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当 PLC 仅提供到位信号时,流程仍应启动,不再依赖就绪、自动模式和工位使能位。
|
||||
/// </summary>
|
||||
/// <returns>异步测试任务。</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ShouldStartWorkflow_WhenOnlyPcbArrivedIsProvided()
|
||||
{
|
||||
var stateStore = new AppStateStore();
|
||||
var plcService = new SequencedPlcService(new[]
|
||||
{
|
||||
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
|
||||
new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true }
|
||||
});
|
||||
var scannerService = new CountingScannerService
|
||||
{
|
||||
Result = new ScanOperationResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
DeviceConnected = true,
|
||||
Barcode = "PCB-001"
|
||||
}
|
||||
};
|
||||
var sftpService = new FakeSftpLookupService
|
||||
{
|
||||
CheckOutcome = new SftpCheckOutcome
|
||||
{
|
||||
Exists = true,
|
||||
ConnectionSucceeded = true,
|
||||
MatchedFilePath = "/pcb/PCB-001.txt"
|
||||
}
|
||||
};
|
||||
|
||||
var service = new WorkflowHostedService(
|
||||
plcService,
|
||||
scannerService,
|
||||
sftpService,
|
||||
new FakeAndonService(),
|
||||
stateStore,
|
||||
new AppConfig
|
||||
{
|
||||
Plc = new PlcOptions
|
||||
{
|
||||
PollIntervalMs = 20,
|
||||
ReleaseAckTimeoutMs = 100,
|
||||
ReleasePulseMs = 10
|
||||
},
|
||||
Scanner = new ScannerOptions
|
||||
{
|
||||
MaxScanAttempts = 1
|
||||
},
|
||||
Sftp = new SftpOptions
|
||||
{
|
||||
MaxRetryCount = 0
|
||||
}
|
||||
},
|
||||
new FakeAppLogger<WorkflowHostedService>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(scannerService.TriggerCount > 0);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ReleasePermit);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
|
||||
|
||||
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcbArrived" && item.CurrentValue == "False");
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcBusy" && item.CurrentValue == "False");
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "ResultCode" && item.CurrentValue == ((ushort)WorkflowResultCode.Passed).ToString());
|
||||
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当到位信号保持高电平时,即使执行软件复位,也不应被视为新的上升沿重复触发流程。
|
||||
/// </summary>
|
||||
/// <returns>异步测试任务。</returns>
|
||||
[Fact]
|
||||
public async Task ResetAsync_ShouldNotRestartWorkflow_WhenPcbArrivedRemainsHigh()
|
||||
{
|
||||
var stateStore = new AppStateStore();
|
||||
var plcService = new SequencedPlcService(new[]
|
||||
{
|
||||
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
|
||||
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
|
||||
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
|
||||
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true },
|
||||
new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true }
|
||||
});
|
||||
var scannerService = new CountingScannerService
|
||||
{
|
||||
Result = new ScanOperationResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
DeviceConnected = true,
|
||||
Barcode = "PCB-EDGE-001"
|
||||
}
|
||||
};
|
||||
var service = new WorkflowHostedService(
|
||||
plcService,
|
||||
scannerService,
|
||||
new FakeSftpLookupService
|
||||
{
|
||||
CheckOutcome = new SftpCheckOutcome
|
||||
{
|
||||
Exists = true,
|
||||
ConnectionSucceeded = true,
|
||||
MatchedFilePath = "/pcb/PCB-EDGE-001.txt"
|
||||
}
|
||||
},
|
||||
new FakeAndonService(),
|
||||
stateStore,
|
||||
new AppConfig
|
||||
{
|
||||
Plc = new PlcOptions
|
||||
{
|
||||
PollIntervalMs = 20,
|
||||
ReleaseAckTimeoutMs = 100,
|
||||
ReleasePulseMs = 10
|
||||
},
|
||||
Scanner = new ScannerOptions
|
||||
{
|
||||
MaxScanAttempts = 1
|
||||
},
|
||||
Sftp = new SftpOptions
|
||||
{
|
||||
MaxRetryCount = 0
|
||||
}
|
||||
},
|
||||
new FakeAppLogger<WorkflowHostedService>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(120);
|
||||
await service.ResetAsync(CancellationToken.None);
|
||||
await Task.Delay(120);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, scannerService.TriggerCount);
|
||||
}
|
||||
|
||||
private sealed class FakePlcService : IPlcService
|
||||
{
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot());
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class ProbingPlcService : IPlcService
|
||||
{
|
||||
public bool ForceReconnectCalled { get; private set; }
|
||||
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ForceReconnectCalled = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PlcMonitorSnapshot
|
||||
{
|
||||
Inputs = new PlcSignalSnapshot
|
||||
{
|
||||
PcbArrived = true,
|
||||
PlcReset = false,
|
||||
PlcAckRelease = false,
|
||||
CapturedAt = DateTimeOffset.Now
|
||||
},
|
||||
Outputs = new PlcProcessState
|
||||
{
|
||||
PcBusy = false,
|
||||
ReleasePermit = false,
|
||||
ResultCode = 0
|
||||
},
|
||||
CapturedAt = DateTimeOffset.Now
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PlcSignalSnapshot());
|
||||
}
|
||||
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingPlcService : IPlcService
|
||||
{
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("PLC unreachable");
|
||||
}
|
||||
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("PLC unreachable");
|
||||
}
|
||||
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("PLC unreachable");
|
||||
}
|
||||
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedPlcService : IPlcService
|
||||
{
|
||||
private readonly PlcSignalSnapshot[] _signals;
|
||||
private int _index;
|
||||
|
||||
public SequencedPlcService(PlcSignalSnapshot[] signals)
|
||||
{
|
||||
_signals = signals;
|
||||
}
|
||||
|
||||
public List<PlcProcessState> WrittenStates { get; } = new();
|
||||
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PlcSignalSnapshot current = _signals[Math.Min(Math.Max(_index - 1, 0), _signals.Length - 1)];
|
||||
PlcProcessState lastWritten = WrittenStates.Count > 0 ? WrittenStates[^1] : new PlcProcessState();
|
||||
return Task.FromResult(new PlcMonitorSnapshot
|
||||
{
|
||||
Inputs = new PlcSignalSnapshot
|
||||
{
|
||||
PcbArrived = current.PcbArrived,
|
||||
PlcReset = current.PlcReset,
|
||||
PlcAckRelease = current.PlcAckRelease,
|
||||
CapturedAt = DateTimeOffset.Now
|
||||
},
|
||||
Outputs = lastWritten.Clone(),
|
||||
CapturedAt = DateTimeOffset.Now
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var current = _signals[Math.Min(_index, _signals.Length - 1)];
|
||||
_index++;
|
||||
return Task.FromResult(current);
|
||||
}
|
||||
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
|
||||
{
|
||||
WrittenStates.Add(state.Clone());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeScannerService : IScannerService
|
||||
{
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
@@ -54,11 +354,30 @@ public sealed class WorkflowHostedServiceTests
|
||||
public Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken) => Task.FromResult(new ScanOperationResult());
|
||||
}
|
||||
|
||||
private sealed class CountingScannerService : IScannerService
|
||||
{
|
||||
public int TriggerCount { get; private set; }
|
||||
|
||||
public ScanOperationResult Result { get; set; } = new();
|
||||
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task<bool> TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true);
|
||||
|
||||
public Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
TriggerCount++;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSftpLookupService : ISftpLookupService
|
||||
{
|
||||
public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
|
||||
|
||||
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(new SftpCheckOutcome());
|
||||
public SftpCheckOutcome CheckOutcome { get; set; } = new();
|
||||
|
||||
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(CheckOutcome);
|
||||
|
||||
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user