From 9a2211ccaa95a12456c551b4b7a32e7ed6e3170d Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Sun, 19 Apr 2026 15:39:31 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(*):=20=E6=B7=BB=E5=8A=A0=20PCB?= =?UTF-8?q?=20=E6=A3=80=E6=B5=8B=E8=AE=B0=E5=BD=95=E7=9A=84=20CSV=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E5=AD=98=E5=82=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储 * 新增 RecordPersistenceModels 定义板件检测记录数据结构 * 在 WorkflowHostedService 中集成检测完成后的记录持久化 * 更新 MainWindowViewModel 支持记录查询与异常标记 * 更新 AppStateStore 添加记录存储相关状态管理 * 新增 DashboardPage UI 元素展示记录存储状态 * 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置 * 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器 * 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests * 配置 Release 模式 portable PDB 并在发布时自动移除 --- src/AxiOmron.PcbCheck/App.xaml.cs | 2 + .../DesignTime/DesignTimeAppStateStore.cs | 68 +++ .../Models/RecordPersistenceModels.cs | 80 +++ .../Models/RuntimeSnapshot.cs | 64 ++- src/AxiOmron.PcbCheck/Options/AppConfig.cs | 21 + .../Services/Implementations/AppStateStore.cs | 56 ++ .../CsvBoardRecordRepository.cs | 504 ++++++++++++++++++ .../Implementations/WorkflowHostedService.cs | 141 ++++- .../Services/Interfaces/CoreInterfaces.cs | 44 ++ .../ViewModels/MainWindowViewModel.cs | 148 ++--- .../ViewModels/SystemSettingViewModel.cs | 19 +- .../Views/Pages/DashboardPage.xaml | 46 +- src/AxiOmron.PcbCheck/appConfig.json | 4 + .../CsvBoardRecordRepositoryTests.cs | 207 +++++++ .../MainWindowViewModelTests.cs | 236 ++++++++ .../SystemSettingViewModelTests.cs | 82 +++ .../WorkflowHostedServiceTests.cs | 169 ++++++ 17 files changed, 1802 insertions(+), 89 deletions(-) create mode 100644 src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs diff --git a/src/AxiOmron.PcbCheck/App.xaml.cs b/src/AxiOmron.PcbCheck/App.xaml.cs index 5565165..900259f 100644 --- a/src/AxiOmron.PcbCheck/App.xaml.cs +++ b/src/AxiOmron.PcbCheck/App.xaml.cs @@ -116,12 +116,14 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(typeof(IAppLogger<>), typeof(AppLogger<>)); services.AddSingleton(); + services.AddSingleton(TimeProvider.System); services.AddHttpClient(nameof(AndonService)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs index c19b232..063cb9d 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs @@ -94,6 +94,28 @@ public sealed class DesignTimeAppStateStore : IAppStateStore ExceptionSummary = "SFTP 文件查询超时" } }; + + _snapshot.RecordStatisticDate = DateOnly.FromDateTime(now.LocalDateTime.Date); + _snapshot.TodayProcessCount = _records.Count; + _snapshot.TodayOkCount = _records.Count(record => record.ResultCode == (ushort)WorkflowResultCode.Passed); + _snapshot.TodayNgCount = _records.Count(record => record.ResultCode != (ushort)WorkflowResultCode.Passed); + _snapshot.RecordsLastUpdatedAt = now; + foreach (BoardProcessRecord record in _records) + { + _snapshot.BoardRecords.Add(new BoardProcessRecord + { + StartedAt = record.StartedAt, + CompletedAt = record.CompletedAt, + Barcode = record.Barcode, + ScanTryCount = record.ScanTryCount, + SftpTryCount = record.SftpTryCount, + ResultCode = record.ResultCode, + ResultDescription = record.ResultDescription, + ReleaseSent = record.ReleaseSent, + AlarmRaised = record.AlarmRaised, + ExceptionSummary = record.ExceptionSummary + }); + } } /// @@ -180,6 +202,52 @@ public sealed class DesignTimeAppStateStore : IAppStateStore _snapshotChanged?.Invoke(this, _snapshot.Clone()); } + /// + /// 使用新的最近记录与统计结果替换当前设计时历史区状态。 + /// + /// 最近记录集合。 + /// 统计结果。 + public void ReplaceRecords(IReadOnlyList records, BoardRecordStatistics statistics) + { + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(statistics); + + _snapshot.BoardRecords.Clear(); + foreach (BoardProcessRecord record in records) + { + _snapshot.BoardRecords.Add(new BoardProcessRecord + { + StartedAt = record.StartedAt, + CompletedAt = record.CompletedAt, + Barcode = record.Barcode, + ScanTryCount = record.ScanTryCount, + SftpTryCount = record.SftpTryCount, + ResultCode = record.ResultCode, + ResultDescription = record.ResultDescription, + ReleaseSent = record.ReleaseSent, + AlarmRaised = record.AlarmRaised, + ExceptionSummary = record.ExceptionSummary + }); + } + + UpdateRecordStatistics(statistics); + } + + /// + /// 更新当前设计时统计结果。 + /// + /// 统计结果。 + public void UpdateRecordStatistics(BoardRecordStatistics statistics) + { + ArgumentNullException.ThrowIfNull(statistics); + _snapshot.RecordStatisticDate = statistics.StatisticDate; + _snapshot.TodayProcessCount = statistics.TotalCount; + _snapshot.TodayOkCount = statistics.OkCount; + _snapshot.TodayNgCount = statistics.NgCount; + _snapshot.RecordsLastUpdatedAt = statistics.LastUpdatedAt; + _snapshotChanged?.Invoke(this, _snapshot.Clone()); + } + /// /// 追加一条设计时日志并通知订阅者。 /// diff --git a/src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs b/src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs new file mode 100644 index 0000000..bb5412f --- /dev/null +++ b/src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs @@ -0,0 +1,80 @@ +namespace AxiOmron.PcbCheck.Models; + +/// +/// 表示当前统计日期对应的处理记录聚合结果。 +/// +public sealed class BoardRecordStatistics +{ + /// + /// 获取或设置统计日期。 + /// + public DateOnly StatisticDate { get; set; } = DateOnly.FromDateTime(DateTime.Today); + + /// + /// 获取或设置处理总数。 + /// + public int TotalCount { get; set; } + + /// + /// 获取或设置 OK 数。 + /// + public int OkCount { get; set; } + + /// + /// 获取或设置 NG 数。 + /// + public int NgCount { get; set; } + + /// + /// 获取或设置最近一次统计更新时间。 + /// + public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now; + + /// + /// 创建当前统计对象的副本。 + /// + /// 统计对象副本。 + public BoardRecordStatistics Clone() + { + return new BoardRecordStatistics + { + StatisticDate = StatisticDate, + TotalCount = TotalCount, + OkCount = OkCount, + NgCount = NgCount, + LastUpdatedAt = LastUpdatedAt + }; + } +} + +/// +/// 表示一次当前日期数据加载结果。 +/// +public class BoardRecordLoadResult +{ + /// + /// 获取或设置最近记录集合,按完成时间倒序排列。 + /// + public IReadOnlyList RecentRecords { get; set; } = Array.Empty(); + + /// + /// 获取或设置当前统计结果。 + /// + public BoardRecordStatistics Statistics { get; set; } = new(); +} + +/// +/// 表示一次处理记录追加写入后的结果。 +/// +public sealed class BoardRecordAppendResult : BoardRecordLoadResult +{ + /// + /// 获取或设置本次持久化是否成功。 + /// + public bool IsPersisted { get; set; } + + /// + /// 获取或设置失败原因。 + /// + public string ErrorMessage { get; set; } = string.Empty; +} diff --git a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs index e5599eb..633a0c9 100644 --- a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs +++ b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs @@ -10,6 +10,11 @@ public sealed class RuntimeSnapshot /// public IList PlcMonitorItems { get; } = new List(); + /// + /// 获取最近处理记录集合。 + /// + public IList BoardRecords { get; } = new List(); + /// /// 获取或设置 PLC 连接状态文本。 /// @@ -80,6 +85,31 @@ public sealed class RuntimeSnapshot /// public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now; + /// + /// 获取或设置当前统计日期。 + /// + public DateOnly? RecordStatisticDate { get; set; } + + /// + /// 获取或设置今日处理总数。 + /// + public int TodayProcessCount { get; set; } + + /// + /// 获取或设置今日 OK 数。 + /// + public int TodayOkCount { get; set; } + + /// + /// 获取或设置今日 NG 数。 + /// + public int TodayNgCount { get; set; } + + /// + /// 获取或设置处理记录数据最近更新时间。 + /// + public DateTimeOffset RecordsLastUpdatedAt { get; set; } = DateTimeOffset.MinValue; + /// /// 创建当前快照的副本。 /// @@ -101,7 +131,12 @@ public sealed class RuntimeSnapshot LastTriggeredAt = LastTriggeredAt, LastCompletedAt = LastCompletedAt, IsBusy = IsBusy, - LastUpdatedAt = LastUpdatedAt + LastUpdatedAt = LastUpdatedAt, + RecordStatisticDate = RecordStatisticDate, + TodayProcessCount = TodayProcessCount, + TodayOkCount = TodayOkCount, + TodayNgCount = TodayNgCount, + RecordsLastUpdatedAt = RecordsLastUpdatedAt }; foreach (PlcMonitorItem item in PlcMonitorItems) @@ -109,6 +144,33 @@ public sealed class RuntimeSnapshot clone.PlcMonitorItems.Add(item.Clone()); } + foreach (BoardProcessRecord record in BoardRecords) + { + clone.BoardRecords.Add(CloneRecord(record)); + } + return clone; } + + /// + /// 创建处理记录对象的副本。 + /// + /// 待复制的处理记录。 + /// 处理记录副本。 + private static BoardProcessRecord CloneRecord(BoardProcessRecord record) + { + return new BoardProcessRecord + { + StartedAt = record.StartedAt, + CompletedAt = record.CompletedAt, + Barcode = record.Barcode, + ScanTryCount = record.ScanTryCount, + SftpTryCount = record.SftpTryCount, + ResultCode = record.ResultCode, + ResultDescription = record.ResultDescription, + ReleaseSent = record.ReleaseSent, + AlarmRaised = record.AlarmRaised, + ExceptionSummary = record.ExceptionSummary + }; + } } diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs index 5635d2d..c8fdd3e 100644 --- a/src/AxiOmron.PcbCheck/Options/AppConfig.cs +++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs @@ -30,6 +30,11 @@ public sealed class AppConfig /// public WorkflowOptions Workflow { get; set; } = new(); + /// + /// 获取或设置处理记录持久化配置。 + /// + public RecordPersistenceOptions RecordPersistence { get; set; } = new(); + /// /// 获取或设置安全控制配置。 /// @@ -286,6 +291,22 @@ public sealed class WorkflowOptions public int MaxBoardRecords { get; set; } = 100; } +/// +/// 表示处理记录 CSV 持久化配置。 +/// +public sealed class RecordPersistenceOptions +{ + /// + /// 获取或设置记录目录相对路径或绝对路径。 + /// + public string DirectoryPath { get; set; } = "Data\\Records"; + + /// + /// 获取或设置跨天检查周期,单位为秒。 + /// + public int DayChangeCheckIntervalSeconds { get; set; } = 30; +} + /// /// 表示简易管理员控制配置。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs index 8ef5643..2c7318b 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/AppStateStore.cs @@ -57,6 +57,62 @@ public sealed class AppStateStore : IAppStateStore SnapshotChanged?.Invoke(this, clonedSnapshot); } + /// + /// 使用新的最近记录与统计结果替换当前历史区状态。 + /// + /// 最近记录集合。 + /// 统计结果。 + public void ReplaceRecords(IReadOnlyList records, BoardRecordStatistics statistics) + { + ArgumentNullException.ThrowIfNull(records); + ArgumentNullException.ThrowIfNull(statistics); + + UpdateSnapshot(snapshot => + { + snapshot.BoardRecords.Clear(); + foreach (BoardProcessRecord record in records) + { + snapshot.BoardRecords.Add(new BoardProcessRecord + { + StartedAt = record.StartedAt, + CompletedAt = record.CompletedAt, + Barcode = record.Barcode, + ScanTryCount = record.ScanTryCount, + SftpTryCount = record.SftpTryCount, + ResultCode = record.ResultCode, + ResultDescription = record.ResultDescription, + ReleaseSent = record.ReleaseSent, + AlarmRaised = record.AlarmRaised, + ExceptionSummary = record.ExceptionSummary + }); + } + + snapshot.RecordStatisticDate = statistics.StatisticDate; + snapshot.TodayProcessCount = statistics.TotalCount; + snapshot.TodayOkCount = statistics.OkCount; + snapshot.TodayNgCount = statistics.NgCount; + snapshot.RecordsLastUpdatedAt = statistics.LastUpdatedAt; + }); + } + + /// + /// 更新当前历史区统计结果。 + /// + /// 统计结果。 + public void UpdateRecordStatistics(BoardRecordStatistics statistics) + { + ArgumentNullException.ThrowIfNull(statistics); + + UpdateSnapshot(snapshot => + { + snapshot.RecordStatisticDate = statistics.StatisticDate; + snapshot.TodayProcessCount = statistics.TotalCount; + snapshot.TodayOkCount = statistics.OkCount; + snapshot.TodayNgCount = statistics.NgCount; + snapshot.RecordsLastUpdatedAt = statistics.LastUpdatedAt; + }); + } + /// /// 追加一条 UI 日志。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs b/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs new file mode 100644 index 0000000..1f786de --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs @@ -0,0 +1,504 @@ +using System.IO; +using System.Text; +using AxiOmron.PcbCheck.Models; +using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Interfaces; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 提供按天分文件的 CSV 处理记录持久化能力。 +/// +public sealed class CsvBoardRecordRepository : IBoardRecordRepository +{ + private const string HeaderLine = "StartedAt,CompletedAt,Barcode,ScanTryCount,SftpTryCount,ResultCode,ResultDescription,ReleaseSent,AlarmRaised,ExceptionSummary"; + private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + private readonly RecordPersistenceOptions _options; + private readonly int _maxRecentRecordCount; + private readonly TimeProvider _timeProvider; + private readonly IAppLogger? _appLogger; + private readonly SemaphoreSlim _syncLock = new(1, 1); + private BoardRecordLoadResult? _cachedCurrentDayResult; + private DateOnly? _cachedCurrentDay; + private DateTimeOffset _nextDayChangeCheckAt = DateTimeOffset.MinValue; + + /// + /// 初始化 CSV 仓储。 + /// + /// 应用配置。 + /// 时间提供器。 + /// 可选应用日志服务。 + public CsvBoardRecordRepository( + AppConfig config, + TimeProvider timeProvider, + IAppLogger? appLogger = null) + : this(config?.RecordPersistence ?? throw new ArgumentNullException(nameof(config)), config.Workflow.MaxBoardRecords, timeProvider, appLogger) + { + } + + /// + /// 初始化 CSV 仓储。 + /// + /// 记录持久化配置。 + /// 时间提供器。 + /// 可选应用日志服务。 + public CsvBoardRecordRepository( + RecordPersistenceOptions options, + TimeProvider timeProvider, + IAppLogger? appLogger = null) + : this(options, 100, timeProvider, appLogger) + { + } + + /// + /// 初始化 CSV 仓储。 + /// + /// 记录持久化配置。 + /// 最近记录最大条数。 + /// 时间提供器。 + /// 可选应用日志服务。 + /// 时抛出。 + private CsvBoardRecordRepository( + RecordPersistenceOptions options, + int maxRecentRecordCount, + TimeProvider timeProvider, + IAppLogger? appLogger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _maxRecentRecordCount = Math.Max(1, maxRecentRecordCount); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _appLogger = appLogger; + } + + /// + /// 加载当前本地日期对应的处理记录与统计数据。 + /// + /// 取消令牌。取消后立即终止读取操作,并返回已取消任务。 + /// 当前日期的最近记录与统计结果。 + public async Task LoadCurrentDayAsync(CancellationToken cancellationToken) + { + await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await LoadDayCoreAsync(GetCurrentLocalDate(), cancellationToken).ConfigureAwait(false); + } + finally + { + _syncLock.Release(); + } + } + + /// + /// 当检测到跨天时重新加载当前日期数据;若日期未变化则返回 。 + /// + /// 取消令牌。取消后立即终止检查与读取操作,并返回已取消任务。 + /// 日期变化时返回新的加载结果;否则返回 + public async Task ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken) + { + await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + DateTimeOffset now = _timeProvider.GetLocalNow(); + if (now < _nextDayChangeCheckAt) + { + return null; + } + + _nextDayChangeCheckAt = now.AddSeconds(Math.Max(1, _options.DayChangeCheckIntervalSeconds)); + DateOnly currentDate = GetCurrentLocalDate(); + if (_cachedCurrentDay.HasValue && _cachedCurrentDay.Value == currentDate) + { + return null; + } + + return await LoadDayCoreAsync(currentDate, cancellationToken).ConfigureAwait(false); + } + finally + { + _syncLock.Release(); + } + } + + /// + /// 将一条处理记录追加写入其完成日 CSV,并返回当前日期对应的最新列表与统计。 + /// + /// 待持久化的处理记录。 + /// 取消令牌。取消后立即终止写入操作,并返回已取消任务。 + /// 追加结果;若写入失败,返回的列表与统计保持在最近一次成功持久化后的真值。 + /// 时抛出。 + public async Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + + await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + DateOnly currentDate = GetCurrentLocalDate(); + if (!_cachedCurrentDay.HasValue || _cachedCurrentDay.Value != currentDate) + { + await LoadDayCoreAsync(currentDate, cancellationToken).ConfigureAwait(false); + } + + try + { + DateOnly recordDate = DateOnly.FromDateTime(record.CompletedAt.LocalDateTime.Date); + string path = GetDailyFilePath(recordDate); + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + bool fileExists = File.Exists(path); + await using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + await using (var writer = new StreamWriter(stream, new UTF8Encoding(true))) + { + if (!fileExists) + { + await writer.WriteLineAsync(HeaderLine).ConfigureAwait(false); + } + + await writer.WriteLineAsync(ToCsvLine(record)).ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + BoardRecordLoadResult refreshed = await LoadDayCoreAsync(currentDate, cancellationToken).ConfigureAwait(false); + return CreateAppendResult(true, string.Empty, refreshed); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _appLogger?.LogError(ex, "处理记录 CSV 追加失败"); + BoardRecordLoadResult current = _cachedCurrentDayResult ?? CreateEmptyResult(currentDate); + return CreateAppendResult(false, ex.Message, current); + } + } + finally + { + _syncLock.Release(); + } + } + + /// + /// 读取指定日期的 CSV 文件并刷新缓存。 + /// + /// 目标日期。 + /// 取消令牌。 + /// 读取结果。 + private async Task LoadDayCoreAsync(DateOnly date, CancellationToken cancellationToken) + { + string path = GetDailyFilePath(date); + if (!File.Exists(path)) + { + BoardRecordLoadResult empty = CreateEmptyResult(date); + Cache(date, empty); + return CloneResult(empty); + } + + try + { + string[] lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false); + if (lines.Length == 0) + { + BoardRecordLoadResult empty = CreateEmptyResult(date); + Cache(date, empty); + return CloneResult(empty); + } + + string header = lines[0].Trim('\uFEFF'); + if (!string.Equals(header, HeaderLine, StringComparison.Ordinal)) + { + _appLogger?.LogError($"处理记录 CSV 表头不匹配,文件={path}"); + BoardRecordLoadResult empty = CreateEmptyResult(date); + Cache(date, empty); + return CloneResult(empty); + } + + var records = new List(); + for (int index = 1; index < lines.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + string line = lines[index]; + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (!TryParseRecord(line, out BoardProcessRecord? record)) + { + _appLogger?.LogWarning($"处理记录 CSV 第 {index + 1} 行解析失败,已跳过。", true); + continue; + } + + records.Add(record!); + } + + records.Sort((left, right) => left.CompletedAt.CompareTo(right.CompletedAt)); + IReadOnlyList recentRecords = records + .Skip(Math.Max(0, records.Count - _maxRecentRecordCount)) + .Reverse() + .Select(CloneRecord) + .ToList(); + + var statistics = new BoardRecordStatistics + { + StatisticDate = date, + TotalCount = records.Count, + OkCount = records.Count(record => record.ResultCode == (ushort)WorkflowResultCode.Passed), + NgCount = records.Count(record => record.ResultCode != (ushort)WorkflowResultCode.Passed + && record.ResultCode != (ushort)WorkflowResultCode.Processing + && record.ResultCode != (ushort)WorkflowResultCode.None), + LastUpdatedAt = _timeProvider.GetLocalNow() + }; + + var result = new BoardRecordLoadResult + { + RecentRecords = recentRecords, + Statistics = statistics + }; + + Cache(date, result); + return CloneResult(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _appLogger?.LogError(ex, "读取当日处理记录 CSV 失败"); + BoardRecordLoadResult empty = CreateEmptyResult(date); + Cache(date, empty); + return CloneResult(empty); + } + } + + /// + /// 刷新当前日期缓存。 + /// + /// 缓存日期。 + /// 缓存结果。 + private void Cache(DateOnly date, BoardRecordLoadResult result) + { + _cachedCurrentDay = date; + _cachedCurrentDayResult = CloneResult(result); + } + + /// + /// 创建空结果。 + /// + /// 统计日期。 + /// 空结果对象。 + private BoardRecordLoadResult CreateEmptyResult(DateOnly date) + { + return new BoardRecordLoadResult + { + RecentRecords = Array.Empty(), + Statistics = new BoardRecordStatistics + { + StatisticDate = date, + LastUpdatedAt = _timeProvider.GetLocalNow() + } + }; + } + + /// + /// 创建追加结果对象。 + /// + /// 是否写入成功。 + /// 错误消息。 + /// 当前日期结果。 + /// 追加结果对象。 + private static BoardRecordAppendResult CreateAppendResult(bool isPersisted, string errorMessage, BoardRecordLoadResult result) + { + return new BoardRecordAppendResult + { + IsPersisted = isPersisted, + ErrorMessage = errorMessage, + RecentRecords = result.RecentRecords.Select(CloneRecord).ToList(), + Statistics = result.Statistics.Clone() + }; + } + + /// + /// 克隆加载结果对象。 + /// + /// 源结果。 + /// 结果副本。 + private static BoardRecordLoadResult CloneResult(BoardRecordLoadResult result) + { + return new BoardRecordLoadResult + { + RecentRecords = result.RecentRecords.Select(CloneRecord).ToList(), + Statistics = result.Statistics.Clone() + }; + } + + /// + /// 获取当前本地日期。 + /// + /// 当前本地日期。 + private DateOnly GetCurrentLocalDate() + { + return DateOnly.FromDateTime(_timeProvider.GetLocalNow().LocalDateTime.Date); + } + + /// + /// 获取指定日期的 CSV 文件路径。 + /// + /// 目标日期。 + /// CSV 文件绝对路径。 + private string GetDailyFilePath(DateOnly date) + { + string directoryPath = Path.IsPathRooted(_options.DirectoryPath) + ? _options.DirectoryPath + : Path.Combine(AppContext.BaseDirectory, _options.DirectoryPath); + + return Path.Combine(directoryPath, $"{date:yyyy-MM-dd}.csv"); + } + + /// + /// 将处理记录转换为单行 CSV 文本。 + /// + /// 待转换记录。 + /// CSV 文本。 + private static string ToCsvLine(BoardProcessRecord record) + { + return string.Join( + ",", + Escape(record.StartedAt.ToString(TimestampFormat)), + Escape(record.CompletedAt.ToString(TimestampFormat)), + Escape(record.Barcode), + record.ScanTryCount.ToString(), + record.SftpTryCount.ToString(), + record.ResultCode.ToString(), + Escape(record.ResultDescription), + record.ReleaseSent.ToString(), + record.AlarmRaised.ToString(), + Escape(record.ExceptionSummary)); + } + + /// + /// 解析单行 CSV 记录。 + /// + /// CSV 文本。 + /// 解析成功的记录对象。 + /// 解析成功返回 ;否则返回 + private static bool TryParseRecord(string line, out BoardProcessRecord? record) + { + record = null; + List fields = ParseCsvLine(line); + if (fields.Count != 10) + { + return false; + } + + if (!DateTimeOffset.TryParse(fields[0], out DateTimeOffset startedAt) + || !DateTimeOffset.TryParse(fields[1], out DateTimeOffset completedAt) + || !int.TryParse(fields[3], out int scanTryCount) + || !int.TryParse(fields[4], out int sftpTryCount) + || !ushort.TryParse(fields[5], out ushort resultCode) + || !bool.TryParse(fields[7], out bool releaseSent) + || !bool.TryParse(fields[8], out bool alarmRaised)) + { + return false; + } + + record = new BoardProcessRecord + { + StartedAt = startedAt, + CompletedAt = completedAt, + Barcode = fields[2], + ScanTryCount = scanTryCount, + SftpTryCount = sftpTryCount, + ResultCode = resultCode, + ResultDescription = fields[6], + ReleaseSent = releaseSent, + AlarmRaised = alarmRaised, + ExceptionSummary = fields[9] + }; + return true; + } + + /// + /// 解析 CSV 行。 + /// + /// CSV 文本。 + /// 字段列表。 + private static List ParseCsvLine(string line) + { + var result = new List(); + var builder = new StringBuilder(); + bool inQuotes = false; + + for (int index = 0; index < line.Length; index++) + { + char current = line[index]; + if (current == '"') + { + if (inQuotes && index + 1 < line.Length && line[index + 1] == '"') + { + builder.Append('"'); + index++; + continue; + } + + inQuotes = !inQuotes; + continue; + } + + if (current == ',' && !inQuotes) + { + result.Add(builder.ToString()); + builder.Clear(); + continue; + } + + builder.Append(current); + } + + result.Add(builder.ToString()); + return result; + } + + /// + /// 对 CSV 字段执行转义。 + /// + /// 原始字段文本。 + /// 转义后的文本。 + private static string Escape(string value) + { + value ??= string.Empty; + if (!value.Contains(',') && !value.Contains('"') && !value.Contains('\r') && !value.Contains('\n')) + { + return value; + } + + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + /// + /// 创建处理记录副本。 + /// + /// 待复制记录。 + /// 记录副本。 + private static BoardProcessRecord CloneRecord(BoardProcessRecord record) + { + return new BoardProcessRecord + { + StartedAt = record.StartedAt, + CompletedAt = record.CompletedAt, + Barcode = record.Barcode, + ScanTryCount = record.ScanTryCount, + SftpTryCount = record.SftpTryCount, + ResultCode = record.ResultCode, + ResultDescription = record.ResultDescription, + ReleaseSent = record.ReleaseSent, + AlarmRaised = record.AlarmRaised, + ExceptionSummary = record.ExceptionSummary + }; + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs index eb04a3f..6786b0b 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs @@ -14,6 +14,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS private readonly IScannerService _scannerService; private readonly ISftpLookupService _sftpLookupService; private readonly IAndonService _andonService; + private readonly IBoardRecordRepository _boardRecordRepository; private readonly IAppStateStore _stateStore; private readonly AppConfig _config; private readonly IAppLogger _appLogger; @@ -52,6 +53,30 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS IAppStateStore stateStore, AppConfig config, IAppLogger appLogger) + : this(plcService, scannerService, sftpLookupService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository()) + { + } + + /// + /// 初始化流程后台服务。 + /// + /// PLC 服务。 + /// 扫码枪服务。 + /// SFTP 校验服务。 + /// 安灯服务。 + /// 运行态存储。 + /// 应用配置。 + /// 日志记录器。 + /// 处理记录仓储。 + public WorkflowHostedService( + IPlcService plcService, + IScannerService scannerService, + ISftpLookupService sftpLookupService, + IAndonService andonService, + IAppStateStore stateStore, + AppConfig config, + IAppLogger appLogger, + IBoardRecordRepository boardRecordRepository) { _plcService = plcService ?? throw new ArgumentNullException(nameof(plcService)); _scannerService = scannerService ?? throw new ArgumentNullException(nameof(scannerService)); @@ -60,6 +85,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); _config = config ?? throw new ArgumentNullException(nameof(config)); _appLogger = appLogger ?? throw new ArgumentNullException(nameof(appLogger)); + _boardRecordRepository = boardRecordRepository ?? throw new ArgumentNullException(nameof(boardRecordRepository)); } /// @@ -70,6 +96,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true); + await RestoreBoardRecordsAsync(stoppingToken).ConfigureAwait(false); await ProbePlcOnStartupAsync(stoppingToken).ConfigureAwait(false); await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false); await ProbeScannerOnStartupAsync(stoppingToken).ConfigureAwait(false); @@ -79,6 +106,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS { try { + await RefreshBoardRecordsIfDateChangedAsync(stoppingToken).ConfigureAwait(false); var signals = await _plcService.ReadSignalsAsync(stoppingToken).ConfigureAwait(false); HandleSignalSnapshot(signals); await RefreshPlcMonitorSnapshotAsync(stoppingToken).ConfigureAwait(false); @@ -844,10 +872,84 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS }; } - _stateStore.AddRecord(record); + await PersistBoardRecordAsync(record, cancellationToken).ConfigureAwait(false); _appLogger.LogInformation($"单板流程完成,结果={record.ResultDescription}", true); } + /// + /// 在应用启动时恢复当天处理记录与统计信息。 + /// + /// 取消令牌。 + /// 表示恢复完成的任务。 + private async Task RestoreBoardRecordsAsync(CancellationToken cancellationToken) + { + try + { + BoardRecordLoadResult loadResult = await _boardRecordRepository.LoadCurrentDayAsync(cancellationToken).ConfigureAwait(false); + _stateStore.ReplaceRecords(loadResult.RecentRecords, loadResult.Statistics); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "启动时恢复当日处理记录失败"); + } + } + + /// + /// 在检测到日期切换时刷新当天处理记录与统计信息。 + /// + /// 取消令牌。 + /// 表示刷新完成的任务。 + private async Task RefreshBoardRecordsIfDateChangedAsync(CancellationToken cancellationToken) + { + try + { + BoardRecordLoadResult? loadResult = await _boardRecordRepository.ReloadCurrentDayIfDateChangedAsync(cancellationToken).ConfigureAwait(false); + if (loadResult is not null) + { + _stateStore.ReplaceRecords(loadResult.RecentRecords, loadResult.Statistics); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "刷新当日处理记录失败"); + } + } + + /// + /// 将流程完成记录持久化到 CSV,并刷新首页历史区。 + /// + /// 待持久化的处理记录。 + /// 取消令牌。 + /// 表示持久化处理完成的任务。 + private async Task PersistBoardRecordAsync(BoardProcessRecord record, CancellationToken cancellationToken) + { + try + { + BoardRecordAppendResult appendResult = await _boardRecordRepository.AppendAsync(record, cancellationToken).ConfigureAwait(false); + _stateStore.ReplaceRecords(appendResult.RecentRecords, appendResult.Statistics); + if (!appendResult.IsPersisted) + { + _appLogger.LogError($"处理记录持久化失败: {appendResult.ErrorMessage}", true); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "处理记录持久化失败"); + } + } + /// /// 在发生系统级异常时进入故障状态。 /// @@ -1134,4 +1236,41 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS _ => "未知结果" }; } + + /// + /// 提供空实现的处理记录仓储,供未接入持久化时兜底使用。 + /// + private sealed class NullBoardRecordRepository : IBoardRecordRepository + { + public Task LoadCurrentDayAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new BoardRecordLoadResult + { + Statistics = new BoardRecordStatistics + { + StatisticDate = DateOnly.FromDateTime(DateTime.Today), + LastUpdatedAt = DateTimeOffset.Now + } + }); + } + + public Task ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + return Task.FromResult(new BoardRecordAppendResult + { + IsPersisted = true, + Statistics = new BoardRecordStatistics + { + StatisticDate = DateOnly.FromDateTime(DateTime.Today), + LastUpdatedAt = DateTimeOffset.Now + } + }); + } + } } diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs index b18e377..d9e8975 100644 --- a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs +++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs @@ -230,6 +230,35 @@ public interface IWorkflowControlService Task TestAndonAsync(CancellationToken cancellationToken); } +/// +/// 定义单板处理记录的 CSV 持久化能力。 +/// +public interface IBoardRecordRepository +{ + /// + /// 加载当前本地日期对应的处理记录与统计数据。 + /// + /// 取消令牌。取消后立即终止读取操作,并返回已取消任务。 + /// 当前日期的最近记录与统计结果。 + Task LoadCurrentDayAsync(CancellationToken cancellationToken); + + /// + /// 当检测到跨天时重新加载当前日期数据;若日期未变化则返回 。 + /// + /// 取消令牌。取消后立即终止检查与读取操作,并返回已取消任务。 + /// 日期变化时返回新的加载结果;否则返回 + Task ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken); + + /// + /// 将一条处理记录追加写入其完成日 CSV,并返回当前日期对应的最新列表与统计。 + /// + /// 待持久化的处理记录。 + /// 取消令牌。取消后立即终止写入操作,并返回已取消任务。 + /// 追加结果;若写入失败,返回的列表与统计保持在最近一次成功持久化后的真值。 + /// 时抛出。 + Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken); +} + /// /// 定义管理员解锁密码弹窗交互能力。 /// @@ -276,6 +305,21 @@ public interface IAppStateStore /// 用于修改快照的更新委托。 void UpdateSnapshot(Action updateAction); + /// + /// 使用一批最近记录与统计结果替换当前历史区状态。 + /// + /// 最近记录集合,通常已按完成时间倒序排列。 + /// 与当前日期对应的统计结果。 + /// 时抛出。 + void ReplaceRecords(IReadOnlyList records, BoardRecordStatistics statistics); + + /// + /// 更新当前历史区统计结果,不修改最近记录集合。 + /// + /// 最新统计结果。 + /// 时抛出。 + void UpdateRecordStatistics(BoardRecordStatistics statistics); + /// /// 追加一条 UI 日志。 /// diff --git a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs index 9aced19..c7566da 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs @@ -21,6 +21,7 @@ public partial class MainWindowViewModel : ObservableObject private readonly SecurityOptions _securityOptions; private readonly IAdminUnlockDialogService _adminUnlockDialogService; private readonly DispatcherTimer _adminUnlockTimer; + private DateTimeOffset _lastAppliedRecordDataUpdatedAt = DateTimeOffset.MinValue; private DateTimeOffset? _adminUnlockedUntil; /// @@ -62,10 +63,8 @@ public partial class MainWindowViewModel : ObservableObject _stateStore.SnapshotChanged += OnSnapshotChanged; _stateStore.LogAdded += OnLogAdded; - _stateStore.RecordAdded += OnRecordAdded; ApplySnapshot(_stateStore.GetSnapshot()); RecalculateLogStatistics(); - RecalculateProcessStatistics(); } /// @@ -225,6 +224,12 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private string _lastProcessUpdateTime = "-"; + /// + /// 获取或设置当前处理记录统计日期文本。 + /// + [ObservableProperty] + private string _recordStatisticDateText = "-"; + /// /// 获取或设置管理员功能是否已解锁。 /// @@ -377,7 +382,7 @@ public partial class MainWindowViewModel : ObservableObject [RelayCommand] private void RefreshProcessRecords() { - RecalculateProcessStatistics(); + LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); } /// @@ -399,12 +404,40 @@ public partial class MainWindowViewModel : ObservableObject LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; IsBusy = snapshot.IsBusy; LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"); + RecordStatisticDateText = snapshot.RecordStatisticDate?.ToString("yyyy-MM-dd") ?? "-"; + TodayProcessCount = snapshot.TodayProcessCount; + TodayOkCount = snapshot.TodayOkCount; + TodayNgCount = snapshot.TodayNgCount; PlcMonitorItems.Clear(); foreach (PlcMonitorItem item in snapshot.PlcMonitorItems) { PlcMonitorItems.Add(item.Clone()); } + + if (snapshot.RecordsLastUpdatedAt != _lastAppliedRecordDataUpdatedAt) + { + _lastAppliedRecordDataUpdatedAt = snapshot.RecordsLastUpdatedAt; + RecentBoards.Clear(); + foreach (BoardProcessRecord record in snapshot.BoardRecords + .OrderByDescending(item => item.CompletedAt) + .Take(_workflowOptions.MaxBoardRecords)) + { + RecentBoards.Add(new BoardProcessRecord + { + StartedAt = record.StartedAt, + CompletedAt = record.CompletedAt, + Barcode = record.Barcode, + ScanTryCount = record.ScanTryCount, + SftpTryCount = record.SftpTryCount, + ResultCode = record.ResultCode, + ResultDescription = record.ResultDescription, + ReleaseSent = record.ReleaseSent, + AlarmRaised = record.AlarmRaised, + ExceptionSummary = record.ExceptionSummary + }); + } + } } /// @@ -450,7 +483,8 @@ public partial class MainWindowViewModel : ObservableObject /// 集合变化参数。 private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - RecalculateProcessStatistics(); + HasProcessRecords = RecentBoards.Count > 0; + LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); } /// @@ -488,42 +522,6 @@ public partial class MainWindowViewModel : ObservableObject LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); } - /// - /// 根据当前处理记录集合重新计算处理区的统计信息与最后刷新时间。 - /// - private void RecalculateProcessStatistics() - { - DateTime today = DateTime.Today; - int totalToday = 0; - int okToday = 0; - int ngToday = 0; - - foreach (BoardProcessRecord record in RecentBoards) - { - if (record.CompletedAt.LocalDateTime.Date != today) - { - continue; - } - - totalToday++; - if (record.ResultCode == (ushort)WorkflowResultCode.Passed) - { - okToday++; - } - else if (record.ResultCode != (ushort)WorkflowResultCode.Processing - && record.ResultCode != (ushort)WorkflowResultCode.None) - { - ngToday++; - } - } - - TodayProcessCount = totalToday; - TodayOkCount = okToday; - TodayNgCount = ngToday; - HasProcessRecords = RecentBoards.Count > 0; - LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); - } - /// /// 处理运行态快照变化事件。 /// @@ -531,7 +529,7 @@ public partial class MainWindowViewModel : ObservableObject /// 最新快照。 private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot) { - await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false); + await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false); } /// @@ -541,34 +539,9 @@ public partial class MainWindowViewModel : ObservableObject /// 新增日志。 private async void OnLogAdded(object? sender, UiLogEntry entry) { - await _dispatcherService.InvokeAsync(() => - { - Logs.Insert(0, entry); - while (Logs.Count > _workflowOptions.MaxUiLogEntries) - { - Logs.RemoveAt(Logs.Count - 1); - } - }).ConfigureAwait(false); + await AppendLogOnUiAsync(entry).ConfigureAwait(false); } - /// - /// 处理新增单板记录事件。 - /// - /// 事件源。 - /// 新增单板记录。 - private async void OnRecordAdded(object? sender, BoardProcessRecord record) - { - await _dispatcherService.InvokeAsync(() => - { - RecentBoards.Insert(0, record); - while (RecentBoards.Count > _workflowOptions.MaxBoardRecords) - { - RecentBoards.RemoveAt(RecentBoards.Count - 1); - } - }).ConfigureAwait(false); - } - - /// /// 处理管理员解锁倒计时,超时后自动恢复锁定。 /// /// 事件源。 @@ -641,4 +614,45 @@ public partial class MainWindowViewModel : ObservableObject }).ConfigureAwait(false); } } + + /// + /// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。 + /// + /// 待应用的运行态快照。 + /// 表示刷新流程结束的任务。 + private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot) + { + try + { + await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。 + } + } + + /// + /// 在 UI 线程中安全追加日志;若应用关闭导致 Dispatcher 调度被取消,则忽略本次日志刷新。 + /// + /// 待追加的日志项。 + /// 表示日志刷新流程结束的任务。 + private async Task AppendLogOnUiAsync(UiLogEntry entry) + { + try + { + await _dispatcherService.InvokeAsync(() => + { + Logs.Insert(0, entry); + while (Logs.Count > _workflowOptions.MaxUiLogEntries) + { + Logs.RemoveAt(Logs.Count - 1); + } + }).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。 + } + } } diff --git a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs index 3d632de..c7b4781 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs @@ -156,6 +156,23 @@ public partial class SystemSettingViewModel : ObservableObject /// 最新快照。 private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot) { - await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false); + await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false); + } + + /// + /// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。 + /// + /// 待应用的运行态快照。 + /// 表示刷新流程结束的任务。 + private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot) + { + try + { + await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。 + } } } diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml index b1bee4d..ace5bad 100644 --- a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml +++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml @@ -1127,25 +1127,33 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxiOmron.PcbCheck/appConfig.json b/src/AxiOmron.PcbCheck/appConfig.json index 5b1bc3d..567d8ff 100644 --- a/src/AxiOmron.PcbCheck/appConfig.json +++ b/src/AxiOmron.PcbCheck/appConfig.json @@ -64,6 +64,10 @@ "MaxUiLogEntries": 200, "MaxBoardRecords": 100 }, + "RecordPersistence": { + "DirectoryPath": "Data\\Records", + "DayChangeCheckIntervalSeconds": 30 + }, "Security": { "AdminPassword": "AxiOmron@123", "AdminSessionTimeoutMinutes": 15 diff --git a/tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs b/tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs new file mode 100644 index 0000000..94a1a49 --- /dev/null +++ b/tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs @@ -0,0 +1,207 @@ +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"); + } + } +} diff --git a/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs b/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs new file mode 100644 index 0000000..7c7b6f0 --- /dev/null +++ b/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs @@ -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; + +/// +/// 验证首页视图模型对处理记录和统计区域的绑定行为。 +/// +public sealed class MainWindowViewModelTests +{ + /// + /// 构造时应使用运行态快照中的统计值,而不是仅根据最近 100 条列表重新计算。 + /// + [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); + } + + /// + /// 构造时应从快照恢复最近 100 条记录,并按完成时间倒序展示。 + /// + [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); + } + + /// + /// 当 UI 调度已被取消时,快照事件不应将取消异常继续抛到同步上下文。 + /// + [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); + } + } + + /// + /// 当 UI 调度已被取消时,日志事件不应将取消异常继续抛到同步上下文。 + /// + [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); + } + } + + /// + /// 创建测试用首页视图模型。 + /// + /// 运行态存储。 + /// 首页视图模型。 + 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()); + } + + /// + /// 捕获 async void 事件处理器回抛到同步上下文中的异常。 + /// + private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext + { + /// + /// 获取或设置捕获到的异常。 + /// + public Exception? CapturedException { get; private set; } + + /// + /// 立即执行回调并记录内部异常,避免测试进程直接崩溃。 + /// + /// 待执行回调。 + /// 回调状态。 + public override void Post(SendOrPostCallback d, object? state) + { + ArgumentNullException.ThrowIfNull(d); + + try + { + d(state); + } + catch (Exception ex) + { + CapturedException = ex; + } + } + } + + /// + /// 提供立即执行的测试调度服务。 + /// + private sealed class ImmediateDispatcherService : IDispatcherService + { + public Task InvokeAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + action(); + return Task.CompletedTask; + } + } + + /// + /// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。 + /// + private sealed class CanceledDispatcherService : IDispatcherService + { + public Task InvokeAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + return Task.FromCanceled(new CancellationToken(canceled: true)); + } + } + + /// + /// 提供空实现的流程控制服务。 + /// + 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; + } + + /// + /// 提供空实现的管理员解锁弹窗服务。 + /// + private sealed class FakeAdminUnlockDialogService : IAdminUnlockDialogService + { + public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) => true; + } +} diff --git a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs index 2398b9a..2660d5d 100644 --- a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs +++ b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs @@ -148,6 +148,46 @@ public sealed class SystemSettingViewModelTests Assert.Null(backgroundException); } + /// + /// 当 UI 调度已被取消时,系统设置页的快照事件不应将取消异常继续抛到同步上下文。 + /// + [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); + } + } + /// /// 在 STA 线程中执行指定委托,并将内部异常重新抛回当前测试线程。 /// @@ -269,6 +309,48 @@ public sealed class SystemSettingViewModelTests } } + /// + /// 捕获 async void 事件处理器回抛到同步上下文中的异常。 + /// + private sealed class ExceptionCapturingSynchronizationContext : SynchronizationContext + { + /// + /// 获取或设置捕获到的异常。 + /// + public Exception? CapturedException { get; private set; } + + /// + /// 立即执行回调并记录内部异常,避免测试进程直接崩溃。 + /// + /// 待执行回调。 + /// 回调状态。 + public override void Post(SendOrPostCallback d, object? state) + { + ArgumentNullException.ThrowIfNull(d); + + try + { + d(state); + } + catch (Exception ex) + { + CapturedException = ex; + } + } + } + + /// + /// 提供始终返回已取消任务的测试调度服务,用于模拟应用关闭阶段的 Dispatcher 中止。 + /// + private sealed class CanceledDispatcherService : IDispatcherService + { + public Task InvokeAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + return Task.FromCanceled(new CancellationToken(canceled: true)); + } + } + /// /// 提供绑定到指定 Dispatcher 的测试调度服务,用于验证跨线程回切行为。 /// diff --git a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs index 38e2888..c162991 100644 --- a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs +++ b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs @@ -228,6 +228,139 @@ public sealed class WorkflowHostedServiceTests Assert.Equal(1, scannerService.TriggerCount); } + /// + /// 应用启动时,应从记录仓储恢复当天最近记录与统计信息。 + /// + /// 异步测试任务。 + [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(), + 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); + } + + /// + /// 当 CSV 持久化失败时,不应让主流程进入故障态。 + /// + /// 异步测试任务。 + [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(), + 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 LoadCurrentDayAsync(CancellationToken cancellationToken) + { + return Task.FromResult(LoadResult); + } + + public Task ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken) + { + if (AppendException is not null) + { + throw AppendException; + } + + return Task.FromResult(AppendResult); + } + } + private sealed class FakeAppLogger : IAppLogger { public void LogError(string message, bool showInUi = false, params object?[] args) { }