| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- using Xunit;
- using Serilog.Events;
- using YZWater.Core.Models;
- using YZWater.Core.Services;
- namespace YZWater.Core.Tests.Services;
- /// <summary>
- /// 报警内存状态的并发回归测试。
- /// 背景:轮询线程(CheckAlarms)、UI 线程(ViewD 搁置/复位)、SignalR 线程(远程复位)会同时读写
- /// _activeAlarms / _alarmStartTimes / _escalatedAlarms 这几个普通集合。改动前它们完全没有同步,
- /// 一边枚举一边被写入就直接抛 "Collection was modified",异常被 Avalonia 的 UI 兜底吞掉后
- /// 表现为"报警列表点不开 / 复位了但还在报警",界面上看不出任何线索。
- /// 这里压两件事:并发不炸不卡,且同一个条件不会被重复触发。
- /// </summary>
- [Collection("Serilog minimum level")]
- public class AlarmServiceConcurrencyTests : IDisposable
- {
- private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
- private readonly AlarmService _service;
- public AlarmServiceConcurrencyTests()
- {
- // 状态变更会记 Warning(报警触发/泛溢),压掉以免污染测试输出
- LogService.ApplyMinimumLevel("Error");
- _service = new AlarmService(PlcPollingService.Instance);
- _service.AlarmDelaySeconds = 0; // 立即触发,让压测真正走到触发分支而不是只登记起始时间
- }
- public void Dispose()
- {
- _service.Dispose();
- LogService.ApplyMinimumLevel(_originalLevel.ToString());
- }
- /// <summary>
- /// 评估、复位、搁置、读快照四路并发:不能抛异常,也不能卡死
- /// (卡死意味着锁里做了落库/事件回调之类的外部调用,与等锁的 UI 线程互等)。
- /// </summary>
- [Fact]
- public async Task ConcurrentEvaluateResetShelveRead_DoesNotCorruptState()
- {
- var workers = new List<Task>();
- // ① 两路"轮询线程":反复评估条件成立,偶尔让一半条件恢复(触发清除分支)
- for (var t = 0; t < 2; t++)
- {
- workers.Add(Task.Run(() =>
- {
- for (var round = 0; round < 200; round++)
- {
- for (var i = 0; i < 20; i++)
- _service.CheckCustomCondition($"Tank{i}_High", true, $"液位高 {i}", 2);
- // 每 20 轮清一次:既制造"边枚举边写"的窗口,又不会把报警表灌满
- if (round % 20 == 19)
- for (var i = 0; i < 20; i += 2)
- _service.CheckCustomCondition($"Tank{i}_High", false, string.Empty, 0);
- }
- }));
- }
- // ② "UI 线程":读快照属性(内部要遍历活跃集合)
- workers.Add(Task.Run(() =>
- {
- for (var round = 0; round < 2000; round++)
- {
- _ = _service.HasActiveAlarm;
- _ = _service.ActiveAlarmCount;
- _ = _service.ActiveAlarmMessage;
- }
- }));
- // ③ "UI 线程":搁置 / 取消搁置
- workers.Add(Task.Run(() =>
- {
- for (var round = 0; round < 300; round++)
- {
- var key = $"Shelve{round % 7}_High";
- _service.Shelve(key, 30, "Tester");
- _service.Unshelve(key, "Tester");
- }
- }));
- // ④ "远程客户端":全部复位(清空同一批集合并回写数据库)
- workers.Add(Task.Run(async () =>
- {
- for (var round = 0; round < 30; round++)
- await _service.ResetAllAlarmsAsync("Tester");
- }));
- var all = Task.WhenAll(workers);
- var finished = await Task.WhenAny(all, Task.Delay(TimeSpan.FromSeconds(60)));
- Assert.True(finished == all, "并发压测未在 60 秒内结束,疑似锁内做外部调用导致互等");
- await all; // 任一 worker 抛出 InvalidOperationException 等异常时让测试失败
- // 状态没被压坏:清空后重新评估仍能触发,并如实反映到快照属性
- await _service.ResetAllAlarmsAsync("Tester");
- _service.CheckCustomCondition("AfterStress_High", true, "压测后仍可触发", 2);
- _service.CheckCustomCondition("AfterStress_High", true, "压测后仍可触发", 2);
- Assert.True(_service.HasActiveAlarm);
- Assert.Equal(1, _service.ActiveAlarmCount);
- Assert.Contains("AfterStress_High", _service.ActiveAlarmMessage);
- }
- /// <summary>
- /// 同一条件被多个线程同时判定成立时只能触发一次 ——
- /// "是否已在报警"的检查和"占住活跃集合"必须在同一把锁里,否则两边都能通过检查,
- /// 报警列表和声音通知会收到重复的同一条报警。
- /// </summary>
- [Fact]
- public async Task SameCondition_RaisedOnlyOnceUnderConcurrentThreads()
- {
- const string conditionId = "ConcurrentTank_High";
- var raised = new List<AlarmRecord>();
- var raisedLock = new object();
- void OnRaised(AlarmRecord alarm)
- {
- lock (raisedLock) raised.Add(alarm);
- }
- _service.AlarmRaised += OnRaised;
- try
- {
- var workers = new List<Task>();
- for (var t = 0; t < 4; t++)
- {
- workers.Add(Task.Run(() =>
- {
- // 第一轮只登记起始时间,之后每一轮都是"条件仍成立"的重复评估
- for (var round = 0; round < 300; round++)
- _service.CheckCustomCondition(conditionId, true, "液位高", 2);
- }));
- }
- await Task.WhenAll(workers);
- }
- finally
- {
- _service.AlarmRaised -= OnRaised;
- }
- int duplicates;
- lock (raisedLock)
- duplicates = raised.Count(a => a.ConditionId == conditionId);
- Assert.Equal(1, duplicates);
- }
- /// <summary>
- /// 搁置期间不能因为并发而漏网触发(判定搁置与占住活跃集合在同一临界区内才成立)。
- /// </summary>
- [Fact]
- public async Task ShelvedCondition_IsNotRaisedEvenWhenEvaluatedConcurrently()
- {
- const string conditionId = "ShelvedTank_High";
- var raised = 0;
- void OnRaised(AlarmRecord alarm)
- {
- if (alarm.ConditionId == conditionId) Interlocked.Increment(ref raised);
- }
- _service.AlarmRaised += OnRaised;
- try
- {
- _service.Shelve(conditionId, 30, "Tester");
- var workers = new List<Task>();
- for (var t = 0; t < 4; t++)
- {
- workers.Add(Task.Run(() =>
- {
- for (var round = 0; round < 300; round++)
- _service.CheckCustomCondition(conditionId, true, "液位高", 2);
- }));
- }
- await Task.WhenAll(workers);
- }
- finally
- {
- _service.AlarmRaised -= OnRaised;
- _service.Unshelve(conditionId, "Tester");
- }
- Assert.Equal(0, raised);
- }
- /// <summary>
- /// 复位后条件仍成立时必须能重新触发,不能留下"已复位但界面仍报警"的脏状态。
- /// </summary>
- [Fact]
- public async Task ResetWhileConditionStillActive_LetsNextPollReRaise()
- {
- const string conditionId = "ResetThenRaise_High";
- _service.CheckCustomCondition(conditionId, true, "液位高", 2);
- _service.CheckCustomCondition(conditionId, true, "液位高", 2);
- Assert.True(_service.HasActiveAlarm);
- await _service.ResetAllAlarmsAsync("Tester");
- Assert.False(_service.HasActiveAlarm);
- _service.CheckCustomCondition(conditionId, true, "液位高", 2);
- _service.CheckCustomCondition(conditionId, true, "液位高", 2);
- Assert.Contains(conditionId, _service.ActiveAlarmMessage);
- }
- }
|