using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Server.Phase7; namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7; /// /// Task #245 — covers the IReadable adapter that surfaces each scripted alarm's /// live ActiveState so OPC UA variable reads on Source=ScriptedAlarm nodes /// return the predicate truth instead of BadNotFound. /// [Trait("Category", "Unit")] public sealed class ScriptedAlarmReadableTests { private static (ScriptedAlarmEngine engine, CachedTagUpstreamSource upstream) BuildEngineWith( params (string alarmId, string predicateSource)[] alarms) { var upstream = new CachedTagUpstreamSource(); var logger = new LoggerConfiguration().CreateLogger(); var factory = new ScriptLoggerFactory(logger); var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger); var defs = alarms.Select(a => new ScriptedAlarmDefinition( AlarmId: a.alarmId, EquipmentPath: "/eq", AlarmName: a.alarmId, Kind: AlarmKind.LimitAlarm, Severity: AlarmSeverity.Medium, MessageTemplate: "x", PredicateScriptSource: a.predicateSource)).ToList(); engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult(); return (engine, upstream); } [Fact] public async Task Reads_return_false_for_newly_loaded_alarm_with_inactive_predicate() { var (engine, _) = BuildEngineWith(("a1", "return false;")); using var _e = engine; var readable = new ScriptedAlarmReadable(engine); var result = await readable.ReadAsync(["a1"], CancellationToken.None); result.Count.ShouldBe(1); result[0].Value.ShouldBe(false); result[0].StatusCode.ShouldBe(0u, "Good quality when the engine has state"); } [Fact] public async Task Reads_return_true_when_predicate_evaluates_to_active() { var (engine, upstream) = BuildEngineWith( ("tempAlarm", "return ctx.GetTag(\"/Site/Line/Cell/Temp\").Value is double d && d > 100;")); using var _e = engine; // Seed the upstream value + nudge the engine so the alarm transitions to Active. upstream.Push("/Site/Line/Cell/Temp", new DataValueSnapshot(150.0, 0u, DateTime.UtcNow, DateTime.UtcNow)); // Allow the engine's change-driven cascade to run. await Task.Delay(50); var readable = new ScriptedAlarmReadable(engine); var result = await readable.ReadAsync(["tempAlarm"], CancellationToken.None); result[0].Value.ShouldBe(true); } [Fact] public async Task Reads_return_BadNodeIdUnknown_for_missing_alarm() { var (engine, _) = BuildEngineWith(("a1", "return false;")); using var _e = engine; var readable = new ScriptedAlarmReadable(engine); var result = await readable.ReadAsync(["a-not-loaded"], CancellationToken.None); result[0].Value.ShouldBeNull(); result[0].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown surfaces a misconfiguration, not a silent false"); } [Fact] public async Task Reads_batch_round_trip_preserves_order() { var (engine, _) = BuildEngineWith( ("a1", "return false;"), ("a2", "return false;")); using var _e = engine; var readable = new ScriptedAlarmReadable(engine); var result = await readable.ReadAsync(["a2", "missing", "a1"], CancellationToken.None); result.Count.ShouldBe(3); result[0].Value.ShouldBe(false); // a2 result[1].StatusCode.ShouldBe(0x80340000u); // missing result[2].Value.ShouldBe(false); // a1 } [Fact] public void Null_engine_rejected() { Should.Throw(() => new ScriptedAlarmReadable(null!)); } [Fact] public async Task Null_fullReferences_rejected() { var (engine, _) = BuildEngineWith(("a1", "return false;")); using var _e = engine; var readable = new ScriptedAlarmReadable(engine); await Should.ThrowAsync( () => readable.ReadAsync(null!, CancellationToken.None)); } }