Ships the composition kernel that maps Config DB rows (Script / VirtualTag /
ScriptedAlarm) to the runtime definitions VirtualTagEngine + ScriptedAlarmEngine
consume, builds the engine instances, and wires OnEvent → historian-sink routing.
## src/ZB.MOM.WW.OtOpcUa.Server/Phase7/
- CachedTagUpstreamSource — implements both Core.VirtualTags.ITagUpstreamSource and
Core.ScriptedAlarms.ITagUpstreamSource (identical shape, distinct namespaces) on one
concrete type so the composer can hand one instance to both engines. Thread-safe
ConcurrentDictionary value cache with synchronous ReadTag + fire-on-write
Push(path, snapshot) that fans out to every observer registered via SubscribeTag.
Unknown-path reads return a BadNodeIdUnknown-quality snapshot (status 0x80340000)
so scripts branch on quality naturally.
- Phase7EngineComposer.Compose(scripts, virtualTags, scriptedAlarms, upstream,
alarmStateStore, historianSink, rootScriptLogger, loggerFactory) — single static
entry point that:
* Indexes scripts by ScriptId, resolves VirtualTag.ScriptId + ScriptedAlarm.PredicateScriptId
to full SourceCode
* Projects DB rows to VirtualTagDefinition + ScriptedAlarmDefinition (mapping
DataType string → DriverDataType enum, AlarmType string → AlarmKind enum,
Severity 1..1000 → AlarmSeverity bucket matching the OPC UA Part 9 bands
that AbCipAlarmProjection + OpcUaClient MapSeverity already use)
* Constructs VirtualTagEngine + loads definitions (throws InvalidOperationException
with the list of scripts that failed to compile — aggregated like Streams B+C)
* Constructs ScriptedAlarmEngine + loads definitions + wires OnEvent →
IAlarmHistorianSink.EnqueueAsync using ScriptedAlarmEvent.Emission as the event
kind + Condition.LastAckUser/LastAckComment for audit fields
* Returns Phase7ComposedSources with Disposables list the caller owns
Empty Phase 7 config returns Phase7ComposedSources.Empty so deployments without
scripts / alarms behave exactly as pre-Phase-7. Non-null sources flow into
OpcUaApplicationHost's virtualReadable / scriptedAlarmReadable plumbing landed by
task #239 — DriverNodeManager then dispatches reads by NodeSourceKind per PR #186.
## Tests — 12/12
CachedTagUpstreamSourceTests (6):
- Unknown-path read returns BadNodeIdUnknown-quality snapshot
- Push-then-Read returns cached value
- Push fans out to subscribers in registration order
- Push to one path doesn't fire another path's observer
- Dispose of subscription handle stops fan-out
- Satisfies both Core.VirtualTags + Core.ScriptedAlarms ITagUpstreamSource interfaces
Phase7EngineComposerTests (6):
- Empty rows → Phase7ComposedSources.Empty (both sources null)
- VirtualTag rows → VirtualReadable non-null + Disposables populated
- Missing script reference throws InvalidOperationException with the missing ScriptId
in the message
- Disabled VirtualTag row skipped by projection
- TimerIntervalMs → TimeSpan.FromMilliseconds round-trip
- Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries
(matches AbCipAlarmProjection + OpcUaClient.MapSeverity banding)
## Scope — what this PR does NOT do
The composition kernel is the tricky part; the remaining wiring is three narrower
follow-ups that each build on this PR:
- task #244 — driver-bridge feed that populates CachedTagUpstreamSource from live
driver subscriptions. Without this, ctx.GetTag returns BadNodeIdUnknown even when
the driver has a fresh value.
- task #245 — ScriptedAlarmReadable adapter exposing each alarm's current Active
state as IReadable. Phase7EngineComposer.Compose currently returns
ScriptedAlarmReadable=null so reads on Source=ScriptedAlarm variables return
BadNotFound per the ADR-002 "misconfiguration not silent fallback" signal.
- task #246 — Program.cs call to Phase7EngineComposer.Compose with config rows
loaded from the sealed-cache DB read, plus SqliteStoreAndForwardSink lifecycle
wiring at %ProgramData%/OtOpcUa/alarm-historian-queue.db with the Galaxy.Host
IPC writer from Stream D.
Task #240 (live OPC UA E2E smoke) depends on all three follow-ups landing.
144 lines
5.3 KiB
C#
144 lines
5.3 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.Disposables.Count.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[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}");
|
|
}
|
|
}
|
|
}
|
|
|