using HslCommunication;
using Serilog.Events;
using YZWater.Core.Services;
using YZWater.Core.Services.PlcDrivers;
namespace YZWater.Core.Tests.Services;
///
/// 连接状态语义的回归测试。
/// 背景:现场出现过"TCP 握得上但整轮读不到数据"(PLC 在 STOP、CPU 型号/机架槽填错、
/// 连接资源被占满都会这样)。改动前 MarkLinkDown 把"会话是否建立"和"读得到数据"写成同一个字段,
/// 轮询循环又按对外状态决定要不要重连,于是每秒一次"握手成功→宣布已连接→读取失败→宣布中断":
/// 报警和界面指示灯抖动、每秒一次 S7 握手耗尽 CPU 连接资源、重试计数被清零导致降级模式永远进不去。
/// 这里用假驱动把拆开的语义钉住:会话和"读得到数据"必须分两步确认。
///
// LogService.MinimumLevel 是进程级静态,会污染 LogLevelSwitchTests 的默认值断言,必须同队列串行跑
[Collection("Serilog minimum level")]
public class PlcConnectionStateTests : IDisposable
{
private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
public PlcConnectionStateTests()
{
// 状态翻转本身会记 Information/Warning("整轮读取均失败"),压掉以免污染测试输出
LogService.ApplyMinimumLevel("Error");
}
///
/// 握手通、但一次数据都没读到:对外必须是"中断"。
/// 这条最容易出错 —— 握手成功看起来就是"连上了",可操作员要的是"读不读得到"。
///
[Fact]
public async Task SessionUp_ButNeverReadData_IsNotConnected()
{
var driver = new FakeDriver { Reachable = true, ServesData = false };
PlcService.UseDriver(driver);
var connected = await PlcService.ConnectAsync();
Assert.True(connected, "会话确实建立起来了");
Assert.True(PlcService.IsSocketConnected);
Assert.False(PlcService.IsConnected, "一次数据都没读到,不能报已连接");
}
/// 读到数据之后才算真的连通,而且状态事件只发一次。
[Fact]
public async Task ReadSuccess_ConnectsAndFiresOnce()
{
var driver = new FakeDriver();
PlcService.UseDriver(driver);
await PlcService.ConnectAsync();
var transitions = new List();
PlcService.ConnectionChanged += OnChanged;
try
{
await PlcService.TryReadFloatAsync("DB1,DWD0");
await PlcService.TryReadFloatAsync("DB1,DWD4");
Assert.True(PlcService.IsConnected);
Assert.Equal(new[] { true }, transitions);
}
finally
{
PlcService.ConnectionChanged -= OnChanged;
}
void OnChanged(bool connected) => transitions.Add(connected);
}
///
/// "整轮读不到"只作废读取结论,不能把会话一起抹掉 ——
/// 否则轮询循环分不清"该重连"和"该继续读",就会每秒握手一次。
///
[Fact]
public async Task MarkLinkDown_KeepsSessionAliveForReads()
{
var driver = new FakeDriver();
PlcService.UseDriver(driver);
await PlcService.ConnectAsync();
await PlcService.TryReadFloatAsync("DB1,DWD0");
PlcService.MarkLinkDown("本轮测点全部读取失败");
Assert.False(PlcService.IsConnected, "读不到数据,对外应报中断");
Assert.True(PlcService.IsSocketConnected, "会话还在,下一轮要继续读而不是重连");
}
/// 已经判过"读不到",再判一次不该重复发事件(每秒一条会把日志和报警刷满)。
[Fact]
public async Task MarkLinkDown_DoesNotRejudgeWhileAlreadyDown()
{
var driver = new FakeDriver();
PlcService.UseDriver(driver);
await PlcService.ConnectAsync();
await PlcService.TryReadFloatAsync("DB1,DWD0");
PlcService.MarkLinkDown("第一次判定");
var transitions = new List();
PlcService.ConnectionChanged += OnChanged;
try
{
PlcService.MarkLinkDown("第二次判定");
Assert.Empty(transitions);
}
finally
{
PlcService.ConnectionChanged -= OnChanged;
}
void OnChanged(bool connected) => transitions.Add(connected);
}
/// 读不到之后又能读了:状态自动恢复,且只发一次"已连接"。
[Fact]
public async Task ReadRecovery_RestoresConnected()
{
var driver = new FakeDriver();
PlcService.UseDriver(driver);
await PlcService.ConnectAsync();
await PlcService.TryReadFloatAsync("DB1,DWD0");
PlcService.MarkLinkDown("本轮全部读取失败");
var transitions = new List();
PlcService.ConnectionChanged += OnChanged;
try
{
await PlcService.TryReadFloatAsync("DB1,DWD0");
Assert.True(PlcService.IsConnected);
Assert.Equal(new[] { true }, transitions);
}
finally
{
PlcService.ConnectionChanged -= OnChanged;
}
void OnChanged(bool connected) => transitions.Add(connected);
}
///
/// 会话断开(重连失败/手动断开)时两个结论一起作废:重连成功不等于读得到数据。
///
[Fact]
public async Task SessionDown_ClearsBothVerdicts()
{
var driver = new FakeDriver();
PlcService.UseDriver(driver);
await PlcService.ConnectAsync();
await PlcService.TryReadFloatAsync("DB1,DWD0");
Assert.True(PlcService.IsConnected);
driver.Reachable = false;
var connected = await PlcService.ConnectAsync();
Assert.False(connected);
Assert.False(PlcService.IsSocketConnected);
Assert.False(PlcService.IsConnected);
// 会话重新建立,但读取仍没确认之前不能报"已连接"
driver.Reachable = true;
driver.ServesData = false;
await PlcService.ConnectAsync();
Assert.True(PlcService.IsSocketConnected);
Assert.False(PlcService.IsConnected);
}
/// 界面上改了 IP 重新初始化,不能沿用上一台设备的"已连接"结论。
[Fact]
public async Task Initialize_ResetsStaleConnectedVerdict()
{
var driver = new FakeDriver();
PlcService.UseDriver(driver);
await PlcService.ConnectAsync();
await PlcService.TryReadFloatAsync("DB1,DWD0");
Assert.True(PlcService.IsConnected);
PlcService.Initialize();
Assert.False(PlcService.IsConnected);
Assert.False(PlcService.IsSocketConnected);
}
public void Dispose()
{
PlcService.UseDriver(null);
LogService.ApplyMinimumLevel(_originalLevel.ToString());
}
///
/// 可分别控制"握不握得上"和"给不给数据"的假驱动 —— 现场最难复现的就是这两个状态不一致。
///
private sealed class FakeDriver : IPlcDriver
{
public bool Reachable { get; set; } = true;
public bool ServesData { get; set; } = true;
public string Name => "Fake";
public bool IsConnected { get; private set; }
public Task ConnectAsync()
{
IsConnected = Reachable;
return Task.FromResult(Reachable
? OperateResult.CreateSuccessResult()
: new OperateResult("模拟握手失败"));
}
public void Disconnect() => IsConnected = false;
public Task> ReadBytesAsync(string address, ushort length) =>
Task.FromResult(ServesData
? OperateResult.CreateSuccessResult(new byte[length])
: new OperateResult("模拟读取失败"));
public Task> ReadFloatAsync(string address) =>
Task.FromResult(ServesData
? OperateResult.CreateSuccessResult(1.23f)
: new OperateResult("模拟读取失败"));
public Task> ReadBoolAsync(string address) =>
Task.FromResult(ServesData
? OperateResult.CreateSuccessResult(true)
: new OperateResult("模拟读取失败"));
public Task WriteAsync(string address, byte[] value) => Ok();
public Task WriteFloatAsync(string address, float value) => Ok();
public Task WriteBoolAsync(string address, bool value) => Ok();
public void Dispose() => IsConnected = false;
// 注意:Hsl 的 new OperateResult() 默认是"未知错误"(IsSuccess=false),
// 成功结果必须用 CreateSuccessResult 造,否则假驱动永远读不到数据
private static Task Ok() => Task.FromResult(OperateResult.CreateSuccessResult());
}
}