* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储 * 新增 RecordPersistenceModels 定义板件检测记录数据结构 * 在 WorkflowHostedService 中集成检测完成后的记录持久化 * 更新 MainWindowViewModel 支持记录查询与异常标记 * 更新 AppStateStore 添加记录存储相关状态管理 * 新增 DashboardPage UI 元素展示记录存储状态 * 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置 * 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器 * 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests * 配置 Release 模式 portable PDB 并在发布时自动移除
575 lines
21 KiB
C#
575 lines
21 KiB
C#
using AxiOmron.PcbCheck.Models;
|
|
using AxiOmron.PcbCheck.Options;
|
|
using AxiOmron.PcbCheck.Services.Implementations;
|
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
|
using Xunit;
|
|
|
|
namespace AxiOmron.PcbCheck.Tests;
|
|
|
|
/// <summary>
|
|
/// 验证流程后台服务的 SFTP 启动探活行为。
|
|
/// </summary>
|
|
public sealed class WorkflowHostedServiceTests
|
|
{
|
|
/// <summary>
|
|
/// 启动探活失败时,不应抛出异常,且应写入运行态状态。
|
|
/// </summary>
|
|
/// <returns>异步测试任务。</returns>
|
|
[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<WorkflowHostedService>());
|
|
|
|
await service.ProbeSftpOnStartupAsync(CancellationToken.None);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 应用启动时,应从记录仓储恢复当天最近记录与统计信息。
|
|
/// </summary>
|
|
/// <returns>异步测试任务。</returns>
|
|
[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<WorkflowHostedService>(),
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当 CSV 持久化失败时,不应让主流程进入故障态。
|
|
/// </summary>
|
|
/// <returns>异步测试任务。</returns>
|
|
[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<WorkflowHostedService>(),
|
|
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<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;
|
|
public Task<bool> TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true);
|
|
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 SftpCheckOutcome CheckOutcome { get; set; } = new();
|
|
|
|
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(CheckOutcome);
|
|
|
|
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(TestOutcome);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeAndonService : IAndonService
|
|
{
|
|
public Task<AndonOperationResult> RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(new AndonOperationResult());
|
|
|
|
public Task<AndonOperationResult> 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<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(LoadResult);
|
|
}
|
|
|
|
public Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult<BoardRecordLoadResult?>(null);
|
|
}
|
|
|
|
public Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken)
|
|
{
|
|
if (AppendException is not null)
|
|
{
|
|
throw AppendException;
|
|
}
|
|
|
|
return Task.FromResult(AppendResult);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeAppLogger<TCategoryName> : IAppLogger<TCategoryName>
|
|
{
|
|
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) { }
|
|
}
|
|
}
|