PlcConnectionStateTests.cs 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. using HslCommunication;
  2. using Serilog.Events;
  3. using YZWater.Core.Services;
  4. using YZWater.Core.Services.PlcDrivers;
  5. namespace YZWater.Core.Tests.Services;
  6. /// <summary>
  7. /// 连接状态语义的回归测试。
  8. /// 背景:现场出现过"TCP 握得上但整轮读不到数据"(PLC 在 STOP、CPU 型号/机架槽填错、
  9. /// 连接资源被占满都会这样)。改动前 MarkLinkDown 把"会话是否建立"和"读得到数据"写成同一个字段,
  10. /// 轮询循环又按对外状态决定要不要重连,于是每秒一次"握手成功→宣布已连接→读取失败→宣布中断":
  11. /// 报警和界面指示灯抖动、每秒一次 S7 握手耗尽 CPU 连接资源、重试计数被清零导致降级模式永远进不去。
  12. /// 这里用假驱动把拆开的语义钉住:会话和"读得到数据"必须分两步确认。
  13. /// </summary>
  14. // LogService.MinimumLevel 是进程级静态,会污染 LogLevelSwitchTests 的默认值断言,必须同队列串行跑
  15. [Collection("Serilog minimum level")]
  16. public class PlcConnectionStateTests : IDisposable
  17. {
  18. private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
  19. public PlcConnectionStateTests()
  20. {
  21. // 状态翻转本身会记 Information/Warning("整轮读取均失败"),压掉以免污染测试输出
  22. LogService.ApplyMinimumLevel("Error");
  23. }
  24. /// <summary>
  25. /// 握手通、但一次数据都没读到:对外必须是"中断"。
  26. /// 这条最容易出错 —— 握手成功看起来就是"连上了",可操作员要的是"读不读得到"。
  27. /// </summary>
  28. [Fact]
  29. public async Task SessionUp_ButNeverReadData_IsNotConnected()
  30. {
  31. var driver = new FakeDriver { Reachable = true, ServesData = false };
  32. PlcService.UseDriver(driver);
  33. var connected = await PlcService.ConnectAsync();
  34. Assert.True(connected, "会话确实建立起来了");
  35. Assert.True(PlcService.IsSocketConnected);
  36. Assert.False(PlcService.IsConnected, "一次数据都没读到,不能报已连接");
  37. }
  38. /// <summary>读到数据之后才算真的连通,而且状态事件只发一次。</summary>
  39. [Fact]
  40. public async Task ReadSuccess_ConnectsAndFiresOnce()
  41. {
  42. var driver = new FakeDriver();
  43. PlcService.UseDriver(driver);
  44. await PlcService.ConnectAsync();
  45. var transitions = new List<bool>();
  46. PlcService.ConnectionChanged += OnChanged;
  47. try
  48. {
  49. await PlcService.TryReadFloatAsync("DB1,DWD0");
  50. await PlcService.TryReadFloatAsync("DB1,DWD4");
  51. Assert.True(PlcService.IsConnected);
  52. Assert.Equal(new[] { true }, transitions);
  53. }
  54. finally
  55. {
  56. PlcService.ConnectionChanged -= OnChanged;
  57. }
  58. void OnChanged(bool connected) => transitions.Add(connected);
  59. }
  60. /// <summary>
  61. /// "整轮读不到"只作废读取结论,不能把会话一起抹掉 ——
  62. /// 否则轮询循环分不清"该重连"和"该继续读",就会每秒握手一次。
  63. /// </summary>
  64. [Fact]
  65. public async Task MarkLinkDown_KeepsSessionAliveForReads()
  66. {
  67. var driver = new FakeDriver();
  68. PlcService.UseDriver(driver);
  69. await PlcService.ConnectAsync();
  70. await PlcService.TryReadFloatAsync("DB1,DWD0");
  71. PlcService.MarkLinkDown("本轮测点全部读取失败");
  72. Assert.False(PlcService.IsConnected, "读不到数据,对外应报中断");
  73. Assert.True(PlcService.IsSocketConnected, "会话还在,下一轮要继续读而不是重连");
  74. }
  75. /// <summary>已经判过"读不到",再判一次不该重复发事件(每秒一条会把日志和报警刷满)。</summary>
  76. [Fact]
  77. public async Task MarkLinkDown_DoesNotRejudgeWhileAlreadyDown()
  78. {
  79. var driver = new FakeDriver();
  80. PlcService.UseDriver(driver);
  81. await PlcService.ConnectAsync();
  82. await PlcService.TryReadFloatAsync("DB1,DWD0");
  83. PlcService.MarkLinkDown("第一次判定");
  84. var transitions = new List<bool>();
  85. PlcService.ConnectionChanged += OnChanged;
  86. try
  87. {
  88. PlcService.MarkLinkDown("第二次判定");
  89. Assert.Empty(transitions);
  90. }
  91. finally
  92. {
  93. PlcService.ConnectionChanged -= OnChanged;
  94. }
  95. void OnChanged(bool connected) => transitions.Add(connected);
  96. }
  97. /// <summary>读不到之后又能读了:状态自动恢复,且只发一次"已连接"。</summary>
  98. [Fact]
  99. public async Task ReadRecovery_RestoresConnected()
  100. {
  101. var driver = new FakeDriver();
  102. PlcService.UseDriver(driver);
  103. await PlcService.ConnectAsync();
  104. await PlcService.TryReadFloatAsync("DB1,DWD0");
  105. PlcService.MarkLinkDown("本轮全部读取失败");
  106. var transitions = new List<bool>();
  107. PlcService.ConnectionChanged += OnChanged;
  108. try
  109. {
  110. await PlcService.TryReadFloatAsync("DB1,DWD0");
  111. Assert.True(PlcService.IsConnected);
  112. Assert.Equal(new[] { true }, transitions);
  113. }
  114. finally
  115. {
  116. PlcService.ConnectionChanged -= OnChanged;
  117. }
  118. void OnChanged(bool connected) => transitions.Add(connected);
  119. }
  120. /// <summary>
  121. /// 会话断开(重连失败/手动断开)时两个结论一起作废:重连成功不等于读得到数据。
  122. /// </summary>
  123. [Fact]
  124. public async Task SessionDown_ClearsBothVerdicts()
  125. {
  126. var driver = new FakeDriver();
  127. PlcService.UseDriver(driver);
  128. await PlcService.ConnectAsync();
  129. await PlcService.TryReadFloatAsync("DB1,DWD0");
  130. Assert.True(PlcService.IsConnected);
  131. driver.Reachable = false;
  132. var connected = await PlcService.ConnectAsync();
  133. Assert.False(connected);
  134. Assert.False(PlcService.IsSocketConnected);
  135. Assert.False(PlcService.IsConnected);
  136. // 会话重新建立,但读取仍没确认之前不能报"已连接"
  137. driver.Reachable = true;
  138. driver.ServesData = false;
  139. await PlcService.ConnectAsync();
  140. Assert.True(PlcService.IsSocketConnected);
  141. Assert.False(PlcService.IsConnected);
  142. }
  143. /// <summary>界面上改了 IP 重新初始化,不能沿用上一台设备的"已连接"结论。</summary>
  144. [Fact]
  145. public async Task Initialize_ResetsStaleConnectedVerdict()
  146. {
  147. var driver = new FakeDriver();
  148. PlcService.UseDriver(driver);
  149. await PlcService.ConnectAsync();
  150. await PlcService.TryReadFloatAsync("DB1,DWD0");
  151. Assert.True(PlcService.IsConnected);
  152. PlcService.Initialize();
  153. Assert.False(PlcService.IsConnected);
  154. Assert.False(PlcService.IsSocketConnected);
  155. }
  156. public void Dispose()
  157. {
  158. PlcService.UseDriver(null);
  159. LogService.ApplyMinimumLevel(_originalLevel.ToString());
  160. }
  161. /// <summary>
  162. /// 可分别控制"握不握得上"和"给不给数据"的假驱动 —— 现场最难复现的就是这两个状态不一致。
  163. /// </summary>
  164. private sealed class FakeDriver : IPlcDriver
  165. {
  166. public bool Reachable { get; set; } = true;
  167. public bool ServesData { get; set; } = true;
  168. public string Name => "Fake";
  169. public bool IsConnected { get; private set; }
  170. public Task<OperateResult> ConnectAsync()
  171. {
  172. IsConnected = Reachable;
  173. return Task.FromResult(Reachable
  174. ? OperateResult.CreateSuccessResult()
  175. : new OperateResult("模拟握手失败"));
  176. }
  177. public void Disconnect() => IsConnected = false;
  178. public Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length) =>
  179. Task.FromResult(ServesData
  180. ? OperateResult<byte[]>.CreateSuccessResult(new byte[length])
  181. : new OperateResult<byte[]>("模拟读取失败"));
  182. public Task<OperateResult<float>> ReadFloatAsync(string address) =>
  183. Task.FromResult(ServesData
  184. ? OperateResult<float>.CreateSuccessResult(1.23f)
  185. : new OperateResult<float>("模拟读取失败"));
  186. public Task<OperateResult<bool>> ReadBoolAsync(string address) =>
  187. Task.FromResult(ServesData
  188. ? OperateResult<bool>.CreateSuccessResult(true)
  189. : new OperateResult<bool>("模拟读取失败"));
  190. public Task<OperateResult> WriteAsync(string address, byte[] value) => Ok();
  191. public Task<OperateResult> WriteFloatAsync(string address, float value) => Ok();
  192. public Task<OperateResult> WriteBoolAsync(string address, bool value) => Ok();
  193. public void Dispose() => IsConnected = false;
  194. // 注意:Hsl 的 new OperateResult() 默认是"未知错误"(IsSuccess=false),
  195. // 成功结果必须用 CreateSuccessResult 造,否则假驱动永远读不到数据
  196. private static Task<OperateResult> Ok() => Task.FromResult(OperateResult.CreateSuccessResult());
  197. }
  198. }