✨ feat(*): 更新 PLC 相关功能与配置
- 在 appConfig.json 中添加 EndianFormat 配置项并更新相关输入输出地址 - 修改 ResultCode 类型为 short,确保与 PLC 通信一致 - 新增 DesignTimePlcService 以支持设计时的 PLC 测试写入功能 - 更新 SystemSettingViewModel 和 DashboardPage 以支持手动测试写入 - 调整多个视图模型和服务以适应新的 PLC 写入逻辑 - 更新相关单元测试以验证新功能的正确性
This commit is contained in:
BIN
src/AxiOmron.PcbCheck/Assets/AppIcon.ico
Normal file
BIN
src/AxiOmron.PcbCheck/Assets/AppIcon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
src/AxiOmron.PcbCheck/Assets/AppIcon.png
Normal file
BIN
src/AxiOmron.PcbCheck/Assets/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -5,6 +5,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
|
||||
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration</InterceptorsNamespaces>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -18,6 +20,10 @@
|
||||
<PackageReference Include="SSH.NET" Version="2020.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\AppIcon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appConfig.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
|
||||
@@ -5,7 +5,7 @@ using AxiOmron.PcbCheck.Models;
|
||||
namespace AxiOmron.PcbCheck.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// 将 <see cref="WorkflowResultCode"/>(以 <see cref="ushort"/> 存储)映射为简短 Tag 文本。
|
||||
/// 将 <see cref="WorkflowResultCode"/>(以 <see cref="short"/> 存储)映射为简短 Tag 文本。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 映射规则:Passed → "OK";Processing → "处理中";None → "-";其余视为 "NG"。
|
||||
@@ -20,21 +20,21 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter
|
||||
/// <param name="parameter">扩展参数。</param>
|
||||
/// <param name="culture">当前区域信息。</param>
|
||||
/// <returns>简短标签文本。</returns>
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
ushort code = value switch
|
||||
short code = value switch
|
||||
{
|
||||
ushort u => u,
|
||||
int i => (ushort)i,
|
||||
WorkflowResultCode rc => (ushort)rc,
|
||||
_ => (ushort)0
|
||||
short s => s,
|
||||
int i => (short)i,
|
||||
WorkflowResultCode rc => (short)rc,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
return code switch
|
||||
{
|
||||
(ushort)WorkflowResultCode.Passed => "OK",
|
||||
(ushort)WorkflowResultCode.Processing => "处理中",
|
||||
(ushort)WorkflowResultCode.None => "-",
|
||||
(short)WorkflowResultCode.Passed => "OK",
|
||||
(short)WorkflowResultCode.Processing => "处理中",
|
||||
(short)WorkflowResultCode.None => "-",
|
||||
_ => "NG"
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter
|
||||
/// <param name="parameter">扩展参数。</param>
|
||||
/// <param name="culture">当前区域信息。</param>
|
||||
/// <returns>抛出不支持异常。</returns>
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
CurrentBarcode = "PCB240417000128",
|
||||
ResultDescription = "已扫码,等待 SFTP 文件确认",
|
||||
FaultMessage = string.Empty,
|
||||
ResultCode = (ushort)WorkflowResultCode.Processing,
|
||||
ResultCode = (short)WorkflowResultCode.Processing,
|
||||
LastTriggeredAt = now.AddSeconds(-18),
|
||||
LastCompletedAt = now.AddMinutes(-2),
|
||||
IsBusy = true,
|
||||
@@ -44,7 +44,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
_snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Inputs", Name = "PlcAckRelease", Address = "10003", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) });
|
||||
_snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Outputs", Name = "PcBusy", Address = "51", CurrentValue = "True", LastUpdatedAt = now.AddSeconds(-1) });
|
||||
_snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Outputs", Name = "ReleasePermit", Address = "52", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) });
|
||||
_snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Registers", Name = "ResultCode", Address = "40001", CurrentValue = ((ushort)WorkflowResultCode.Processing).ToString(), LastUpdatedAt = now.AddSeconds(-1) });
|
||||
_snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Registers", Name = "ResultCode", Address = "40001", CurrentValue = ((short)WorkflowResultCode.Processing).ToString(), LastUpdatedAt = now.AddSeconds(-1) });
|
||||
|
||||
_logs = new List<UiLogEntry>
|
||||
{
|
||||
@@ -64,7 +64,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
Barcode = "PCB240417000125",
|
||||
ScanTryCount = 1,
|
||||
SftpTryCount = 1,
|
||||
ResultCode = (ushort)WorkflowResultCode.Passed,
|
||||
ResultCode = (short)WorkflowResultCode.Passed,
|
||||
ResultDescription = "OK 放行",
|
||||
ReleaseSent = true,
|
||||
ExceptionSummary = string.Empty
|
||||
@@ -76,7 +76,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
Barcode = "PCB240417000126",
|
||||
ScanTryCount = 3,
|
||||
SftpTryCount = 0,
|
||||
ResultCode = (ushort)WorkflowResultCode.NgReleased,
|
||||
ResultCode = (short)WorkflowResultCode.NgReleased,
|
||||
ResultDescription = "扫码失败后放行",
|
||||
ReleaseSent = true,
|
||||
ExceptionSummary = "扫码连续失败三次"
|
||||
@@ -88,7 +88,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
Barcode = "PCB240417000127",
|
||||
ScanTryCount = 1,
|
||||
SftpTryCount = 3,
|
||||
ResultCode = (ushort)WorkflowResultCode.NgReleased,
|
||||
ResultCode = (short)WorkflowResultCode.NgReleased,
|
||||
ResultDescription = "处理结果 NG,随后放行",
|
||||
ReleaseSent = true,
|
||||
ExceptionSummary = "SFTP 文件查询超时"
|
||||
@@ -97,8 +97,8 @@ public sealed class DesignTimeAppStateStore : IAppStateStore
|
||||
|
||||
_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.TodayOkCount = _records.Count(record => record.ResultCode == (short)WorkflowResultCode.Passed);
|
||||
_snapshot.TodayNgCount = _records.Count(record => record.ResultCode != (short)WorkflowResultCode.Passed);
|
||||
_snapshot.RecordsLastUpdatedAt = now;
|
||||
foreach (BoardProcessRecord record in _records)
|
||||
{
|
||||
|
||||
64
src/AxiOmron.PcbCheck/DesignTime/DesignTimePlcService.cs
Normal file
64
src/AxiOmron.PcbCheck/DesignTime/DesignTimePlcService.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using AxiOmron.PcbCheck.Models;
|
||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||
|
||||
namespace AxiOmron.PcbCheck.DesignTime;
|
||||
|
||||
/// <summary>
|
||||
/// 为设计器提供 PLC 通信能力的设计时占位服务,所有方法均为无副作用实现。
|
||||
/// </summary>
|
||||
public sealed class DesignTimePlcService : IPlcService
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回设计时输入信号快照。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>空的输入信号快照。</returns>
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PlcSignalSnapshot());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回设计时监控镜像快照。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>空的监控镜像快照。</returns>
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PlcMonitorSnapshot());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟写入流程状态,不执行实际设备通信。
|
||||
/// </summary>
|
||||
/// <param name="state">待写入状态。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>已完成任务。</returns>
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 PLC 重连,不执行实际设备通信。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>已完成任务。</returns>
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回设计时测试写入成功结果,不执行实际设备通信。
|
||||
/// </summary>
|
||||
/// <param name="address">目标地址。</param>
|
||||
/// <param name="type">变量类型。</param>
|
||||
/// <param name="value">写入值文本。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>固定成功结果。</returns>
|
||||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(PlcWriteResult.Success(address, type, value));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public sealed class DesignTimeViewModelLocator
|
||||
private readonly DesignTimeAppConfigService _appConfigService = new();
|
||||
private readonly DesignTimeAdminUnlockDialogService _adminUnlockDialogService = new();
|
||||
private readonly DesignTimeSftpLookupService _sftpLookupService = new();
|
||||
private readonly DesignTimePlcService _plcService = new();
|
||||
private MainWindowViewModel? _mainWindowViewModel;
|
||||
private SystemSettingViewModel? _systemSettingViewModel;
|
||||
|
||||
@@ -28,7 +29,7 @@ public sealed class DesignTimeViewModelLocator
|
||||
/// 获取系统设置设计时视图模型。
|
||||
/// </summary>
|
||||
public SystemSettingViewModel SystemSettingViewModel
|
||||
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService);
|
||||
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService, _plcService);
|
||||
|
||||
/// <summary>
|
||||
/// 创建首页设计时视图模型。
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
Height="950"
|
||||
MinWidth="1400"
|
||||
MinHeight="860"
|
||||
Icon="Assets/AppIcon.ico"
|
||||
Background="{DynamicResource {x:Static hc:ResourceToken.BackgroundBrush}}"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid>
|
||||
|
||||
105
src/AxiOmron.PcbCheck/Models/PlcWriteModels.cs
Normal file
105
src/AxiOmron.PcbCheck/Models/PlcWriteModels.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
namespace AxiOmron.PcbCheck.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 表示 PLC 测试写入支持的变量类型。
|
||||
/// </summary>
|
||||
public enum PlcWriteType
|
||||
{
|
||||
/// <summary>
|
||||
/// 布尔位。
|
||||
/// </summary>
|
||||
Bool = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 有符号 16 位整数。
|
||||
/// </summary>
|
||||
Int16 = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 无符号 16 位整数。
|
||||
/// </summary>
|
||||
UInt16 = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 有符号 32 位整数。
|
||||
/// </summary>
|
||||
Int32 = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 无符号 32 位整数。
|
||||
/// </summary>
|
||||
UInt32 = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 单精度浮点数。
|
||||
/// </summary>
|
||||
Float = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一次 PLC 测试写入的执行结果。
|
||||
/// </summary>
|
||||
public sealed record PlcWriteResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取一个值,指示写入是否成功。
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取面向用户的结果说明。
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取本次写入的目标地址。
|
||||
/// </summary>
|
||||
public string Address { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取本次写入的变量类型名称。
|
||||
/// </summary>
|
||||
public string TypeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取本次实际写入的文本值。
|
||||
/// </summary>
|
||||
public string WrittenText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个表示写入成功的结果。
|
||||
/// </summary>
|
||||
/// <param name="address">目标地址。</param>
|
||||
/// <param name="type">变量类型。</param>
|
||||
/// <param name="writtenText">实际写入的文本值。</param>
|
||||
/// <returns>成功结果对象。</returns>
|
||||
public static PlcWriteResult Success(string address, PlcWriteType type, string writtenText)
|
||||
{
|
||||
return new PlcWriteResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Address = address,
|
||||
TypeName = type.ToString(),
|
||||
WrittenText = writtenText,
|
||||
Message = $"写入成功:{address} = {writtenText} ({type})"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个表示写入失败的结果。
|
||||
/// </summary>
|
||||
/// <param name="address">目标地址。</param>
|
||||
/// <param name="type">变量类型。</param>
|
||||
/// <param name="message">失败说明。</param>
|
||||
/// <returns>失败结果对象。</returns>
|
||||
public static PlcWriteResult Failure(string address, PlcWriteType type, string message)
|
||||
{
|
||||
return new PlcWriteResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Address = address,
|
||||
TypeName = type.ToString(),
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public sealed class RuntimeSnapshot
|
||||
/// <summary>
|
||||
/// 获取或设置结果代码。
|
||||
/// </summary>
|
||||
public ushort ResultCode { get; set; }
|
||||
public short ResultCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置上次触发时间。
|
||||
|
||||
@@ -69,7 +69,7 @@ public enum WorkflowState
|
||||
/// <summary>
|
||||
/// 表示最终结果代码定义。
|
||||
/// </summary>
|
||||
public enum WorkflowResultCode : ushort
|
||||
public enum WorkflowResultCode : short
|
||||
{
|
||||
/// <summary>
|
||||
/// 无结果。
|
||||
@@ -182,7 +182,7 @@ public sealed class PlcProcessState
|
||||
/// <summary>
|
||||
/// 获取或设置结果代码寄存器值。
|
||||
/// </summary>
|
||||
public ushort ResultCode { get; set; }
|
||||
public short ResultCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建当前状态对象的浅拷贝。
|
||||
@@ -496,7 +496,7 @@ public sealed class BoardProcessRecord
|
||||
/// <summary>
|
||||
/// 获取或设置结果代码。
|
||||
/// </summary>
|
||||
public ushort ResultCode { get; set; }
|
||||
public short ResultCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置结果描述。
|
||||
|
||||
@@ -129,6 +129,11 @@ public sealed class PlcOptions
|
||||
/// </summary>
|
||||
public byte SourceNodeAddress { get; set; } = 25;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 PLC 寄存器字节序,默认使用 BADC 以匹配现场 D 区 Int16 高低字节顺序。
|
||||
/// </summary>
|
||||
public string? EndianFormat { get; set; } = "BADC";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置轮询周期,单位为毫秒。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>false</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"profiles": {
|
||||
"AxiOmron.PcbCheck": {
|
||||
"commandName": "Project",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,10 +247,10 @@ public sealed class CsvBoardRecordRepository : IBoardRecordRepository
|
||||
{
|
||||
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),
|
||||
OkCount = records.Count(record => record.ResultCode == (short)WorkflowResultCode.Passed),
|
||||
NgCount = records.Count(record => record.ResultCode != (short)WorkflowResultCode.Passed
|
||||
&& record.ResultCode != (short)WorkflowResultCode.Processing
|
||||
&& record.ResultCode != (short)WorkflowResultCode.None),
|
||||
LastUpdatedAt = _timeProvider.GetLocalNow()
|
||||
};
|
||||
|
||||
@@ -400,7 +400,7 @@ public sealed class CsvBoardRecordRepository : IBoardRecordRepository
|
||||
|| !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)
|
||||
|| !short.TryParse(fields[5], out short resultCode)
|
||||
|| !bool.TryParse(fields[7], out bool releaseSent)
|
||||
|| !bool.TryParse(fields[8], out bool alarmRaised))
|
||||
{
|
||||
|
||||
@@ -29,7 +29,6 @@ internal static class FileCheckPathMatcher
|
||||
public static bool IsMatch(string fileName, string candidateName, string barcode)
|
||||
{
|
||||
return fileName.Equals(candidateName, StringComparison.OrdinalIgnoreCase)
|
||||
|| WildcardMatcher.IsMatch(fileName, candidateName)
|
||||
|| fileName.Contains(barcode, StringComparison.OrdinalIgnoreCase);
|
||||
|| WildcardMatcher.IsMatch(fileName, candidateName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
{
|
||||
PcBusy = ReadBoolean(ToDataMemoryAddress(_options.Outputs.PcBusy)),
|
||||
ReleasePermit = ReadBoolean(ToDataMemoryAddress(_options.Outputs.ReleasePermit)),
|
||||
ResultCode = ReadUInt16(ToDataMemoryAddress(_options.Registers.ResultCode))
|
||||
ResultCode = ReadInt16(ToDataMemoryAddress(_options.Registers.ResultCode))
|
||||
},
|
||||
CapturedAt = capturedAt
|
||||
};
|
||||
@@ -155,6 +155,54 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向指定地址写入一个原始值,供调试界面手动测试使用。
|
||||
/// 复用与回读相同的 <see cref="_ioLock"/> 串行化,且不修改 <see cref="_lastWrittenState"/>,因此不会干扰后台轮询回读与流程状态机写入。
|
||||
/// </summary>
|
||||
/// <param name="address">完整的 FINS 地址字符串,例如 <c>D100</c> 或 <c>D100.0</c>。</param>
|
||||
/// <param name="type">写入的变量类型。</param>
|
||||
/// <param name="value">待写入值的文本表示。</param>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止本次写入并返回已取消任务。</param>
|
||||
/// <returns>包含成功状态与说明的写入结果。</returns>
|
||||
public async Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
string trimmedAddress = address.Trim();
|
||||
if (string.IsNullOrEmpty(trimmedAddress))
|
||||
{
|
||||
return PlcWriteResult.Failure(trimmedAddress, type, "写入失败:地址不能为空。");
|
||||
}
|
||||
|
||||
if (!TryParseWriteValue(type, value, out object parsedValue, out string parseError))
|
||||
{
|
||||
return PlcWriteResult.Failure(trimmedAddress, type, $"写入失败:{parseError}");
|
||||
}
|
||||
|
||||
await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureConnected();
|
||||
WriteRawValueUnsafe(trimmedAddress, parsedValue);
|
||||
string writtenText = FormatWrittenText(parsedValue);
|
||||
_logger.LogInformation("测试写入 PLC 成功,地址={Address},类型={Type},值={Value}", true, trimmedAddress, type, writtenText);
|
||||
return PlcWriteResult.Success(trimmedAddress, type, writtenText);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 仅记录并返回失败结果,不断开连接,避免干扰后台轮询回读循环。
|
||||
_logger.LogWarning(ex, "测试写入 PLC 失败,地址={Address},类型={Type}", true, trimmedAddress, type);
|
||||
return PlcWriteResult.Failure(trimmedAddress, type, $"写入失败:{ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 PLC 通信资源。
|
||||
/// </summary>
|
||||
@@ -181,7 +229,8 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
}
|
||||
|
||||
DisconnectUnsafe();
|
||||
var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD)
|
||||
EndianFormat endianFormat = ResolveEndianFormat(_options);
|
||||
var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, endianFormat)
|
||||
{
|
||||
UnitAddress = _options.UnitAddress,
|
||||
SA1 = _options.SourceNodeAddress
|
||||
@@ -189,7 +238,28 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
var openResult = client.Open();
|
||||
EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 OMRON FINS PLC 失败");
|
||||
_client = client;
|
||||
_logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port);
|
||||
_logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port},字节序={EndianFormat}", false, _options.Host, _options.Port, endianFormat);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 PLC 字节序配置。
|
||||
/// </summary>
|
||||
/// <param name="options">PLC 通信配置。</param>
|
||||
/// <returns>IoTClient 使用的字节序枚举。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="options"/> 为空时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当字节序配置为空或无法识别时抛出。</exception>
|
||||
internal static EndianFormat ResolveEndianFormat(PlcOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
string? configuredValue = options.EndianFormat?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(configuredValue)
|
||||
|| !Enum.TryParse(configuredValue, ignoreCase: true, out EndianFormat endianFormat))
|
||||
{
|
||||
throw new InvalidOperationException($"PLC 字节序配置无效:{options.EndianFormat}。支持值:ABCD、BADC、CDAB、DCBA。");
|
||||
}
|
||||
|
||||
return endianFormat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -206,15 +276,15 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取单个 D 区无符号整数。
|
||||
/// 读取单个 D 区有符号 16 位整数。
|
||||
/// </summary>
|
||||
/// <param name="address">FINS 地址。</param>
|
||||
/// <returns>读取到的寄存器值。</returns>
|
||||
private ushort ReadUInt16(string address)
|
||||
private short ReadInt16(string address)
|
||||
{
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = client.ReadUInt16(address);
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS UInt16 失败,地址={address}");
|
||||
var result = client.ReadInt16(address);
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS Int16 失败,地址={address}");
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
@@ -225,9 +295,10 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
private void WriteChangedState(PlcProcessState state)
|
||||
{
|
||||
var previous = _lastWrittenState;
|
||||
short? previousResultCode = previous is null ? null : previous.ResultCode;
|
||||
WriteBooleanIfChanged(previous?.PcBusy, state.PcBusy, ToDataMemoryAddress(_options.Outputs.PcBusy));
|
||||
WriteBooleanIfChanged(previous?.ReleasePermit, state.ReleasePermit, ToDataMemoryAddress(_options.Outputs.ReleasePermit));
|
||||
WriteUInt16IfChanged(previous?.ResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode));
|
||||
WriteInt16IfChanged(previousResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -249,12 +320,12 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅当值发生变化时写入单个 D 区无符号整数。
|
||||
/// 仅当值发生变化时写入单个 D 区有符号 16 位整数。
|
||||
/// </summary>
|
||||
/// <param name="previous">上一值。</param>
|
||||
/// <param name="current">当前值。</param>
|
||||
/// <param name="address">FINS 地址。</param>
|
||||
private void WriteUInt16IfChanged(ushort? previous, ushort current, string address)
|
||||
private void WriteInt16IfChanged(short? previous, short current, string address)
|
||||
{
|
||||
if (previous.HasValue && previous.Value == current)
|
||||
{
|
||||
@@ -263,7 +334,148 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
|
||||
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = client.Write(address, current);
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}");
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS Int16 失败,地址={address},值={current}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按变量类型将文本值解析为强类型对象。
|
||||
/// </summary>
|
||||
/// <param name="type">变量类型。</param>
|
||||
/// <param name="value">待解析的文本值。</param>
|
||||
/// <param name="parsedValue">解析成功时返回的强类型值。</param>
|
||||
/// <param name="error">解析失败时返回的错误说明。</param>
|
||||
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
||||
private static bool TryParseWriteValue(PlcWriteType type, string value, out object parsedValue, out string error)
|
||||
{
|
||||
parsedValue = default!;
|
||||
error = string.Empty;
|
||||
string text = value.Trim();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
error = "写入值不能为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case PlcWriteType.Bool:
|
||||
if (TryParseBoolean(text, out bool boolValue))
|
||||
{
|
||||
parsedValue = boolValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "布尔值仅支持 1/0、true/false。";
|
||||
return false;
|
||||
case PlcWriteType.Int16:
|
||||
if (short.TryParse(text, out short shortValue))
|
||||
{
|
||||
parsedValue = shortValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"无法将 '{text}' 解析为 Int16。";
|
||||
return false;
|
||||
case PlcWriteType.UInt16:
|
||||
if (ushort.TryParse(text, out ushort ushortValue))
|
||||
{
|
||||
parsedValue = ushortValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"无法将 '{text}' 解析为 UInt16。";
|
||||
return false;
|
||||
case PlcWriteType.Int32:
|
||||
if (int.TryParse(text, out int intValue))
|
||||
{
|
||||
parsedValue = intValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"无法将 '{text}' 解析为 Int32。";
|
||||
return false;
|
||||
case PlcWriteType.UInt32:
|
||||
if (uint.TryParse(text, out uint uintValue))
|
||||
{
|
||||
parsedValue = uintValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"无法将 '{text}' 解析为 UInt32。";
|
||||
return false;
|
||||
case PlcWriteType.Float:
|
||||
if (float.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float floatValue))
|
||||
{
|
||||
parsedValue = floatValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"无法将 '{text}' 解析为 Float。";
|
||||
return false;
|
||||
default:
|
||||
error = $"不支持的变量类型:{type}。";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析布尔文本,接受 1/0 与 true/false(忽略大小写)。
|
||||
/// </summary>
|
||||
/// <param name="text">待解析文本。</param>
|
||||
/// <param name="result">解析结果。</param>
|
||||
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
||||
private static bool TryParseBoolean(string text, out bool result)
|
||||
{
|
||||
switch (text.ToLowerInvariant())
|
||||
{
|
||||
case "1":
|
||||
case "true":
|
||||
result = true;
|
||||
return true;
|
||||
case "0":
|
||||
case "false":
|
||||
result = false;
|
||||
return true;
|
||||
default:
|
||||
result = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按强类型值调用 IoTClient 对应写入重载。
|
||||
/// </summary>
|
||||
/// <param name="address">FINS 地址。</param>
|
||||
/// <param name="parsedValue">已解析的强类型值。</param>
|
||||
private void WriteRawValueUnsafe(string address, object parsedValue)
|
||||
{
|
||||
var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。");
|
||||
var result = parsedValue switch
|
||||
{
|
||||
bool boolValue => client.Write(address, boolValue),
|
||||
short shortValue => client.Write(address, shortValue),
|
||||
ushort ushortValue => client.Write(address, ushortValue),
|
||||
int intValue => client.Write(address, intValue),
|
||||
uint uintValue => client.Write(address, uintValue),
|
||||
float floatValue => client.Write(address, floatValue),
|
||||
_ => throw new InvalidOperationException($"不支持的写入值类型:{parsedValue.GetType().Name}。")
|
||||
};
|
||||
|
||||
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS 值失败,地址={address}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将强类型值格式化为用于展示的文本。
|
||||
/// </summary>
|
||||
/// <param name="parsedValue">已解析的强类型值。</param>
|
||||
/// <returns>文本表示。</returns>
|
||||
private static string FormatWrittenText(object parsedValue)
|
||||
{
|
||||
return parsedValue switch
|
||||
{
|
||||
float floatValue => floatValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => parsedValue.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -848,7 +848,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
Barcode = _currentBarcode,
|
||||
ScanTryCount = _scanTryCount,
|
||||
SftpTryCount = _sftpTryCount,
|
||||
ResultCode = (ushort)_resultCode,
|
||||
ResultCode = (short)_resultCode,
|
||||
ResultDescription = GetResultDescription(_resultCode),
|
||||
ReleaseSent = _releaseSent,
|
||||
AlarmRaised = false,
|
||||
@@ -1082,7 +1082,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
lock (_stateSyncRoot)
|
||||
{
|
||||
updateAction(_plcState);
|
||||
_plcState.ResultCode = (ushort)_resultCode;
|
||||
_plcState.ResultCode = (short)_resultCode;
|
||||
snapshot = _plcState.Clone();
|
||||
}
|
||||
|
||||
@@ -1103,7 +1103,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
DateTimeOffset? startedAt;
|
||||
string barcode;
|
||||
ushort resultCode;
|
||||
short resultCode;
|
||||
bool isBusy;
|
||||
string faultMessage;
|
||||
|
||||
@@ -1111,7 +1111,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
||||
{
|
||||
startedAt = _currentBoardStartedAt;
|
||||
barcode = _currentBarcode;
|
||||
resultCode = (ushort)_resultCode;
|
||||
resultCode = (short)_resultCode;
|
||||
isBusy = _plcState.PcBusy;
|
||||
faultMessage = _faultLatched ? description : string.Empty;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,17 @@ public interface IPlcService
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示重连完成的任务。</returns>
|
||||
Task ForceReconnectAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 向指定地址写入一个原始值,供调试界面手动测试使用。
|
||||
/// 该写入复用与回读相同的 IO 串行化机制,且不会影响流程状态机的写入缓存,因此不会干扰后台轮询回读。
|
||||
/// </summary>
|
||||
/// <param name="address">完整的 FINS 地址字符串,例如 <c>D100</c> 或 <c>D100.0</c>。</param>
|
||||
/// <param name="type">写入的变量类型。</param>
|
||||
/// <param name="value">待写入值的文本表示,由实现按 <paramref name="type"/> 解析。</param>
|
||||
/// <param name="cancellationToken">取消令牌。取消后立即终止本次写入并返回已取消任务。</param>
|
||||
/// <returns>包含成功状态与说明的写入结果。</returns>
|
||||
Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -150,7 +150,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
/// 获取或设置结果代码。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private ushort _resultCode;
|
||||
private short _resultCode;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置最近触发时间。
|
||||
@@ -182,6 +182,12 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string _lastLogUpdateTime = "-";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置运行日志区域是否展开;该状态仅影响首页布局,不参与业务流程。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private bool _isLogAreaExpanded;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置今日异常日志条数。
|
||||
/// </summary>
|
||||
@@ -367,6 +373,15 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 切换运行日志区域展开状态,收起或恢复上方状态概览区域。
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void ToggleLogArea()
|
||||
{
|
||||
IsLogAreaExpanded = !IsLogAreaExpanded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。
|
||||
/// </summary>
|
||||
|
||||
@@ -4,7 +4,6 @@ using AxiOmron.PcbCheck.Options;
|
||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||
using AxiOmron.PcbCheck.Models;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AxiOmron.PcbCheck.ViewModels;
|
||||
|
||||
@@ -17,6 +16,7 @@ public partial class SystemSettingViewModel : ObservableObject
|
||||
private readonly IFileCheckService _fileCheckService;
|
||||
private readonly IAppStateStore _appStateStore;
|
||||
private readonly IDispatcherService _dispatcherService;
|
||||
private readonly IPlcService _plcService;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化系统设置视图模型。
|
||||
@@ -25,20 +25,24 @@ public partial class SystemSettingViewModel : ObservableObject
|
||||
/// <param name="fileCheckService">文件校验通道测试服务。</param>
|
||||
/// <param name="appStateStore">运行态快照存储。</param>
|
||||
/// <param name="dispatcherService">UI 线程调度服务。</param>
|
||||
/// <param name="plcService">PLC 通信服务,用于调试界面的测试写入。</param>
|
||||
public SystemSettingViewModel(
|
||||
IAppConfigService appConfigService,
|
||||
IFileCheckService fileCheckService,
|
||||
IAppStateStore appStateStore,
|
||||
IDispatcherService dispatcherService)
|
||||
IDispatcherService dispatcherService,
|
||||
IPlcService plcService)
|
||||
{
|
||||
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
|
||||
_fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService));
|
||||
_appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore));
|
||||
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
|
||||
_plcService = plcService ?? throw new ArgumentNullException(nameof(plcService));
|
||||
EditableConfig = _appConfigService.Load();
|
||||
ConfigPath = _appConfigService.GetConfigPath();
|
||||
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
|
||||
FileCheckProviders = Enum.GetValues<FileCheckProvider>();
|
||||
PlcWriteTypes = Enum.GetValues<PlcWriteType>();
|
||||
ApplySnapshot(_appStateStore.GetSnapshot());
|
||||
_appStateStore.SnapshotChanged += OnSnapshotChanged;
|
||||
StatusMessage = "已加载配置。";
|
||||
@@ -54,6 +58,11 @@ public partial class SystemSettingViewModel : ObservableObject
|
||||
/// </summary>
|
||||
public IReadOnlyList<FileCheckProvider> FileCheckProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取 PLC 测试写入支持的变量类型可选项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlcWriteType> PlcWriteTypes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置可编辑配置对象。
|
||||
/// </summary>
|
||||
@@ -79,6 +88,37 @@ public partial class SystemSettingViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private bool _isTestingFileCheckConnection;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试写入的目标地址,例如 D100、D100.0。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _plcWriteAddress = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试写入的变量类型。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private PlcWriteType _selectedPlcWriteType = PlcWriteType.Int16;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试写入的值文本。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _plcWriteValue = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置测试写入的状态提示文本。
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _plcWriteStatusMessage = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置当前是否正在执行 PLC 测试写入。
|
||||
/// </summary>
|
||||
[NotifyCanExecuteChangedFor(nameof(WritePlcValueCommand))]
|
||||
[ObservableProperty]
|
||||
private bool _isWritingPlc;
|
||||
|
||||
/// <summary>
|
||||
/// 保存当前配置。
|
||||
/// </summary>
|
||||
@@ -143,6 +183,44 @@ public partial class SystemSettingViewModel : ObservableObject
|
||||
/// </summary>
|
||||
private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection;
|
||||
|
||||
/// <summary>
|
||||
/// 执行一次 PLC 测试写入。该写入复用回读相同的 IO 串行化,不会干扰后台轮询回读。
|
||||
/// </summary>
|
||||
/// <returns>表示写入完成的任务。</returns>
|
||||
[RelayCommand(CanExecute = nameof(CanWritePlcValue))]
|
||||
private async Task WritePlcValueAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PlcWriteAddress))
|
||||
{
|
||||
PlcWriteStatusMessage = "请先输入写入地址。";
|
||||
return;
|
||||
}
|
||||
|
||||
IsWritingPlc = true;
|
||||
PlcWriteStatusMessage = "正在写入...";
|
||||
|
||||
try
|
||||
{
|
||||
PlcWriteResult result = await _plcService
|
||||
.WriteRawValueAsync(PlcWriteAddress, SelectedPlcWriteType, PlcWriteValue, CancellationToken.None);
|
||||
|
||||
PlcWriteStatusMessage = result.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PlcWriteStatusMessage = $"写入失败:{ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsWritingPlc = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断当前是否允许执行 PLC 测试写入。
|
||||
/// </summary>
|
||||
private bool CanWritePlcValue => !IsWritingPlc;
|
||||
|
||||
/// <summary>
|
||||
/// 将运行态快照中的 PLC 监控数据同步到当前视图模型。
|
||||
/// </summary>
|
||||
|
||||
@@ -639,11 +639,12 @@
|
||||
<Grid Grid.Row="2" Grid.Column="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="16" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0">
|
||||
<Grid Grid.Row="0"
|
||||
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1.12*" />
|
||||
<ColumnDefinition Width="12" />
|
||||
@@ -775,6 +776,10 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Height="16"
|
||||
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}" />
|
||||
|
||||
<Border Grid.Row="2" Style="{StaticResource ModernCardStyle}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
@@ -809,17 +814,29 @@
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="8" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="8" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Button Grid.Column="0"
|
||||
Style="{StaticResource ToolbarButtonStyle}"
|
||||
Command="{Binding ToggleLogAreaCommand}">
|
||||
<Grid>
|
||||
<TextBlock Text="展开日志"
|
||||
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}" />
|
||||
<TextBlock Text="恢复概览"
|
||||
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Content="全部"
|
||||
Style="{StaticResource ToolbarButtonStyle}"
|
||||
Command="{Binding ShowAllLogsCommand}" />
|
||||
<Button Grid.Column="2"
|
||||
<Button Grid.Column="4"
|
||||
Content="仅错误"
|
||||
Style="{StaticResource ToolbarButtonStyle}"
|
||||
Command="{Binding ShowErrorLogsCommand}" />
|
||||
<Button Grid.Column="4"
|
||||
<Button Grid.Column="6"
|
||||
Content="导出"
|
||||
Style="{StaticResource ToolbarButtonStyle}"
|
||||
Command="{Binding ExportLogsCommand}" />
|
||||
|
||||
@@ -140,6 +140,8 @@
|
||||
<TabItem Header="PLC监控">
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="12" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="12" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -153,6 +155,60 @@
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Background="White"
|
||||
BorderBrush="#E8ECF3"
|
||||
BorderThickness="1"
|
||||
CornerRadius="10"
|
||||
Padding="16,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="测试写入" FontSize="15" FontWeight="SemiBold" Foreground="#1F2937" />
|
||||
<TextBlock Text="手动向指定地址写入测试值,仅用于调试,不影响实时回读。"
|
||||
Margin="0,4,0,0"
|
||||
Foreground="#6B7280" />
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="1.4*" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0"
|
||||
Style="{StaticResource TextBoxExtend}"
|
||||
hc:InfoElement.Title="地址"
|
||||
hc:InfoElement.TitlePlacement="Top"
|
||||
hc:InfoElement.Placeholder="如 D100、D100.0"
|
||||
Text="{Binding PlcWriteAddress, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<ComboBox Grid.Column="2"
|
||||
Style="{StaticResource ComboBoxExtend}"
|
||||
hc:InfoElement.Title="变量类型"
|
||||
hc:InfoElement.TitlePlacement="Top"
|
||||
ItemsSource="{Binding PlcWriteTypes}"
|
||||
SelectedItem="{Binding SelectedPlcWriteType}" />
|
||||
<TextBox Grid.Column="4"
|
||||
Style="{StaticResource TextBoxExtend}"
|
||||
hc:InfoElement.Title="写入值"
|
||||
hc:InfoElement.TitlePlacement="Top"
|
||||
hc:InfoElement.Placeholder="Bool 用 1/0 或 true/false"
|
||||
Text="{Binding PlcWriteValue, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button Grid.Column="6"
|
||||
VerticalAlignment="Bottom"
|
||||
Style="{StaticResource ButtonPrimary}"
|
||||
Padding="18,8"
|
||||
MinHeight="36"
|
||||
Content="写入"
|
||||
Command="{Binding WritePlcValueCommand}" />
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding PlcWriteStatusMessage}"
|
||||
Margin="0,10,0,0"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="#2563EB" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="4"
|
||||
Background="White"
|
||||
BorderBrush="#E8ECF3"
|
||||
BorderThickness="1"
|
||||
|
||||
@@ -4,33 +4,34 @@
|
||||
"Port": 9600,
|
||||
"UnitAddress": 0,
|
||||
"SourceNodeAddress": 25,
|
||||
"EndianFormat": "BADC",
|
||||
"PollIntervalMs": 200,
|
||||
"ConnectTimeoutMs": 3000,
|
||||
"ReleasePulseMs": 500,
|
||||
"ReleaseAckTimeoutMs": 2000,
|
||||
"Inputs": {
|
||||
"PcbArrived": 1001,
|
||||
"PlcReset": 1002,
|
||||
"PlcAckRelease": 1003
|
||||
"PcbArrived": 101,
|
||||
"PlcReset": 102,
|
||||
"PlcAckRelease": 103
|
||||
},
|
||||
"Outputs": {
|
||||
"PcBusy": 1051,
|
||||
"ReleasePermit": 1052
|
||||
"PcBusy": 51,
|
||||
"ReleasePermit": 52
|
||||
},
|
||||
"Registers": {
|
||||
"ResultCode": 1100
|
||||
"ResultCode": 401
|
||||
}
|
||||
},
|
||||
"Scanner": {
|
||||
"Scanner1": {
|
||||
"Name": "扫码枪1",
|
||||
"Host": "127.0.0.1",
|
||||
"Port": 9001
|
||||
"Port": 2001
|
||||
},
|
||||
"Scanner2": {
|
||||
"Name": "扫码枪2",
|
||||
"Host": "127.0.0.1",
|
||||
"Port": 9002
|
||||
"Port": 2001
|
||||
},
|
||||
"ConnectTimeoutMs": 3000,
|
||||
"ReadTimeoutMs": 3000,
|
||||
@@ -39,7 +40,7 @@
|
||||
"TriggerInParallel": true
|
||||
},
|
||||
"FileCheck": {
|
||||
"Provider": "Sftp",
|
||||
"Provider": "Local",
|
||||
"FileNamePattern": "${barcode}.txt",
|
||||
"RetryIntervalSeconds": 2,
|
||||
"MaxRetryCount": 3,
|
||||
@@ -61,7 +62,7 @@
|
||||
"ConnectTimeoutMs": 3000
|
||||
},
|
||||
"Andon": {
|
||||
"Enable": true,
|
||||
"Enable": false,
|
||||
"Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm",
|
||||
"Method": "POST",
|
||||
"TimeoutMs": 3000,
|
||||
@@ -88,4 +89,4 @@
|
||||
"AdminPassword": "AxiOmron@123",
|
||||
"AdminSessionTimeoutMinutes": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,10 @@ public sealed class CsvBoardRecordRepositoryTests : IDisposable
|
||||
timeProvider);
|
||||
|
||||
await repository.AppendAsync(
|
||||
CreateRecord("PCB-001", new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.Passed),
|
||||
CreateRecord("PCB-001", new DateTimeOffset(2026, 4, 19, 8, 0, 0, TimeSpan.FromHours(8)), (short)WorkflowResultCode.Passed),
|
||||
CancellationToken.None);
|
||||
await repository.AppendAsync(
|
||||
CreateRecord("PCB-002", new DateTimeOffset(2026, 4, 19, 8, 5, 0, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.NgReleased),
|
||||
CreateRecord("PCB-002", new DateTimeOffset(2026, 4, 19, 8, 5, 0, TimeSpan.FromHours(8)), (short)WorkflowResultCode.NgReleased),
|
||||
CancellationToken.None);
|
||||
|
||||
BoardRecordLoadResult result = await repository.LoadCurrentDayAsync(CancellationToken.None);
|
||||
@@ -100,7 +100,7 @@ public sealed class CsvBoardRecordRepositoryTests : IDisposable
|
||||
timeProvider);
|
||||
|
||||
await repository.AppendAsync(
|
||||
CreateRecord("PCB-OLD", new DateTimeOffset(2026, 4, 19, 23, 59, 55, TimeSpan.FromHours(8)), (ushort)WorkflowResultCode.Passed),
|
||||
CreateRecord("PCB-OLD", new DateTimeOffset(2026, 4, 19, 23, 59, 55, TimeSpan.FromHours(8)), (short)WorkflowResultCode.Passed),
|
||||
CancellationToken.None);
|
||||
|
||||
timeProvider.SetLocalNow(new DateTimeOffset(2026, 4, 20, 0, 0, 10, TimeSpan.FromHours(8)));
|
||||
@@ -131,7 +131,7 @@ public sealed class CsvBoardRecordRepositoryTests : IDisposable
|
||||
/// <param name="completedAt">完成时间。</param>
|
||||
/// <param name="resultCode">结果代码。</param>
|
||||
/// <returns>测试记录。</returns>
|
||||
private static BoardProcessRecord CreateRecord(string barcode, DateTimeOffset completedAt, ushort resultCode)
|
||||
private static BoardProcessRecord CreateRecord(string barcode, DateTimeOffset completedAt, short resultCode)
|
||||
{
|
||||
return new BoardProcessRecord
|
||||
{
|
||||
@@ -141,7 +141,7 @@ public sealed class CsvBoardRecordRepositoryTests : IDisposable
|
||||
ScanTryCount = 1,
|
||||
SftpTryCount = 1,
|
||||
ResultCode = resultCode,
|
||||
ResultDescription = resultCode == (ushort)WorkflowResultCode.Passed ? "OK" : "NG",
|
||||
ResultDescription = resultCode == (short)WorkflowResultCode.Passed ? "OK" : "NG",
|
||||
ReleaseSent = true,
|
||||
AlarmRaised = false,
|
||||
ExceptionSummary = string.Empty
|
||||
|
||||
@@ -89,20 +89,20 @@ public sealed class LocalFileCheckProviderTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件名包含条码时,应返回命中文件路径。
|
||||
/// 文件名只包含条码但不等于模板生成文件名时,不应命中。
|
||||
/// </summary>
|
||||
/// <returns>异步测试任务。</returns>
|
||||
[Fact]
|
||||
public async Task CheckFileAsync_ShouldMatchFileNameContainingBarcode()
|
||||
public async Task CheckFileAsync_ShouldNotMatchFileNameContainingBarcodeOnly()
|
||||
{
|
||||
string filePath = Path.Combine(_rootPath, "prefix-PCB-002-result.dat");
|
||||
string filePath = Path.Combine(_rootPath, "abcd10.txt");
|
||||
await File.WriteAllTextAsync(filePath, "ok");
|
||||
var provider = CreateProvider();
|
||||
|
||||
FileCheckOutcome outcome = await provider.CheckFileAsync("PCB-002", CreateConfig(_rootPath), CancellationToken.None);
|
||||
FileCheckOutcome outcome = await provider.CheckFileAsync("abcd1", CreateConfig(_rootPath), CancellationToken.None);
|
||||
|
||||
Assert.True(outcome.Exists);
|
||||
Assert.Equal(filePath, outcome.MatchedFilePath);
|
||||
Assert.False(outcome.Exists);
|
||||
Assert.Empty(outcome.MatchedFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class MainWindowViewModelTests
|
||||
Barcode = $"PCB-{index:D3}",
|
||||
ScanTryCount = 1,
|
||||
SftpTryCount = 1,
|
||||
ResultCode = index % 2 == 0 ? (ushort)WorkflowResultCode.Passed : (ushort)WorkflowResultCode.NgReleased,
|
||||
ResultCode = index % 2 == 0 ? (short)WorkflowResultCode.Passed : (short)WorkflowResultCode.NgReleased,
|
||||
ResultDescription = index % 2 == 0 ? "OK" : "NG",
|
||||
ReleaseSent = true,
|
||||
AlarmRaised = false
|
||||
@@ -131,10 +131,31 @@ public sealed class MainWindowViewModelTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 日志区域展开命令应只切换界面折叠状态,不影响运行数据。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToggleLogArea_ShouldSwitchExpandedState()
|
||||
{
|
||||
var stateStore = new AppStateStore();
|
||||
var viewModel = CreateViewModel(stateStore);
|
||||
|
||||
Assert.False(viewModel.IsLogAreaExpanded);
|
||||
|
||||
viewModel.ToggleLogAreaCommand.Execute(null);
|
||||
|
||||
Assert.True(viewModel.IsLogAreaExpanded);
|
||||
|
||||
viewModel.ToggleLogAreaCommand.Execute(null);
|
||||
|
||||
Assert.False(viewModel.IsLogAreaExpanded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建测试用首页视图模型。
|
||||
/// </summary>
|
||||
/// <param name="stateStore">运行态存储。</param>
|
||||
/// <param name="dispatcherService">可选的 UI 调度服务。</param>
|
||||
/// <returns>首页视图模型。</returns>
|
||||
private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null)
|
||||
{
|
||||
|
||||
41
tests/AxiOmron.PcbCheck.Tests/OmronFinsPlcServiceTests.cs
Normal file
41
tests/AxiOmron.PcbCheck.Tests/OmronFinsPlcServiceTests.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using AxiOmron.PcbCheck.Options;
|
||||
using AxiOmron.PcbCheck.Services.Implementations;
|
||||
using IoTClient.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace AxiOmron.PcbCheck.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证欧姆龙 FINS PLC 服务的通信参数解析行为。
|
||||
/// </summary>
|
||||
public sealed class OmronFinsPlcServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认字节序应使用 BADC,以匹配现场 D 区 Int16 的高低字节顺序。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveEndianFormat_ShouldUseBadc_WhenConfigurationIsDefault()
|
||||
{
|
||||
var options = new PlcOptions();
|
||||
|
||||
EndianFormat result = OmronFinsPlcService.ResolveEndianFormat(options);
|
||||
|
||||
Assert.Equal(EndianFormat.BADC, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 非法字节序配置应抛出明确异常,避免使用错误端序静默读写 PLC 寄存器。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveEndianFormat_ShouldThrow_WhenConfigurationIsInvalid()
|
||||
{
|
||||
var options = new PlcOptions
|
||||
{
|
||||
EndianFormat = "XYZ"
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => OmronFinsPlcService.ResolveEndianFormat(options));
|
||||
|
||||
Assert.Contains("PLC 字节序配置无效", exception.Message);
|
||||
}
|
||||
}
|
||||
33
tests/AxiOmron.PcbCheck.Tests/ResultCodeTypeTests.cs
Normal file
33
tests/AxiOmron.PcbCheck.Tests/ResultCodeTypeTests.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using AxiOmron.PcbCheck.Models;
|
||||
using AxiOmron.PcbCheck.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace AxiOmron.PcbCheck.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证结果代码在模型与界面层统一使用 16 位有符号整数。
|
||||
/// </summary>
|
||||
public sealed class ResultCodeTypeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 结果代码枚举应以 Int16 作为底层类型,匹配 PLC 的 ResultCode 定义。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WorkflowResultCode_ShouldUseInt16UnderlyingType()
|
||||
{
|
||||
Assert.Equal(typeof(short), Enum.GetUnderlyingType(typeof(WorkflowResultCode)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 结果代码属性应统一使用 Int16,避免经过界面或快照时被误建模为 UInt16 或 Int32。
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(typeof(RuntimeSnapshot))]
|
||||
[InlineData(typeof(PlcProcessState))]
|
||||
[InlineData(typeof(BoardProcessRecord))]
|
||||
[InlineData(typeof(MainWindowViewModel))]
|
||||
public void ResultCodeProperties_ShouldUseInt16(Type ownerType)
|
||||
{
|
||||
Assert.Equal(typeof(short), ownerType.GetProperty("ResultCode")?.PropertyType);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public sealed class SystemSettingViewModelTests
|
||||
StatusMessage = "SFTP 连接成功,根目录可访问。"
|
||||
}
|
||||
};
|
||||
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService);
|
||||
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
|
||||
|
||||
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class SystemSettingViewModelTests
|
||||
StatusMessage = "SFTP 连接失败: timeout"
|
||||
}
|
||||
};
|
||||
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService);
|
||||
var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
|
||||
|
||||
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class SystemSettingViewModelTests
|
||||
});
|
||||
});
|
||||
|
||||
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService);
|
||||
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
|
||||
|
||||
Assert.Single(viewModel.PlcMonitorItems);
|
||||
Assert.Equal("PcbArrived", viewModel.PlcMonitorItems[0].Name);
|
||||
@@ -104,7 +104,7 @@ public sealed class SystemSettingViewModelTests
|
||||
var configService = new FakeAppConfigService();
|
||||
var stateStore = new AppStateStore();
|
||||
var dispatcherService = new CapturedDispatcherService(Dispatcher.CurrentDispatcher);
|
||||
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService);
|
||||
var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
|
||||
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
|
||||
|
||||
Exception? capturedException = null;
|
||||
@@ -160,7 +160,8 @@ public sealed class SystemSettingViewModelTests
|
||||
configService,
|
||||
new FakeFileCheckService(),
|
||||
stateStore,
|
||||
new CanceledDispatcherService());
|
||||
new CanceledDispatcherService(),
|
||||
new FakePlcService());
|
||||
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
|
||||
SynchronizationContext? originalContext = SynchronizationContext.Current;
|
||||
|
||||
@@ -296,6 +297,37 @@ public sealed class SystemSettingViewModelTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提供系统设置页测试用 PLC 服务替身。
|
||||
/// </summary>
|
||||
private sealed class FakePlcService : IPlcService
|
||||
{
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PlcSignalSnapshot());
|
||||
}
|
||||
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PlcMonitorSnapshot());
|
||||
}
|
||||
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ForceReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(PlcWriteResult.Success(address, type, value));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。
|
||||
/// </summary>
|
||||
|
||||
@@ -148,12 +148,12 @@ public sealed class WorkflowHostedServiceTests
|
||||
|
||||
Assert.True(scannerService.TriggerCount > 0);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ReleasePermit);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (short)WorkflowResultCode.Passed);
|
||||
|
||||
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcbArrived" && item.CurrentValue == "False");
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcBusy" && item.CurrentValue == "False");
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "ResultCode" && item.CurrentValue == ((ushort)WorkflowResultCode.Passed).ToString());
|
||||
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "ResultCode" && item.CurrentValue == ((short)WorkflowResultCode.Passed).ToString());
|
||||
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ public sealed class WorkflowHostedServiceTests
|
||||
|
||||
Assert.Equal(1, scannerService.TriggerCount);
|
||||
Assert.Equal("PCB-MERGED", fileCheckService.LastBarcode);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
|
||||
Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (short)WorkflowResultCode.Passed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -304,7 +304,7 @@ public sealed class WorkflowHostedServiceTests
|
||||
Barcode = "PCB-RESTORED",
|
||||
ScanTryCount = 1,
|
||||
SftpTryCount = 1,
|
||||
ResultCode = (ushort)WorkflowResultCode.Passed,
|
||||
ResultCode = (short)WorkflowResultCode.Passed,
|
||||
ResultDescription = "OK",
|
||||
ReleaseSent = true
|
||||
}
|
||||
@@ -409,7 +409,7 @@ public sealed class WorkflowHostedServiceTests
|
||||
|
||||
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
|
||||
Assert.NotEqual(WorkflowState.Faulted, snapshot.WorkflowState);
|
||||
Assert.Equal((ushort)WorkflowResultCode.Passed, snapshot.ResultCode);
|
||||
Assert.Equal((short)WorkflowResultCode.Passed, snapshot.ResultCode);
|
||||
}
|
||||
|
||||
private sealed class FakePlcService : IPlcService
|
||||
@@ -418,6 +418,7 @@ public sealed class WorkflowHostedServiceTests
|
||||
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot());
|
||||
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
|
||||
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) => Task.FromResult(PlcWriteResult.Success(address, type, value));
|
||||
}
|
||||
|
||||
private sealed class ProbingPlcService : IPlcService
|
||||
@@ -460,6 +461,11 @@ public sealed class WorkflowHostedServiceTests
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(PlcWriteResult.Success(address, type, value));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingPlcService : IPlcService
|
||||
@@ -483,6 +489,11 @@ public sealed class WorkflowHostedServiceTests
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("PLC unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedPlcService : IPlcService
|
||||
@@ -529,6 +540,11 @@ public sealed class WorkflowHostedServiceTests
|
||||
WrittenStates.Add(state.Clone());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(PlcWriteResult.Success(address, type, value));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeScannerService : IScannerService
|
||||
|
||||
Reference in New Issue
Block a user