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