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