Task #245 — exposes each scripted alarm's current ActiveState as IReadable so OPC UA variable reads on Source=ScriptedAlarm nodes return the live predicate truth instead of BadNotFound. ## ScriptedAlarmReadable Wraps ScriptedAlarmEngine + implements IReadable: - Known alarm + Active → DataValueSnapshot(true, Good) - Known alarm + Inactive → DataValueSnapshot(false, Good) - Unknown alarm id → DataValueSnapshot(null, BadNodeIdUnknown) — surfaces misconfiguration rather than silently reading false - Batch reads preserve request order Phase7EngineComposer.Compose now returns this as ScriptedAlarmReadable when ScriptedAlarm rows are present. ScriptedAlarmSource (IAlarmSource for the event stream) stays in place — the IReadable is a separate adapter over the same engine. ## Tests — 6 new + 1 updated composer test = 19 total Phase 7 tests ScriptedAlarmReadableTests covers: inactive + active predicate → bool snapshot, unknown alarm id → BadNodeIdUnknown, batch order preservation, null-engine + null-fullReferences guards. The active-predicate test uses ctx.GetTag on a seeded upstream value to drive a real cascade through the engine. Updated Phase7EngineComposerTests to assert ScriptedAlarmReadable is non-null when alarms compose, null when only virtual tags. ## Follow-ups remaining - #244 — driver-bridge feed populating CachedTagUpstreamSource - #246 — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
163 lines
6.1 KiB
C#
163 lines
6.1 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Serilog;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
|
|
|
/// <summary>
|
|
/// Phase 7 follow-up (task #243) — verifies the composer that maps Config DB
|
|
/// rows to runtime engine definitions + wires up VirtualTagEngine +
|
|
/// ScriptedAlarmEngine + historian routing.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class Phase7EngineComposerTests
|
|
{
|
|
private static Script ScriptRow(string id, string source) => new()
|
|
{
|
|
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
|
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
|
};
|
|
|
|
private static VirtualTag VtRow(string id, string scriptId) => new()
|
|
{
|
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
|
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
|
DataType = "Float32", ScriptId = scriptId,
|
|
};
|
|
|
|
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
|
{
|
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
|
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
|
AlarmType = "LimitAlarm", Severity = 500,
|
|
MessageTemplate = "x", PredicateScriptId = scriptId,
|
|
};
|
|
|
|
[Fact]
|
|
public void Compose_empty_rows_returns_Empty_sentinel()
|
|
{
|
|
var result = Phase7EngineComposer.Compose(
|
|
scripts: [],
|
|
virtualTags: [],
|
|
scriptedAlarms: [],
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance);
|
|
|
|
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
|
|
result.VirtualReadable.ShouldBeNull();
|
|
result.ScriptedAlarmReadable.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_VirtualTag_rows_returns_non_null_VirtualReadable()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
|
var vtags = new[] { VtRow("vt-1", "scr-1") };
|
|
|
|
var result = Phase7EngineComposer.Compose(
|
|
scripts, vtags, [],
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance);
|
|
|
|
result.VirtualReadable.ShouldNotBeNull();
|
|
result.ScriptedAlarmReadable.ShouldBeNull("no alarms configured");
|
|
result.Disposables.Count.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_ScriptedAlarm_rows_returns_non_null_ScriptedAlarmReadable()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return false;") };
|
|
var alarms = new[] { AlarmRow("al-1", "scr-1") };
|
|
|
|
var result = Phase7EngineComposer.Compose(
|
|
scripts, [], alarms,
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance);
|
|
|
|
result.ScriptedAlarmReadable.ShouldNotBeNull("task #245 — alarm Active state readable");
|
|
result.VirtualReadable.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_missing_script_reference_throws_with_actionable_message()
|
|
{
|
|
var vtags = new[] { VtRow("vt-1", "scr-missing") };
|
|
|
|
Should.Throw<InvalidOperationException>(() =>
|
|
Phase7EngineComposer.Compose(
|
|
scripts: [],
|
|
vtags, [],
|
|
upstream: new CachedTagUpstreamSource(),
|
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
|
historianSink: NullAlarmHistorianSink.Instance,
|
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
|
loggerFactory: NullLoggerFactory.Instance))
|
|
.Message.ShouldContain("scr-missing");
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_disabled_VirtualTag_is_skipped()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
|
var disabled = VtRow("vt-1", "scr-1");
|
|
disabled.Enabled = false;
|
|
|
|
var defs = Phase7EngineComposer.ProjectVirtualTags(
|
|
new[] { disabled },
|
|
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).ToList();
|
|
|
|
defs.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectVirtualTags_maps_timer_interval_milliseconds_to_TimeSpan()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
|
var vt = VtRow("vt-1", "scr-1");
|
|
vt.TimerIntervalMs = 2500;
|
|
|
|
var def = Phase7EngineComposer.ProjectVirtualTags(
|
|
new[] { vt },
|
|
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
|
|
|
|
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectScriptedAlarms_maps_Severity_numeric_to_AlarmSeverity_bucket()
|
|
{
|
|
var scripts = new[] { ScriptRow("scr-1", "return true;") };
|
|
|
|
var buckets = new[] { (1, AlarmSeverity.Low), (250, AlarmSeverity.Low),
|
|
(251, AlarmSeverity.Medium), (500, AlarmSeverity.Medium),
|
|
(501, AlarmSeverity.High), (750, AlarmSeverity.High),
|
|
(751, AlarmSeverity.Critical), (1000, AlarmSeverity.Critical) };
|
|
foreach (var (input, expected) in buckets)
|
|
{
|
|
var row = AlarmRow("a1", "scr-1");
|
|
row.Severity = input;
|
|
var def = Phase7EngineComposer.ProjectScriptedAlarms(
|
|
new[] { row },
|
|
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
|
|
def.Severity.ShouldBe(expected, $"severity {input} should map to {expected}");
|
|
}
|
|
}
|
|
}
|
|
|