chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,83 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Covers the Phase 7 driver-to-engine bridge cache (task #243). Verifies the
/// cache serves last-known values synchronously, fans out Push updates to
/// subscribers, and cleans up on Dispose.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CachedTagUpstreamSourceTests
{
private static DataValueSnapshot Snap(object? v) =>
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
[Fact]
public void ReadTag_unknown_path_returns_BadNodeIdUnknown_snapshot()
{
var c = new CachedTagUpstreamSource();
var snap = c.ReadTag("/nowhere");
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
}
[Fact]
public void Push_then_Read_returns_cached_value()
{
var c = new CachedTagUpstreamSource();
c.Push("/Line1/Temp", Snap(42));
c.ReadTag("/Line1/Temp").Value.ShouldBe(42);
}
[Fact]
public void Push_fans_out_to_subscribers_in_registration_order()
{
var c = new CachedTagUpstreamSource();
var events = new List<string>();
c.SubscribeTag("/X", (p, s) => events.Add($"A:{p}:{s.Value}"));
c.SubscribeTag("/X", (p, s) => events.Add($"B:{p}:{s.Value}"));
c.Push("/X", Snap(7));
events.ShouldBe(["A:/X:7", "B:/X:7"]);
}
[Fact]
public void Push_to_different_path_does_not_fire_foreign_observer()
{
var c = new CachedTagUpstreamSource();
var fired = 0;
c.SubscribeTag("/X", (_, _) => fired++);
c.Push("/Y", Snap(1));
fired.ShouldBe(0);
}
[Fact]
public void Dispose_of_subscription_stops_fan_out()
{
var c = new CachedTagUpstreamSource();
var fired = 0;
var sub = c.SubscribeTag("/X", (_, _) => fired++);
c.Push("/X", Snap(1));
sub.Dispose();
c.Push("/X", Snap(2));
fired.ShouldBe(1);
}
[Fact]
public void Satisfies_both_VirtualTag_and_ScriptedAlarm_upstream_interfaces()
{
var c = new CachedTagUpstreamSource();
// Single instance is assignable to both — the composer passes it through for
// both engine constructors per the task #243 wiring.
((Core.VirtualTags.ITagUpstreamSource)c).ShouldNotBeNull();
((Core.ScriptedAlarms.ITagUpstreamSource)c).ShouldNotBeNull();
}
}

View File

@@ -0,0 +1,226 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Task #244 — covers the bridge that pumps live driver <c>OnDataChange</c>
/// notifications into the Phase 7 <see cref="CachedTagUpstreamSource"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverSubscriptionBridgeTests
{
private sealed class FakeDriver : ISubscribable
{
public List<IReadOnlyList<string>> SubscribeCalls { get; } = [];
public List<ISubscriptionHandle> Unsubscribed { get; } = [];
public ISubscriptionHandle? LastHandle { get; private set; }
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
SubscribeCalls.Add(fullReferences);
LastHandle = new Handle($"sub-{SubscribeCalls.Count}");
return Task.FromResult(LastHandle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
Unsubscribed.Add(handle);
return Task.CompletedTask;
}
public void Fire(string fullRef, object value)
{
OnDataChange?.Invoke(this, new DataChangeEventArgs(
LastHandle!, fullRef,
new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow)));
}
private sealed record Handle(string DiagnosticId) : ISubscriptionHandle;
}
[Fact]
public async Task StartAsync_calls_SubscribeAsync_with_distinct_fullRefs()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string>
{
["/Site/L1/A/Temp"] = "DR.Temp",
["/Site/L1/A/Pressure"] = "DR.Pressure",
},
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.SubscribeCalls.Count.ShouldBe(1);
driver.SubscribeCalls[0].ShouldContain("DR.Temp");
driver.SubscribeCalls[0].ShouldContain("DR.Pressure");
}
[Fact]
public async Task OnDataChange_pushes_to_cache_keyed_by_UNS_path()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/Site/L1/A/Temp"] = "DR.Temp" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.Fire("DR.Temp", 42.5);
sink.ReadTag("/Site/L1/A/Temp").Value.ShouldBe(42.5);
}
[Fact]
public async Task OnDataChange_with_unmapped_fullRef_is_ignored()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.Fire("DR.B", 99); // not in map
sink.ReadTag("/p").StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured,
"unmapped fullRef shouldn't pollute the cache");
}
[Fact]
public async Task Empty_PathToFullRef_skips_SubscribeAsync_call()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver, new Dictionary<string, string>(), TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.SubscribeCalls.ShouldBeEmpty();
}
[Fact]
public async Task DisposeAsync_unsubscribes_each_active_subscription()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
await bridge.DisposeAsync();
driver.Unsubscribed.Count.ShouldBe(1);
driver.Unsubscribed[0].ShouldBeSameAs(driver.LastHandle);
}
[Fact]
public async Task DisposeAsync_unhooks_OnDataChange_so_post_dispose_events_dont_push()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
await bridge.DisposeAsync();
driver.Fire("DR.A", 999); // post-dispose event
sink.ReadTag("/p").StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
}
[Fact]
public async Task StartAsync_called_twice_throws()
{
var sink = new CachedTagUpstreamSource();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(Array.Empty<DriverFeed>(), CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(
() => bridge.StartAsync(Array.Empty<DriverFeed>(), CancellationToken.None));
}
[Fact]
public async Task DisposeAsync_is_idempotent()
{
var sink = new CachedTagUpstreamSource();
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.DisposeAsync();
await bridge.DisposeAsync(); // must not throw
}
[Fact]
public async Task Subscribe_failure_unhooks_handler_and_propagates()
{
var sink = new CachedTagUpstreamSource();
var failingDriver = new ThrowingDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
var feeds = new[]
{
new DriverFeed(failingDriver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
};
await Should.ThrowAsync<InvalidOperationException>(
() => bridge.StartAsync(feeds, CancellationToken.None));
// Handler should be unhooked — firing now would NPE if it wasn't (event has 0 subs).
failingDriver.HasAnyHandlers.ShouldBeFalse(
"handler must be removed when SubscribeAsync throws so it doesn't leak");
}
[Fact]
public void Null_sink_or_logger_rejected()
{
Should.Throw<ArgumentNullException>(() => new DriverSubscriptionBridge(null!, NullLogger<DriverSubscriptionBridge>.Instance));
Should.Throw<ArgumentNullException>(() => new DriverSubscriptionBridge(new CachedTagUpstreamSource(), null!));
}
private sealed class ThrowingDriver : ISubscribable
{
private EventHandler<DataChangeEventArgs>? _handler;
public bool HasAnyHandlers => _handler is not null;
public event EventHandler<DataChangeEventArgs>? OnDataChange
{
add => _handler = (EventHandler<DataChangeEventArgs>?)Delegate.Combine(_handler, value);
remove => _handler = (EventHandler<DataChangeEventArgs>?)Delegate.Remove(_handler, value);
}
public Task<ISubscriptionHandle> SubscribeAsync(IReadOnlyList<string> _, TimeSpan __, CancellationToken ___) =>
throw new InvalidOperationException("driver offline");
public Task UnsubscribeAsync(ISubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,93 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Task #246 — covers the deterministic mapping inside <see cref="Phase7Composer"/>
/// that turns <see cref="EquipmentNamespaceContent"/> into the path → fullRef map
/// <see cref="DriverFeed.PathToFullRef"/> consumes. Pure function; no DI / DB needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Phase7ComposerMappingTests
{
private static UnsArea Area(string id, string name) =>
new() { UnsAreaId = id, ClusterId = "c", Name = name, GenerationId = 1 };
private static UnsLine Line(string id, string areaId, string name) =>
new() { UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1 };
private static Equipment Eq(string id, string lineId, string name) => new()
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1, EquipmentId = id,
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
UnsLineId = lineId, Name = name, MachineCode = "m",
};
private static Tag T(string id, string name, string fullRef, string equipmentId) => new()
{
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = id,
DriverInstanceId = "drv", EquipmentId = equipmentId,
Name = name, DataType = "Float32",
AccessLevel = TagAccessLevel.Read, TagConfig = fullRef,
};
[Fact]
public void Maps_tag_to_UNS_path_walker_emits()
{
var content = new EquipmentNamespaceContent(
Areas: [Area("a1", "warsaw")],
Lines: [Line("l1", "a1", "oven-line")],
Equipment: [Eq("e1", "l1", "oven-3")],
Tags: [T("t1", "Temp", "DR.Temp", "e1")]);
var map = Phase7Composer.MapPathsToFullRefs(content);
map.ShouldContainKeyAndValue("/warsaw/oven-line/oven-3/Temp", "DR.Temp");
}
[Fact]
public void Skips_tag_with_null_EquipmentId()
{
var content = new EquipmentNamespaceContent(
[Area("a1", "warsaw")], [Line("l1", "a1", "ol")], [Eq("e1", "l1", "ov")],
[T("t1", "Bare", "DR.Bare", null!)]); // SystemPlatform-style orphan
Phase7Composer.MapPathsToFullRefs(content).ShouldBeEmpty();
}
[Fact]
public void Skips_tag_pointing_at_unknown_Equipment()
{
var content = new EquipmentNamespaceContent(
[Area("a1", "warsaw")], [Line("l1", "a1", "ol")], [Eq("e1", "l1", "ov")],
[T("t1", "Lost", "DR.Lost", "e-missing")]);
Phase7Composer.MapPathsToFullRefs(content).ShouldBeEmpty();
}
[Fact]
public void Maps_multiple_tags_under_same_equipment_distinctly()
{
var content = new EquipmentNamespaceContent(
[Area("a1", "site")], [Line("l1", "a1", "line1")], [Eq("e1", "l1", "cell")],
[T("t1", "Temp", "DR.T", "e1"), T("t2", "Pressure", "DR.P", "e1")]);
var map = Phase7Composer.MapPathsToFullRefs(content);
map.Count.ShouldBe(2);
map["/site/line1/cell/Temp"].ShouldBe("DR.T");
map["/site/line1/cell/Pressure"].ShouldBe("DR.P");
}
[Fact]
public void Empty_content_yields_empty_map()
{
Phase7Composer.MapPathsToFullRefs(new EquipmentNamespaceContent([], [], [], []))
.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,122 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// PR B.4 — pins the precedence order Phase7Composer uses to pick an
/// <see cref="IAlarmHistorianWriter"/>:
/// driver-provided > DI-registered > none. Driver wins so a future
/// GalaxyDriver-as-IAlarmHistorianWriter takes the write path directly,
/// preserving the v1 invariant where a driver that natively owns the
/// historian client doesn't bounce through the sidecar IPC.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Phase7ComposerWriterSelectionTests
{
[Fact]
public async Task No_driver_no_injected_writer_returns_null()
{
await using var host = new DriverHost();
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injectedWriter: null, out var source);
writer.ShouldBeNull();
source.ShouldBeNull();
}
[Fact]
public async Task Injected_writer_only_is_selected()
{
await using var host = new DriverHost();
var injected = new RecordingWriter("from-di");
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injected, out var source);
writer.ShouldBeSameAs(injected);
source.ShouldStartWith("di:");
}
[Fact]
public async Task Driver_writer_wins_over_injected()
{
await using var host = new DriverHost();
var driver = new FakeDriverWithWriter("drv-1", "drv-out");
await host.RegisterAsync(driver, driverConfigJson: "{}", CancellationToken.None);
var injected = new RecordingWriter("from-di");
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injected, out var source);
writer.ShouldBeSameAs(driver);
source.ShouldBe("driver:drv-1");
}
[Fact]
public async Task First_driver_implementing_writer_wins()
{
await using var host = new DriverHost();
var driverNoWriter = new FakeDriverWithoutWriter("drv-1");
var driverWithWriter = new FakeDriverWithWriter("drv-2", "drv-out");
await host.RegisterAsync(driverNoWriter, "{}", CancellationToken.None);
await host.RegisterAsync(driverWithWriter, "{}", CancellationToken.None);
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injectedWriter: null, out var source);
writer.ShouldBeSameAs(driverWithWriter);
source.ShouldBe("driver:drv-2");
}
private sealed class RecordingWriter : IAlarmHistorianWriter
{
public string Tag { get; }
public RecordingWriter(string tag) { Tag = tag; }
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
var outcomes = new HistorianWriteOutcome[batch.Count];
for (var i = 0; i < outcomes.Length; i++) outcomes[i] = HistorianWriteOutcome.Ack;
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
}
}
private sealed class FakeDriverWithoutWriter : IDriver
{
public FakeDriverWithoutWriter(string id) { DriverInstanceId = id; }
public string DriverInstanceId { get; }
public string DriverType => "FakeNoWriter";
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class FakeDriverWithWriter : IDriver, IAlarmHistorianWriter
{
private readonly RecordingWriter _writer;
public FakeDriverWithWriter(string id, string tag)
{
DriverInstanceId = id;
_writer = new RecordingWriter(tag);
}
public string DriverInstanceId { get; }
public string DriverType => "FakeWithWriter";
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
=> _writer.WriteBatchAsync(batch, cancellationToken);
}
}

View File

@@ -0,0 +1,162 @@
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}");
}
}
}

View File

@@ -0,0 +1,120 @@
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;
/// <summary>
/// Task #245 — covers the IReadable adapter that surfaces each scripted alarm's
/// live <c>ActiveState</c> so OPC UA variable reads on Source=ScriptedAlarm nodes
/// return the predicate truth instead of BadNotFound.
/// </summary>
[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<ArgumentNullException>(() => 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<ArgumentNullException>(
() => readable.ReadAsync(null!, CancellationToken.None));
}
}