✨ feat(*): 添加 PCB 检测记录的 CSV 持久化存储功能
* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储 * 新增 RecordPersistenceModels 定义板件检测记录数据结构 * 在 WorkflowHostedService 中集成检测完成后的记录持久化 * 更新 MainWindowViewModel 支持记录查询与异常标记 * 更新 AppStateStore 添加记录存储相关状态管理 * 新增 DashboardPage UI 元素展示记录存储状态 * 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置 * 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器 * 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests * 配置 Release 模式 portable PDB 并在发布时自动移除
This commit is contained in:
@@ -228,6 +228,139 @@ public sealed class WorkflowHostedServiceTests
|
||||
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;
|
||||
@@ -394,6 +527,42 @@ public sealed class WorkflowHostedServiceTests
|
||||
=> 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) { }
|
||||
|
||||
Reference in New Issue
Block a user