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:
2026-04-19 15:39:31 +08:00
parent d70b94e904
commit 9a2211ccaa
17 changed files with 1802 additions and 89 deletions

View File

@@ -0,0 +1,207 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证 CSV 处理记录仓储的持久化、恢复与坏行容错行为。
/// </summary>
public sealed class CsvBoardRecordRepositoryTests : IDisposable
{
private readonly string _tempDirectory;
/// <summary>
/// 初始化测试临时目录。
/// </summary>
public CsvBoardRecordRepositoryTests()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), "AxiOmron.PcbCheck.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDirectory);
}
/// <summary>
/// 首次追加记录后,应创建按天 CSV并能从当天文件恢复记录与统计。
/// </summary>
[Fact]
public async Task AppendAsync_ShouldCreateDailyCsv_AndLoadTodayAsync_ShouldRecoverStatistics()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)));
var repository = new CsvBoardRecordRepository(
new RecordPersistenceOptions
{
DirectoryPath = _tempDirectory,
DayChangeCheckIntervalSeconds = 30
},
timeProvider);
await repository.AppendAsync(
CreateRecord("PCB-001", new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.Passed),
CancellationToken.None);
await repository.AppendAsync(
CreateRecord("PCB-002", new DateTimeOffset(2026, 4, 19, 8, 5, 0, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.NgReleased),
CancellationToken.None);
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
Assert.Equal(2, result.RecentRecords.Count);
Assert.Equal(2, result.Statistics.TotalCount);
Assert.Equal(1, result.Statistics.OkCount);
Assert.Equal(1, result.Statistics.NgCount);
Assert.Equal(new DateOnly(2026, 4, 19), result.Statistics.StatisticDate);
Assert.True(File.Exists(Path.Combine(_tempDirectory, "2026-04-19.csv")));
}
/// <summary>
/// 当 CSV 中存在坏行时,应跳过坏行并继续恢复其余有效记录。
/// </summary>
[Fact]
public async Task LoadCurrentDayAsync_ShouldSkipBrokenRows_AndContinue()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)));
string filePath = Path.Combine(_tempDirectory, "2026-04-19.csv");
await File.WriteAllTextAsync(
filePath,
"""
StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary
broken,row
2026-04-19 08:00:00.000,2026-04-19 08:01:00.000,PCB-001,1,1,10,OK,True,False,
""");
var repository = new CsvBoardRecordRepository(
new RecordPersistenceOptions
{
DirectoryPath = _tempDirectory,
DayChangeCheckIntervalSeconds = 30
},
timeProvider);
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
Assert.Single(result.RecentRecords);
Assert.Equal("PCB-001", result.RecentRecords[0].Barcode);
Assert.Equal(1, result.Statistics.TotalCount);
}
/// <summary>
/// 当系统时间跨过 0 点时,应自动切换到新一天数据视图。
/// </summary>
[Fact]
public async Task ReloadCurrentDayIfDateChangedAsync_ShouldSwitchToNewDay_WhenClockMovesPastMidnight()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 19, 23, 59, 50, TimeSpan.FromHours(8)));
var repository = new CsvBoardRecordRepository(
new RecordPersistenceOptions
{
DirectoryPath = _tempDirectory,
DayChangeCheckIntervalSeconds = 1
},
timeProvider);
await repository.AppendAsync(
CreateRecord("PCB-OLD", new DateTimeOffset(2026, 4, 19, 23, 59, 55, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.Passed),
CancellationToken.None);
timeProvider.SetLocalNow(new DateTimeOffset(2026, 4, 20, 0, 0, 10, TimeSpan.FromHours(8)));
BoardRecordLoadResult? result = await repository.ReloadCurrentDayIfDateChangedAsync(CancellationToken.None);
Assert.NotNull(result);
Assert.Empty(result.RecentRecords);
Assert.Equal(new DateOnly(2026, 4, 20), result.Statistics.StatisticDate);
Assert.Equal(0, result.Statistics.TotalCount);
}
/// <summary>
/// 释放测试产生的临时目录。
/// </summary>
public void Dispose()
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, true);
}
}
/// <summary>
/// 创建测试用处理记录。
/// </summary>
/// <param name="barcode">条码。</param>
/// <param name="completedAt">完成时间。</param>
/// <param name="resultCode">结果代码。</param>
/// <returns>测试记录。</returns>
private static BoardProcessRecord CreateRecord(string barcode, DateTimeOffset completedAt, ushort resultCode)
{
return new BoardProcessRecord
{
StartedAt = completedAt.AddSeconds(-5),
CompletedAt = completedAt,
Barcode = barcode,
ScanTryCount = 1,
SftpTryCount = 1,
ResultCode = resultCode,
ResultDescription = resultCode == (ushort)WorkflowResultCode.Passed ? "OK" : "NG",
ReleaseSent = true,
AlarmRaised = false,
ExceptionSummary = string.Empty
};
}
/// <summary>
/// 提供可控本地时间的测试时钟。
/// </summary>
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _localNow;
private TimeZoneInfo _localTimeZone;
/// <summary>
/// 初始化测试时钟。
/// </summary>
/// <param name="localNow">当前本地时间。</param>
public FakeTimeProvider(DateTimeOffset localNow)
{
_localNow = localNow;
_localTimeZone = TimeZoneInfo.CreateCustomTimeZone(
$"Fake-{localNow.Offset}",
localNow.Offset,
"Fake Local",
"Fake Local");
}
/// <summary>
/// 获取当前 UTC 时间。
/// </summary>
/// <returns>当前 UTC 时间。</returns>
public override DateTimeOffset GetUtcNow()
{
return _localNow.ToUniversalTime();
}
/// <summary>
/// 获取当前本地时区。
/// </summary>
public override TimeZoneInfo LocalTimeZone
{
get
{
return _localTimeZone;
}
}
/// <summary>
/// 设置当前本地时间。
/// </summary>
/// <param name="localNow">新的本地时间。</param>
public void SetLocalNow(DateTimeOffset localNow)
{
_localNow = localNow;
_localTimeZone = TimeZoneInfo.CreateCustomTimeZone(
$"Fake-{localNow.Offset}",
localNow.Offset,
"Fake Local",
"Fake Local");
}
}
}

View File

@@ -0,0 +1,236 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.ViewModels;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证首页视图模型对处理记录和统计区域的绑定行为。
/// </summary>
public sealed class MainWindowViewModelTests
{
/// <summary>
/// 构造时应使用运行态快照中的统计值,而不是仅根据最近 100 条列表重新计算。
/// </summary>
[Fact]
public void Constructor_ShouldUseSnapshotStatistics_InsteadOfRecentListCount()
{
var stateStore = new AppStateStore();
stateStore.UpdateSnapshot(snapshot =>
{
snapshot.RecordStatisticDate = new DateOnly(2026, 4, 19);
snapshot.TodayProcessCount = 150;
snapshot.TodayOkCount = 120;
snapshot.TodayNgCount = 30;
});
var viewModel = CreateViewModel(stateStore);
Assert.Equal(150, viewModel.TodayProcessCount);
Assert.Equal(120, viewModel.TodayOkCount);
Assert.Equal(30, viewModel.TodayNgCount);
Assert.Equal("2026-04-19", viewModel.RecordStatisticDateText);
}
/// <summary>
/// 构造时应从快照恢复最近 100 条记录,并按完成时间倒序展示。
/// </summary>
[Fact]
public void Constructor_ShouldLoadRecentRecords_FromSnapshot_AndLimitTo100()
{
var stateStore = new AppStateStore();
var records = Enumerable.Range(1, 120)
.Select(index => new BoardProcessRecord
{
StartedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)).AddMinutes(index).AddSeconds(-10),
CompletedAt = new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)).AddMinutes(index),
Barcode = $"PCB-{index:D3}",
ScanTryCount = 1,
SftpTryCount = 1,
ResultCode = index % 2 == 0 ? (ushort)WorkflowResultCode.Passed : (ushort)WorkflowResultCode.NgReleased,
ResultDescription = index % 2 == 0 ? "OK" : "NG",
ReleaseSent = true,
AlarmRaised = false
})
.ToList();
stateStore.ReplaceRecords(
records,
new BoardRecordStatistics
{
StatisticDate = new DateOnly(2026, 4, 19),
TotalCount = 120,
OkCount = 60,
NgCount = 60
});
var viewModel = CreateViewModel(stateStore);
Assert.Equal(100, viewModel.RecentProcessRecords.Count);
Assert.Equal("PCB-120", viewModel.RecentProcessRecords[0].Barcode);
Assert.Equal("PCB-021", viewModel.RecentProcessRecords[^1].Barcode);
}
/// <summary>
/// 当 UI 调度已被取消时,快照事件不应将取消异常继续抛到同步上下文。
/// </summary>
[Fact]
public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask()
{
var stateStore = new AppStateStore();
var viewModel = CreateViewModel(stateStore, new CanceledDispatcherService());
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
SynchronizationContext? originalContext = SynchronizationContext.Current;
try
{
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
stateStore.UpdateSnapshot(snapshot => snapshot.WorkflowStateText = "运行中");
Assert.Null(synchronizationContext.CapturedException);
Assert.Equal("空闲等待", viewModel.WorkflowStateText);
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
}
}
/// <summary>
/// 当 UI 调度已被取消时,日志事件不应将取消异常继续抛到同步上下文。
/// </summary>
[Fact]
public void LogAdded_ShouldIgnoreCanceledDispatcherTask()
{
var stateStore = new AppStateStore();
var viewModel = CreateViewModel(stateStore, new CanceledDispatcherService());
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
SynchronizationContext? originalContext = SynchronizationContext.Current;
try
{
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
stateStore.AddLog(new UiLogEntry
{
Timestamp = DateTimeOffset.Now,
Level = "Information",
Message = "测试日志"
});
Assert.Null(synchronizationContext.CapturedException);
Assert.Empty(viewModel.Logs);
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
}
}
/// <summary>
/// 创建测试用首页视图模型。
/// </summary>
/// <param name="stateStore">运行态存储。</param>
/// <returns>首页视图模型。</returns>
private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null)
{
return new MainWindowViewModel(
stateStore,
dispatcherService ?? new ImmediateDispatcherService(),
new FakeWorkflowControlService(),
new AppConfig
{
Workflow = new WorkflowOptions
{
MaxBoardRecords = 100,
MaxUiLogEntries = 200
},
Security = new SecurityOptions
{
AdminPassword = "test"
}
},
new FakeAdminUnlockDialogService());
}
/// <summary>
/// 捕获 async void 事件处理器回抛到同步上下文中的异常。
/// </summary>
private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext
{
/// <summary>
/// 获取或设置捕获到的异常。
/// </summary>
public Exception? CapturedException { get; private set; }
/// <summary>
/// 立即执行回调并记录内部异常,避免测试进程直接崩溃。
/// </summary>
/// <param name="d">待执行回调。</param>
/// <param name="state">回调状态。</param>
public override void Post(SendOrPostCallback d, object? state)
{
ArgumentNullException.ThrowIfNull(d);
try
{
d(state);
}
catch (Exception ex)
{
CapturedException = ex;
}
}
}
/// <summary>
/// 提供立即执行的测试调度服务。
/// </summary>
private sealed class ImmediateDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
action();
return Task.CompletedTask;
}
}
/// <summary>
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
/// </summary>
private sealed class CanceledDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
return Task.FromCanceled(new CancellationToken(canceled: true));
}
}
/// <summary>
/// 提供空实现的流程控制服务。
/// </summary>
private sealed class FakeWorkflowControlService : IWorkflowControlService
{
public Task ReconnectPlcAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReconnectScannerAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task ResetAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task TestAndonAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>
/// 提供空实现的管理员解锁弹窗服务。
/// </summary>
private sealed class FakeAdminUnlockDialogService : IAdminUnlockDialogService
{
public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) => true;
}
}

View File

@@ -148,6 +148,46 @@ public sealed class SystemSettingViewModelTests
Assert.Null(backgroundException);
}
/// <summary>
/// 当 UI 调度已被取消时,系统设置页的快照事件不应将取消异常继续抛到同步上下文。
/// </summary>
[Fact]
public void SnapshotChanged_ShouldIgnoreCanceledDispatcherTask()
{
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var viewModel = new SystemSettingViewModel(
configService,
new FakeSftpLookupService(),
stateStore,
new CanceledDispatcherService());
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
SynchronizationContext? originalContext = SynchronizationContext.Current;
try
{
SynchronizationContext.SetSynchronizationContext(synchronizationContext);
stateStore.UpdateSnapshot(snapshot =>
{
snapshot.PlcMonitorItems.Add(new PlcMonitorItem
{
GroupName = "Inputs",
Name = "PcbArrived",
CurrentValue = "True",
LastUpdatedAt = DateTimeOffset.Now
});
});
Assert.Null(synchronizationContext.CapturedException);
Assert.Empty(viewModel.PlcMonitorItems);
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
}
}
/// <summary>
/// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。
/// </summary>
@@ -269,6 +309,48 @@ public sealed class SystemSettingViewModelTests
}
}
/// <summary>
/// 捕获 async void 事件处理器回抛到同步上下文中的异常。
/// </summary>
private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext
{
/// <summary>
/// 获取或设置捕获到的异常。
/// </summary>
public Exception? CapturedException { get; private set; }
/// <summary>
/// 立即执行回调并记录内部异常,避免测试进程直接崩溃。
/// </summary>
/// <param name="d">待执行回调。</param>
/// <param name="state">回调状态。</param>
public override void Post(SendOrPostCallback d, object? state)
{
ArgumentNullException.ThrowIfNull(d);
try
{
d(state);
}
catch (Exception ex)
{
CapturedException = ex;
}
}
}
/// <summary>
/// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。
/// </summary>
private sealed class CanceledDispatcherService : IDispatcherService
{
public Task InvokeAsync(Action action)
{
ArgumentNullException.ThrowIfNull(action);
return Task.FromCanceled(new CancellationToken(canceled: true));
}
}
/// <summary>
/// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。
/// </summary>

View File

@@ -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) { }