Files
Axi_Omron/tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs
yunxiao.zhu 9a2211ccaa feat(*): 添加 PCB 检测记录的 CSV 持久化存储功能
* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储
* 新增 RecordPersistenceModels 定义板件检测记录数据结构
* 在 WorkflowHostedService 中集成检测完成后的记录持久化
* 更新 MainWindowViewModel 支持记录查询与异常标记
* 更新 AppStateStore 添加记录存储相关状态管理
* 新增 DashboardPage UI 元素展示记录存储状态
* 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置
* 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器
* 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests
* 配置 Release 模式 portable PDB 并在发布时自动移除
2026-04-19 15:39:31 +08:00

208 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
}
}
}