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