using Xunit; using Serilog.Events; using YZWater.Core.Models; using YZWater.Core.Services; namespace YZWater.Core.Tests.Services; /// /// 报警内存状态的并发回归测试。 /// 背景:轮询线程(CheckAlarms)、UI 线程(ViewD 搁置/复位)、SignalR 线程(远程复位)会同时读写 /// _activeAlarms / _alarmStartTimes / _escalatedAlarms 这几个普通集合。改动前它们完全没有同步, /// 一边枚举一边被写入就直接抛 "Collection was modified",异常被 Avalonia 的 UI 兜底吞掉后 /// 表现为"报警列表点不开 / 复位了但还在报警",界面上看不出任何线索。 /// 这里压两件事:并发不炸不卡,且同一个条件不会被重复触发。 /// [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()); } /// /// 评估、复位、搁置、读快照四路并发:不能抛异常,也不能卡死 /// (卡死意味着锁里做了落库/事件回调之类的外部调用,与等锁的 UI 线程互等)。 /// [Fact] public async Task ConcurrentEvaluateResetShelveRead_DoesNotCorruptState() { var workers = new List(); // ① 两路"轮询线程":反复评估条件成立,偶尔让一半条件恢复(触发清除分支) 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); } /// /// 同一条件被多个线程同时判定成立时只能触发一次 —— /// "是否已在报警"的检查和"占住活跃集合"必须在同一把锁里,否则两边都能通过检查, /// 报警列表和声音通知会收到重复的同一条报警。 /// [Fact] public async Task SameCondition_RaisedOnlyOnceUnderConcurrentThreads() { const string conditionId = "ConcurrentTank_High"; var raised = new List(); var raisedLock = new object(); void OnRaised(AlarmRecord alarm) { lock (raisedLock) raised.Add(alarm); } _service.AlarmRaised += OnRaised; try { var workers = new List(); 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); } /// /// 搁置期间不能因为并发而漏网触发(判定搁置与占住活跃集合在同一临界区内才成立)。 /// [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(); 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); } /// /// 复位后条件仍成立时必须能重新触发,不能留下"已复位但界面仍报警"的脏状态。 /// [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); } }