* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储 * 新增 RecordPersistenceModels 定义板件检测记录数据结构 * 在 WorkflowHostedService 中集成检测完成后的记录持久化 * 更新 MainWindowViewModel 支持记录查询与异常标记 * 更新 AppStateStore 添加记录存储相关状态管理 * 新增 DashboardPage UI 元素展示记录存储状态 * 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置 * 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器 * 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests * 配置 Release 模式 portable PDB 并在发布时自动移除
208 lines
7.4 KiB
C#
208 lines
7.4 KiB
C#
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");
|
||
}
|
||
}
|
||
}
|