using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Implementations; using Xunit; namespace AxiOmron.PcbCheck.Tests; /// /// 验证 CSV 处理记录仓储的持久化、恢复与坏行容错行为。 /// public sealed class CsvBoardRecordRepositoryTests : IDisposable { private readonly string _tempDirectory; /// /// 初始化测试临时目录。 /// public CsvBoardRecordRepositoryTests() { _tempDirectory = Path.Combine(Path.GetTempPath(), "AxiOmron.PcbCheck.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDirectory); } /// /// 首次追加记录后,应创建按天 CSV,并能从当天文件恢复记录与统计。 /// [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"))); } /// /// 当 CSV 中存在坏行时,应跳过坏行并继续恢复其余有效记录。 /// [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); } /// /// 当系统时间跨过 0 点时,应自动切换到新一天数据视图。 /// [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); } /// /// 释放测试产生的临时目录。 /// public void Dispose() { if (Directory.Exists(_tempDirectory)) { Directory.Delete(_tempDirectory, true); } } /// /// 创建测试用处理记录。 /// /// 条码。 /// 完成时间。 /// 结果代码。 /// 测试记录。 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 }; } /// /// 提供可控本地时间的测试时钟。 /// private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _localNow; private TimeZoneInfo _localTimeZone; /// /// 初始化测试时钟。 /// /// 当前本地时间。 public FakeTimeProvider(DateTimeOffset localNow) { _localNow = localNow; _localTimeZone = TimeZoneInfo.CreateCustomTimeZone( $"Fake-{localNow.Offset}", localNow.Offset, "Fake Local", "Fake Local"); } /// /// 获取当前 UTC 时间。 /// /// 当前 UTC 时间。 public override DateTimeOffset GetUtcNow() { return _localNow.ToUniversalTime(); } /// /// 获取当前本地时区。 /// public override TimeZoneInfo LocalTimeZone { get { return _localTimeZone; } } /// /// 设置当前本地时间。 /// /// 新的本地时间。 public void SetLocalNow(DateTimeOffset localNow) { _localNow = localNow; _localTimeZone = TimeZoneInfo.CreateCustomTimeZone( $"Fake-{localNow.Offset}", localNow.Offset, "Fake Local", "Fake Local"); } } }