✨ 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:
@@ -116,12 +116,14 @@ public partial class App : Application
|
||||
services.AddSingleton<IAppStateStore, AppStateStore>();
|
||||
services.AddSingleton(typeof(IAppLogger<>), typeof(AppLogger<>));
|
||||
services.AddSingleton<IDispatcherService, DispatcherService>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddHttpClient(nameof(AndonService));
|
||||
|
||||
services.AddSingleton<IPlcService, ModbusTcpPlcService>();
|
||||
services.AddSingleton<IScannerService, SerialScannerService>();
|
||||
services.AddSingleton<ISftpLookupService, SftpLookupService>();
|
||||
services.AddSingleton<IAndonService, AndonService>();
|
||||
services.AddSingleton<IBoardRecordRepository, CsvBoardRecordRepository>();
|
||||
services.AddSingleton<WorkflowHostedService>();
|
||||
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,6 +202,52 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
_snapshotChanged?.Invoke(this, _snapshot.Clone());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用新的最近记录与统计结果替换当前设计时历史区状态。
|
||||
/// </summary>
|
||||
/// <param name="records">最近记录集合。</param>
|
||||
/// <param name="statistics">统计结果。</param>
|
||||
public void ReplaceRecords(IReadOnlyList<BoardProcessRecord> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前设计时统计结果。
|
||||
/// </summary>
|
||||
/// <param name="statistics">统计结果。</param>
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 追加一条设计时日志并通知订阅者。
|
||||
/// </summary>
|
||||
|
||||
80
src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs
Normal file
80
src/AxiOmron.PcbCheck/Models/RecordPersistenceModels.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
namespace AxiOmron.PcbCheck.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 表示当前统计日期对应的处理记录聚合结果。
|
||||
/// </summary>
|
||||
public sealed class BoardRecordStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置统计日期。
|
||||
/// </summary>
|
||||
public DateOnly StatisticDate { get; set; } = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置处理总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 OK 数。
|
||||
/// </summary>
|
||||
public int OkCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 NG 数。
|
||||
/// </summary>
|
||||
public int NgCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置最近一次统计更新时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 创建当前统计对象的副本。
|
||||
/// </summary>
|
||||
/// <returns>统计对象副本。</returns>
|
||||
public BoardRecordStatistics Clone()
|
||||
{
|
||||
return new BoardRecordStatistics
|
||||
{
|
||||
StatisticDate = StatisticDate,
|
||||
TotalCount = TotalCount,
|
||||
OkCount = OkCount,
|
||||
NgCount = NgCount,
|
||||
LastUpdatedAt = LastUpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一次当前日期数据加载结果。
|
||||
/// </summary>
|
||||
public class BoardRecordLoadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置最近记录集合,按完成时间倒序排列。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BoardProcessRecord> RecentRecords { get; set; } = Array.Empty<BoardProcessRecord>();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前统计结果。
|
||||
/// </summary>
|
||||
public BoardRecordStatistics Statistics { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一次处理记录追加写入后的结果。
|
||||
/// </summary>
|
||||
public sealed class BoardRecordAppendResult : BoardRecordLoadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置本次持久化是否成功。
|
||||
/// </summary>
|
||||
public bool IsPersisted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置失败原因。
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -10,6 +10,11 @@ public sealed class RuntimeSnapshot
|
||||
/// </summary>
|
||||
public IList<PlcMonitorItem> PlcMonitorItems { get; } = new List<PlcMonitorItem>();
|
||||
|
||||
/// <summary>
|
||||
/// 获取最近处理记录集合。
|
||||
/// </summary>
|
||||
public IList<BoardProcessRecord> BoardRecords { get; } = new List<BoardProcessRecord>();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 PLC 连接状态文本。
|
||||
/// </summary>
|
||||
@@ -80,6 +85,31 @@ public sealed class RuntimeSnapshot
|
||||
/// </summary>
|
||||
public DateTimeOffset LastUpdatedAt { get; set; } = DateTimeOffset.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前统计日期。
|
||||
/// </summary>
|
||||
public DateOnly? RecordStatisticDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置今日处理总数。
|
||||
/// </summary>
|
||||
public int TodayProcessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置今日 OK 数。
|
||||
/// </summary>
|
||||
public int TodayOkCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置今日 NG 数。
|
||||
/// </summary>
|
||||
public int TodayNgCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置处理记录数据最近更新时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset RecordsLastUpdatedAt { get; set; } = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// 创建当前快照的副本。
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建处理记录对象的副本。
|
||||
/// </summary>
|
||||
/// <param name="record">待复制的处理记录。</param>
|
||||
/// <returns>处理记录副本。</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ public sealed class AppConfig
|
||||
/// </summary>
|
||||
public WorkflowOptions Workflow { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置处理记录持久化配置。
|
||||
/// </summary>
|
||||
public RecordPersistenceOptions RecordPersistence { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置安全控制配置。
|
||||
/// </summary>
|
||||
@@ -286,6 +291,22 @@ public sealed class WorkflowOptions
|
||||
public int MaxBoardRecords { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示处理记录 CSV 持久化配置。
|
||||
/// </summary>
|
||||
public sealed class RecordPersistenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置记录目录相对路径或绝对路径。
|
||||
/// </summary>
|
||||
public string DirectoryPath { get; set; } = "Data\\Records";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置跨天检查周期,单位为秒。
|
||||
/// </summary>
|
||||
public int DayChangeCheckIntervalSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示简易管理员控制配置。
|
||||
/// </summary>
|
||||
|
||||
@@ -57,6 +57,62 @@ public sealed class AppStateStore : IAppStateStore
|
||||
SnapshotChanged?.Invoke(this, clonedSnapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用新的最近记录与统计结果替换当前历史区状态。
|
||||
/// </summary>
|
||||
/// <param name="records">最近记录集合。</param>
|
||||
/// <param name="statistics">统计结果。</param>
|
||||
public void ReplaceRecords(IReadOnlyList<BoardProcessRecord> 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;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前历史区统计结果。
|
||||
/// </summary>
|
||||
/// <param name="statistics">统计结果。</param>
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 追加一条 UI 日志。
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 提供按天分文件的 CSV 处理记录持久化能力。
|
||||
/// </summary>
|
||||
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<CsvBoardRecordRepository>? _appLogger;
|
||||
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||
private BoardRecordLoadResult? _cachedCurrentDayResult;
|
||||
private DateOnly? _cachedCurrentDay;
|
||||
private DateTimeOffset _nextDayChangeCheckAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 CSV 仓储。
|
||||
/// </summary>
|
||||
/// <param name="config">应用配置。</param>
|
||||
/// <param name="timeProvider">时间提供器。</param>
|
||||
/// <param name="appLogger">可选应用日志服务。</param>
|
||||
public CsvBoardRecordRepository(
|
||||
AppConfig config,
|
||||
TimeProvider timeProvider,
|
||||
IAppLogger<CsvBoardRecordRepository>? appLogger = null)
|
||||
: this(config?.RecordPersistence ?? throw new ArgumentNullException(nameof(config)), config.Workflow.MaxBoardRecords, timeProvider, appLogger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 CSV 仓储。
|
||||
/// </summary>
|
||||
/// <param name="options">记录持久化配置。</param>
|
||||
/// <param name="timeProvider">时间提供器。</param>
|
||||
/// <param name="appLogger">可选应用日志服务。</param>
|
||||
public CsvBoardRecordRepository(
|
||||
RecordPersistenceOptions options,
|
||||
TimeProvider timeProvider,
|
||||
IAppLogger<CsvBoardRecordRepository>? appLogger = null)
|
||||
: this(options, 100, timeProvider, appLogger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 CSV 仓储。
|
||||
/// </summary>
|
||||
/// <param name="options">记录持久化配置。</param>
|
||||
/// <param name="maxRecentRecordCount">最近记录最大条数。</param>
|
||||
/// <param name="timeProvider">时间提供器。</param>
|
||||
/// <param name="appLogger">可选应用日志服务。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="options"/> 或 <paramref name="timeProvider"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||
private CsvBoardRecordRepository(
|
||||
RecordPersistenceOptions options,
|
||||
int maxRecentRecordCount,
|
||||
TimeProvider timeProvider,
|
||||
IAppLogger<CsvBoardRecordRepository>? appLogger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_maxRecentRecordCount = Math.Max(1, maxRecentRecordCount);
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_appLogger = appLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载当前本地日期对应的处理记录与统计数据。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止读取操作,并返回已取消任务。</param>
|
||||
/// <returns>当前日期的最近记录与统计结果。</returns>
|
||||
public async Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _syncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await LoadDayCoreAsync(GetCurrentLocalDate(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当检测到跨天时重新加载当前日期数据;若日期未变化则返回 <see langword="null"/>。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止检查与读取操作,并返回已取消任务。</param>
|
||||
/// <returns>日期变化时返回新的加载结果;否则返回 <see langword="null"/>。</returns>
|
||||
public async Task<BoardRecordLoadResult?> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将一条处理记录追加写入其完成日 CSV,并返回当前日期对应的最新列表与统计。
|
||||
/// </summary>
|
||||
/// <param name="record">待持久化的处理记录。</param>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止写入操作,并返回已取消任务。</param>
|
||||
/// <returns>追加结果;若写入失败,返回的列表与统计保持在最近一次成功持久化后的真值。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="record"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||
public async Task<BoardRecordAppendResult> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定日期的 CSV 文件并刷新缓存。
|
||||
/// </summary>
|
||||
/// <param name="date">目标日期。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>读取结果。</returns>
|
||||
private async Task<BoardRecordLoadResult> 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<BoardProcessRecord>();
|
||||
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<BoardProcessRecord> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新当前日期缓存。
|
||||
/// </summary>
|
||||
/// <param name="date">缓存日期。</param>
|
||||
/// <param name="result">缓存结果。</param>
|
||||
private void Cache(DateOnly date, BoardRecordLoadResult result)
|
||||
{
|
||||
_cachedCurrentDay = date;
|
||||
_cachedCurrentDayResult = CloneResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建空结果。
|
||||
/// </summary>
|
||||
/// <param name="date">统计日期。</param>
|
||||
/// <returns>空结果对象。</returns>
|
||||
private BoardRecordLoadResult CreateEmptyResult(DateOnly date)
|
||||
{
|
||||
return new BoardRecordLoadResult
|
||||
{
|
||||
RecentRecords = Array.Empty<BoardProcessRecord>(),
|
||||
Statistics = new BoardRecordStatistics
|
||||
{
|
||||
StatisticDate = date,
|
||||
LastUpdatedAt = _timeProvider.GetLocalNow()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建追加结果对象。
|
||||
/// </summary>
|
||||
/// <param name="isPersisted">是否写入成功。</param>
|
||||
/// <param name="errorMessage">错误消息。</param>
|
||||
/// <param name="result">当前日期结果。</param>
|
||||
/// <returns>追加结果对象。</returns>
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 克隆加载结果对象。
|
||||
/// </summary>
|
||||
/// <param name="result">源结果。</param>
|
||||
/// <returns>结果副本。</returns>
|
||||
private static BoardRecordLoadResult CloneResult(BoardRecordLoadResult result)
|
||||
{
|
||||
return new BoardRecordLoadResult
|
||||
{
|
||||
RecentRecords = result.RecentRecords.Select(CloneRecord).ToList(),
|
||||
Statistics = result.Statistics.Clone()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前本地日期。
|
||||
/// </summary>
|
||||
/// <returns>当前本地日期。</returns>
|
||||
private DateOnly GetCurrentLocalDate()
|
||||
{
|
||||
return DateOnly.FromDateTime(_timeProvider.GetLocalNow().LocalDateTime.Date);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定日期的 CSV 文件路径。
|
||||
/// </summary>
|
||||
/// <param name="date">目标日期。</param>
|
||||
/// <returns>CSV 文件绝对路径。</returns>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将处理记录转换为单行 CSV 文本。
|
||||
/// </summary>
|
||||
/// <param name="record">待转换记录。</param>
|
||||
/// <returns>CSV 文本。</returns>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析单行 CSV 记录。
|
||||
/// </summary>
|
||||
/// <param name="line">CSV 文本。</param>
|
||||
/// <param name="record">解析成功的记录对象。</param>
|
||||
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
||||
private static bool TryParseRecord(string line, out BoardProcessRecord? record)
|
||||
{
|
||||
record = null;
|
||||
List<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 CSV 行。
|
||||
/// </summary>
|
||||
/// <param name="line">CSV 文本。</param>
|
||||
/// <returns>字段列表。</returns>
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对 CSV 字段执行转义。
|
||||
/// </summary>
|
||||
/// <param name="value">原始字段文本。</param>
|
||||
/// <returns>转义后的文本。</returns>
|
||||
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("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建处理记录副本。
|
||||
/// </summary>
|
||||
/// <param name="record">待复制记录。</param>
|
||||
/// <returns>记录副本。</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<WorkflowHostedService> _appLogger;
|
||||
@@ -52,6 +53,30 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
IAppStateStore stateStore,
|
||||
AppConfig config,
|
||||
IAppLogger<WorkflowHostedService> appLogger)
|
||||
: this(plcService, scannerService, sftpLookupService, andonService, stateStore, config, appLogger, new NullBoardRecordRepository())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化流程后台服务。
|
||||
/// </summary>
|
||||
/// <param name="plcService">PLC 服务。</param>
|
||||
/// <param name="scannerService">扫码枪服务。</param>
|
||||
/// <param name="sftpLookupService">SFTP 校验服务。</param>
|
||||
/// <param name="andonService">安灯服务。</param>
|
||||
/// <param name="stateStore">运行态存储。</param>
|
||||
/// <param name="config">应用配置。</param>
|
||||
/// <param name="appLogger">日志记录器。</param>
|
||||
/// <param name="boardRecordRepository">处理记录仓储。</param>
|
||||
public WorkflowHostedService(
|
||||
IPlcService plcService,
|
||||
IScannerService scannerService,
|
||||
ISftpLookupService sftpLookupService,
|
||||
IAndonService andonService,
|
||||
IAppStateStore stateStore,
|
||||
AppConfig config,
|
||||
IAppLogger<WorkflowHostedService> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在应用启动时恢复当天处理记录与统计信息。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示恢复完成的任务。</returns>
|
||||
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, "启动时恢复当日处理记录失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在检测到日期切换时刷新当天处理记录与统计信息。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示刷新完成的任务。</returns>
|
||||
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, "刷新当日处理记录失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将流程完成记录持久化到 CSV,并刷新首页历史区。
|
||||
/// </summary>
|
||||
/// <param name="record">待持久化的处理记录。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示持久化处理完成的任务。</returns>
|
||||
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, "处理记录持久化失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在发生系统级异常时进入故障状态。
|
||||
/// </summary>
|
||||
@@ -1134,4 +1236,41 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
_ => "未知结果"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提供空实现的处理记录仓储,供未接入持久化时兜底使用。
|
||||
/// </summary>
|
||||
private sealed class NullBoardRecordRepository : IBoardRecordRepository
|
||||
{
|
||||
public Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new BoardRecordLoadResult
|
||||
{
|
||||
Statistics = new BoardRecordStatistics
|
||||
{
|
||||
StatisticDate = DateOnly.FromDateTime(DateTime.Today),
|
||||
LastUpdatedAt = DateTimeOffset.Now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<BoardRecordLoadResult?>(null);
|
||||
}
|
||||
|
||||
public Task<BoardRecordAppendResult> 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,35 @@ public interface IWorkflowControlService
|
||||
Task TestAndonAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义单板处理记录的 CSV 持久化能力。
|
||||
/// </summary>
|
||||
public interface IBoardRecordRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载当前本地日期对应的处理记录与统计数据。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止读取操作,并返回已取消任务。</param>
|
||||
/// <returns>当前日期的最近记录与统计结果。</returns>
|
||||
Task<BoardRecordLoadResult> LoadCurrentDayAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 当检测到跨天时重新加载当前日期数据;若日期未变化则返回 <see langword="null"/>。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止检查与读取操作,并返回已取消任务。</param>
|
||||
/// <returns>日期变化时返回新的加载结果;否则返回 <see langword="null"/>。</returns>
|
||||
Task<BoardRecordLoadResult?> ReloadCurrentDayIfDateChangedAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 将一条处理记录追加写入其完成日 CSV,并返回当前日期对应的最新列表与统计。
|
||||
/// </summary>
|
||||
/// <param name="record">待持久化的处理记录。</param>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止写入操作,并返回已取消任务。</param>
|
||||
/// <returns>追加结果;若写入失败,返回的列表与统计保持在最近一次成功持久化后的真值。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="record"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||
Task<BoardRecordAppendResult> AppendAsync(BoardProcessRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义管理员解锁密码弹窗交互能力。
|
||||
/// </summary>
|
||||
@@ -276,6 +305,21 @@ public interface IAppStateStore
|
||||
/// <param name="updateAction">用于修改快照的更新委托。</param>
|
||||
void UpdateSnapshot(Action<RuntimeSnapshot> updateAction);
|
||||
|
||||
/// <summary>
|
||||
/// 使用一批最近记录与统计结果替换当前历史区状态。
|
||||
/// </summary>
|
||||
/// <param name="records">最近记录集合,通常已按完成时间倒序排列。</param>
|
||||
/// <param name="statistics">与当前日期对应的统计结果。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="records"/> 或 <paramref name="statistics"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||
void ReplaceRecords(IReadOnlyList<BoardProcessRecord> records, BoardRecordStatistics statistics);
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前历史区统计结果,不修改最近记录集合。
|
||||
/// </summary>
|
||||
/// <param name="statistics">最新统计结果。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="statistics"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||
void UpdateRecordStatistics(BoardRecordStatistics statistics);
|
||||
|
||||
/// <summary>
|
||||
/// 追加一条 UI 日志。
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
@@ -62,10 +63,8 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
_stateStore.SnapshotChanged += OnSnapshotChanged;
|
||||
_stateStore.LogAdded += OnLogAdded;
|
||||
_stateStore.RecordAdded += OnRecordAdded;
|
||||
ApplySnapshot(_stateStore.GetSnapshot());
|
||||
RecalculateLogStatistics();
|
||||
RecalculateProcessStatistics();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -225,6 +224,12 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string _lastProcessUpdateTime = "-";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前处理记录统计日期文本。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _recordStatisticDateText = "-";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置管理员功能是否已解锁。
|
||||
/// </summary>
|
||||
@@ -377,7 +382,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private void RefreshProcessRecords()
|
||||
{
|
||||
RecalculateProcessStatistics();
|
||||
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -450,7 +483,8 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
/// <param name="e">集合变化参数。</param>
|
||||
private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
RecalculateProcessStatistics();
|
||||
HasProcessRecords = RecentBoards.Count > 0;
|
||||
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -488,42 +522,6 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前处理记录集合重新计算处理区的统计信息与最后刷新时间。
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理运行态快照变化事件。
|
||||
/// </summary>
|
||||
@@ -531,7 +529,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
/// <param name="snapshot">最新快照。</param>
|
||||
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
|
||||
{
|
||||
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
||||
await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -541,34 +539,9 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
/// <param name="entry">新增日志。</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理新增单板记录事件。
|
||||
/// </summary>
|
||||
/// <param name="sender">事件源。</param>
|
||||
/// <param name="record">新增单板记录。</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理管理员解锁倒计时,超时后自动恢复锁定。
|
||||
/// </summary>
|
||||
/// <param name="sender">事件源。</param>
|
||||
@@ -641,4 +614,45 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。
|
||||
/// </summary>
|
||||
/// <param name="snapshot">待应用的运行态快照。</param>
|
||||
/// <returns>表示刷新流程结束的任务。</returns>
|
||||
private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 UI 线程中安全追加日志;若应用关闭导致 Dispatcher 调度被取消,则忽略本次日志刷新。
|
||||
/// </summary>
|
||||
/// <param name="entry">待追加的日志项。</param>
|
||||
/// <returns>表示日志刷新流程结束的任务。</returns>
|
||||
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 事件将其上抛为未处理异常。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,23 @@ public partial class SystemSettingViewModel : ObservableObject
|
||||
/// <param name="snapshot">最新快照。</param>
|
||||
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
|
||||
{
|
||||
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
||||
await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。
|
||||
/// </summary>
|
||||
/// <param name="snapshot">待应用的运行态快照。</param>
|
||||
/// <returns>表示刷新流程结束的任务。</returns>
|
||||
private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,6 +1131,8 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="24" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0"
|
||||
@@ -1142,6 +1144,12 @@
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Style="{StaticResource CardFooterTextStyle}">
|
||||
<Run Text="统计日期:" />
|
||||
<Run Text="{Binding RecordStatisticDateText, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Column="3"
|
||||
Style="{StaticResource CardFooterTextStyle}">
|
||||
<Run Text="最后刷新:" />
|
||||
<Run Text="{Binding LastProcessUpdateTime, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"MaxUiLogEntries": 200,
|
||||
"MaxBoardRecords": 100
|
||||
},
|
||||
"RecordPersistence": {
|
||||
"DirectoryPath": "Data\\Records",
|
||||
"DayChangeCheckIntervalSeconds": 30
|
||||
},
|
||||
"Security": {
|
||||
"AdminPassword": "AxiOmron@123",
|
||||
"AdminSessionTimeoutMinutes": 15
|
||||
|
||||
207
tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs
Normal file
207
tests/AxiOmron.PcbCheck.Tests/CsvBoardRecordRepositoryTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
236
tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
Normal file
236
tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
Reference in New Issue
Block a user