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; /// /// Phase 7 follow-up (task #243) — verifies the composer that maps Config DB /// rows to runtime engine definitions + wires up VirtualTagEngine + /// ScriptedAlarmEngine + historian routing. /// [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(() => 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 { ["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 { ["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 { ["scr-1"] = scripts[0] }).Single(); def.Severity.ShouldBe(expected, $"severity {input} should map to {expected}"); } } }