using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Interfaces; using Xunit; namespace AxiOmron.PcbCheck.Tests; /// /// 验证流程后台服务的文件校验启动探活行为。 /// public sealed class WorkflowHostedServiceTests { /// /// 启动探活失败时,不应抛出异常,且应写入运行态状态。 /// /// 异步测试任务。 [Fact] public async Task ProbeFileCheckOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails() { var stateStore = new AppStateStore(); var service = new WorkflowHostedService( new FakePlcService(), new FakeScannerService(), new FakeFileCheckService { TestOutcome = new FileCheckTestOutcome { IsSuccess = false, IsSystemError = true, StatusMessage = "启动探活失败" } }, new FakeAndonService(), stateStore, new AppConfig(), new FakeAppLogger()); await service.ProbeFileCheckOnStartupAsync(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 FakeFileCheckService(), 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 FakeFileCheckService(), 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 fileCheckService = new FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-001.txt" } }; var service = new WorkflowHostedService( plcService, scannerService, fileCheckService, new FakeAndonService(), stateStore, new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 100, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { 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)); } /// /// 启用应答后删除功能时,收到 PLC 放行应答后应删除本次精确命中的产品 ID 文件。 /// /// 异步测试任务。 [Fact] public async Task ExecuteAsync_ShouldDeleteMatchedFile_WhenReleaseAckReceivedAndDeletionEnabled() { const string matchedFilePath = "/pcb/PCB-DELETE-001.txt"; var fileCheckService = new FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = matchedFilePath } }; var service = new WorkflowHostedService( new SequencedPlcService(new[] { new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true }, new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true } }), new CountingScannerService { Result = new ScanOperationResult { IsSuccess = true, DeviceConnected = true, Barcode = "PCB-DELETE-001" } }, fileCheckService, new FakeAndonService(), new AppStateStore(), new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 100, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { MaxRetryCount = 0, DeleteMatchedFileAfterPlcAck = true } }, new FakeAppLogger()); await service.StartAsync(CancellationToken.None); await Task.Delay(200); await service.StopAsync(CancellationToken.None); Assert.Equal(matchedFilePath, Assert.Single(fileCheckService.DeletedFilePaths)); } /// /// 未收到 PLC 放行应答时,即使启用删除功能也不得删除产品 ID 文件。 /// /// 异步测试任务。 [Fact] public async Task ExecuteAsync_ShouldNotDeleteMatchedFile_WhenReleaseAckTimesOut() { var fileCheckService = new FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-KEEP-001.txt" } }; var service = new WorkflowHostedService( new SequencedPlcService(new[] { new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = false }, new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = false } }), new CountingScannerService { Result = new ScanOperationResult { IsSuccess = true, DeviceConnected = true, Barcode = "PCB-KEEP-001" } }, fileCheckService, new FakeAndonService(), new AppStateStore(), new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 50, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { MaxRetryCount = 0, DeleteMatchedFileAfterPlcAck = true } }, new FakeAppLogger()); await service.StartAsync(CancellationToken.None); await Task.Delay(200); await service.StopAsync(CancellationToken.None); Assert.Empty(fileCheckService.DeletedFilePaths); } /// /// 删除开关关闭时,即使收到 PLC 放行应答也不得删除产品 ID 文件。 /// /// 异步测试任务。 [Fact] public async Task ExecuteAsync_ShouldNotDeleteMatchedFile_WhenDeletionDisabled() { var fileCheckService = new FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-KEEP-002.txt" } }; var service = new WorkflowHostedService( new SequencedPlcService(new[] { new PlcSignalSnapshot { PcbArrived = true, PlcAckRelease = true }, new PlcSignalSnapshot { PcbArrived = false, PlcAckRelease = true } }), new CountingScannerService { Result = new ScanOperationResult { IsSuccess = true, DeviceConnected = true, Barcode = "PCB-KEEP-002" } }, fileCheckService, new FakeAndonService(), new AppStateStore(), new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 100, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { MaxRetryCount = 0, DeleteMatchedFileAfterPlcAck = false } }, new FakeAppLogger()); await service.StartAsync(CancellationToken.None); await Task.Delay(200); await service.StopAsync(CancellationToken.None); Assert.Empty(fileCheckService.DeletedFilePaths); } /// /// 双扫码汇总后,应使用汇总条码继续执行统一文件校验。 /// /// 异步测试任务。 [Fact] public async Task ExecuteAsync_ShouldCheckFileWithMergedBarcode_WhenScannerServiceReturnsMergedBarcode() { 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-MERGED", RawMessage = "使用扫码枪2" } }; var fileCheckService = new FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-MERGED.txt" } }; var service = new WorkflowHostedService( plcService, scannerService, fileCheckService, new FakeAndonService(), stateStore, new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 100, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { MaxRetryCount = 0 } }, new FakeAppLogger()); await service.StartAsync(CancellationToken.None); await Task.Delay(200); await service.StopAsync(CancellationToken.None); Assert.Equal(1, scannerService.TriggerCount); Assert.Equal("PCB-MERGED", fileCheckService.LastBarcode); Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed); } /// /// 当到位信号保持高电平时,即使执行软件复位,也不应被视为新的上升沿重复触发流程。 /// /// 异步测试任务。 [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 FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-EDGE-001.txt" } }, new FakeAndonService(), stateStore, new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 100, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { 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 FakeFileCheckService(), 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 FakeFileCheckService { CheckOutcome = new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = "/pcb/PCB-CSV-FAIL.txt" } }, new FakeAndonService(), stateStore, new AppConfig { Plc = new PlcOptions { PollIntervalMs = 20, ReleaseAckTimeoutMs = 100, ReleasePulseMs = 10 }, FileCheck = new FileCheckOptions { 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; public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) => Task.FromResult(PlcWriteResult.Success(address, type, value)); } 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; } public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) { return Task.FromResult(PlcWriteResult.Success(address, type, value)); } } 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; } public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) { throw new InvalidOperationException("PLC unreachable"); } } 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; } public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) { return Task.FromResult(PlcWriteResult.Success(address, type, value)); } } 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 FakeFileCheckService : IFileCheckService { public FileCheckTestOutcome TestOutcome { get; set; } = new(); public FileCheckOutcome CheckOutcome { get; set; } = new(); public FileDeleteOutcome DeleteOutcome { get; set; } = new() { IsSuccess = true }; public string LastBarcode { get; private set; } = string.Empty; public List DeletedFilePaths { get; } = new(); public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) { LastBarcode = barcode; return Task.FromResult(CheckOutcome); } public Task DeleteMatchedFileAsync(string filePath, CancellationToken cancellationToken) { DeletedFilePaths.Add(filePath); DeleteOutcome.FilePath = filePath; return Task.FromResult(DeleteOutcome); } public Task TestConnectionAsync(AppConfig? config, 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) { } } }