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()); } }