using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Interfaces; using Xunit; namespace AxiOmron.PcbCheck.Tests; /// /// 验证流程后台服务的 SFTP 启动探活行为。 /// public sealed class WorkflowHostedServiceTests { /// /// 启动探活失败时,不应抛出异常,且应写入运行态状态。 /// /// 异步测试任务。 [Fact] public async Task ProbeSftpOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails() { var stateStore = new AppStateStore(); var service = new WorkflowHostedService( new FakePlcService(), new FakeScannerService(), new FakeSftpLookupService { TestOutcome = new SftpConnectionTestOutcome { IsSuccess = false, IsSystemError = true, StatusMessage = "启动探活失败" } }, new FakeAndonService(), stateStore, new AppConfig(), new FakeAppLogger()); await service.ProbeSftpOnStartupAsync(CancellationToken.None); Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus); } /// /// 启动探活 PLC 成功时,应立即将 PLC 状态更新为已连接。 /// /// 异步测试任务。 [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()); 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"); } /// /// 启动探活 PLC 失败时,应将状态更新为连接失败而不是保留默认未连接。 /// /// 异步测试任务。 [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()); await service.ProbePlcOnStartupAsync(CancellationToken.None); Assert.StartsWith("连接失败:", stateStore.GetSnapshot().PlcStatus); } /// /// 当 PLC 仅提供到位信号时,流程仍应启动,不再依赖就绪、自动模式和工位使能位。 /// /// 异步测试任务。 [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()); 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)); } /// /// 当到位信号保持高电平时,即使执行软件复位,也不应被视为新的上升沿重复触发流程。 /// /// 异步测试任务。 [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()); 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); } /// /// 应用启动时,应从记录仓储恢复当天最近记录与统计信息。 /// /// 异步测试任务。 [Fact] public async Task StartAsync_ShouldRestoreTodayRecords_FromRepository() { var stateStore = new AppStateStore(); var recordRepository = new FakeBoardRecordRepository { LoadResult = new BoardRecordLoadResult { RecentRecords = [ new BoardProcessRecord { StartedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)), CompletedAt = new DateTimeOffset(2026, 4, 19, 8, 1, 0, TimeSpan.FromHours(8)), Barcode = "PCB-RESTORED", ScanTryCount = 1, SftpTryCount = 1, ResultCode = (ushort)WorkflowResultCode.Passed, ResultDescription = "OK", ReleaseSent = true } ], Statistics = new BoardRecordStatistics { StatisticDate = new DateOnly(2026, 4, 19), TotalCount = 1, OkCount = 1, NgCount = 0 } } }; var service = new WorkflowHostedService( new FakePlcService(), new FakeScannerService(), new FakeSftpLookupService(), new FakeAndonService(), stateStore, new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20 } }, new FakeAppLogger(), recordRepository); await service.StartAsync(CancellationToken.None); await Task.Delay(80); await service.StopAsync(CancellationToken.None); RuntimeSnapshot snapshot = stateStore.GetSnapshot(); Assert.Single(snapshot.BoardRecords); Assert.Equal("PCB-RESTORED", snapshot.BoardRecords[0].Barcode); Assert.Equal(1, snapshot.TodayProcessCount); Assert.Equal(1, snapshot.TodayOkCount); Assert.Equal(0, snapshot.TodayNgCount); } /// /// 当 CSV 持久化失败时,不应让主流程进入故障态。 /// /// 异步测试任务。 [Fact] public async Task ExecuteAsync_ShouldNotEnterFault_WhenCsvAppendFails() { 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-CSV-FAIL" } }; var recordRepository = new FakeBoardRecordRepository { AppendException = new IOException("disk full") }; var service = new WorkflowHostedService( plcService, scannerService, new FakeSftpLookupService { CheckOutcome = new SftpCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-CSV-FAIL.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(), recordRepository); await service.StartAsync(CancellationToken.None); await Task.Delay(200); await service.StopAsync(CancellationToken.None); RuntimeSnapshot snapshot = stateStore.GetSnapshot(); Assert.NotEqual(WorkflowState.Faulted, snapshot.WorkflowState); Assert.Equal((ushort)WorkflowResultCode.Passed, snapshot.ResultCode); } private sealed class FakePlcService : IPlcService { public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot()); public Task 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 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 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 ReadMonitorSnapshotAsync(CancellationToken cancellationToken) { throw new InvalidOperationException("PLC unreachable"); } public Task 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 WrittenStates { get; } = new(); public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task 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 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; public Task TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true); public Task 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 TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true); public Task TriggerScanAsync(CancellationToken cancellationToken) { TriggerCount++; return Task.FromResult(Result); } } private sealed class FakeSftpLookupService : ISftpLookupService { public SftpConnectionTestOutcome TestOutcome { get; set; } = new(); public SftpCheckOutcome CheckOutcome { get; set; } = new(); public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(CheckOutcome); public Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken) { return Task.FromResult(TestOutcome); } } private sealed class FakeAndonService : IAndonService { public Task RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken) => Task.FromResult(new AndonOperationResult()); public Task TestAsync(CancellationToken cancellationToken) => Task.FromResult(new AndonOperationResult()); } private sealed class FakeBoardRecordRepository : IBoardRecordRepository { public BoardRecordLoadResult LoadResult { get; set; } = new(); public BoardRecordAppendResult AppendResult { get; set; } = new() { IsPersisted = true, Statistics = new BoardRecordStatistics { StatisticDate = DateOnly.FromDateTime(DateTime.Today) } }; public Exception? AppendException { get; set; } public Task LoadCurrentDayAsync(CancellationToken cancellationToken) { return Task.FromResult(LoadResult); } public Task ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken) { return Task.FromResult(null); } public Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken) { if (AppendException is not null) { throw AppendException; } return Task.FromResult(AppendResult); } } private sealed class FakeAppLogger : IAppLogger { public void LogError(string message, bool showInUi = false, params object?[] args) { } public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args) { } public void LogInformation(string message, bool showInUi = false, params object?[] args) { } public void LogWarning(string message, bool showInUi = false, params object?[] args) { } public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args) { } } }