Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs
T

243 lines
9.9 KiB
C#

using System.Linq;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Byte-parity tests for the scripted-alarm deployment-artifact decode path
/// (<c>DeploymentArtifact.BuildEquipmentScriptedAlarmPlans</c>) against the live compose seam
/// (<c>Phase7Composer.Compose</c>). Both sides derive <c>EquipmentScriptedAlarmPlan</c> from the
/// same ScriptedAlarm + Script data via the shared <c>EquipmentScriptPaths.ExtractAlarmDependencyRefs</c>
/// helper, so the decoded plans must equal the composer's element-wise (the record has value
/// equality including DependencyRefs order). Mirrors the existing EquipmentVirtualTags parity
/// style in <c>DeploymentArtifactEquipTokenTests</c>.
/// </summary>
public sealed class DeploymentArtifactScriptedAlarmParityTests
{
private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot);
/// <summary>The live composer's plan for the same scripted alarms + scripts the artifact carries
/// must equal the artifact-decode plan, field-for-field including DependencyRefs order.</summary>
[Fact]
public void ParseComposition_scripted_alarms_are_byte_parity_with_composer()
{
// Two equipments, each with a scripted alarm: predicate reads a tag via ctx.GetTag("X.Y"),
// and the message template carries a distinct {A.B} token — so DependencyRefs exercise the
// predicate-reads-then-template-tokens merge order on both sides.
var script1 = new Script
{
ScriptId = "s-1",
Name = "hot-1",
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
SourceHash = "h1",
};
var script2 = new Script
{
ScriptId = "s-2",
Name = "hot-2",
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;",
SourceHash = "h2",
};
var alarm1 = new ScriptedAlarm
{
ScriptedAlarmId = "al-1",
EquipmentId = "eq-1",
Name = "Overheat-1",
AlarmType = "LimitAlarm",
Severity = 700,
MessageTemplate = "Machine 1 at {Mach1.Pressure}",
PredicateScriptId = "s-1",
HistorizeToAveva = true,
Retain = true,
Enabled = true,
};
var alarm2 = new ScriptedAlarm
{
ScriptedAlarmId = "al-2",
EquipmentId = "eq-2",
Name = "Overheat-2",
AlarmType = "OffNormalAlarm",
Severity = 250,
MessageTemplate = "Machine 2 at {Mach2.Pressure}",
PredicateScriptId = "s-2",
HistorizeToAveva = false,
Retain = false,
Enabled = false,
};
var composed = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: Array.Empty<VirtualTag>(),
scripts: new[] { script1, script2 });
// The artifact blob the write side (ConfigComposer) emits: the FULL ScriptedAlarm + Script
// entities serialised Pascal-case off the EF entity.
var blob = BlobOf(new
{
ScriptedAlarms = new[]
{
ToSnapshot(alarm1),
ToSnapshot(alarm2),
},
Scripts = new[]
{
new { ScriptId = script1.ScriptId, SourceCode = script1.SourceCode },
new { ScriptId = script2.ScriptId, SourceCode = script2.SourceCode },
},
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// Byte-parity: element-wise value equality (record equality compares DependencyRefs order).
decoded.EquipmentScriptedAlarms.Count.ShouldBe(2);
decoded.EquipmentScriptedAlarms.SequenceEqual(composed.EquipmentScriptedAlarms).ShouldBeTrue();
// And spot-check the merged DependencyRefs (predicate read first, then template token).
var plan1 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp", "Mach1.Pressure" });
plan1.Enabled.ShouldBeTrue();
var plan2 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp", "Mach2.Pressure" });
plan2.Enabled.ShouldBeFalse();
plan2.HistorizeToAveva.ShouldBeFalse();
plan2.Retain.ShouldBeFalse();
}
/// <summary>The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to
/// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered).</summary>
[Fact]
public void ParseComposition_scoped_keeps_only_my_clusters_scripted_alarms()
{
var blob = BlobOf(new
{
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
Nodes = new[]
{
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
},
Equipment = new[]
{
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" },
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" },
},
Scripts = new[]
{
new { ScriptId = "scr", SourceCode = "return ctx.GetTag(\"A.X\").Value;" },
},
ScriptedAlarms = new[]
{
NewAlarmSnapshot("al-main", "eq-main", "scr"),
NewAlarmSnapshot("al-sa", "eq-sa", "scr"),
},
});
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
main.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-main" });
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
siteA.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-sa" });
}
/// <summary>An alarm whose PredicateScriptId matches no Script row is SKIPPED on BOTH the composer
/// and the artifact-decode side, so parity holds for the surviving alarm.</summary>
[Fact]
public void ParseComposition_skips_alarm_with_missing_predicate_script_matching_composer()
{
var goodScript = new Script
{
ScriptId = "s-ok",
Name = "ok",
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
SourceHash = "h1",
};
var goodAlarm = new ScriptedAlarm
{
ScriptedAlarmId = "al-ok",
EquipmentId = "eq-1",
Name = "Ok",
AlarmType = "AlarmCondition",
Severity = 500,
MessageTemplate = "ok",
PredicateScriptId = "s-ok",
HistorizeToAveva = true,
Retain = true,
Enabled = true,
};
var orphanAlarm = new ScriptedAlarm
{
ScriptedAlarmId = "al-orphan",
EquipmentId = "eq-2",
Name = "Orphan",
AlarmType = "AlarmCondition",
Severity = 500,
MessageTemplate = "orphan",
PredicateScriptId = "s-does-not-exist",
HistorizeToAveva = true,
Retain = true,
Enabled = true,
};
var composed = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
scripts: new[] { goodScript });
var blob = BlobOf(new
{
ScriptedAlarms = new[] { ToSnapshot(goodAlarm), ToSnapshot(orphanAlarm) },
Scripts = new[] { new { ScriptId = goodScript.ScriptId, SourceCode = goodScript.SourceCode } },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// The orphan is dropped on both sides; the survivor's plan is byte-identical.
var composedPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem();
composedPlan.ScriptedAlarmId.ShouldBe("al-ok");
var decodedPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem();
decodedPlan.ScriptedAlarmId.ShouldBe("al-ok");
decodedPlan.ShouldBe(composedPlan);
}
// The full Pascal-case snapshot a ScriptedAlarm EF entity serialises to (matches ConfigComposer).
private static object ToSnapshot(ScriptedAlarm a) => new
{
a.ScriptedAlarmId,
a.EquipmentId,
a.Name,
a.AlarmType,
a.Severity,
a.MessageTemplate,
a.PredicateScriptId,
a.HistorizeToAveva,
a.Retain,
a.Enabled,
};
private static object NewAlarmSnapshot(string id, string equipmentId, string scriptId) => new
{
ScriptedAlarmId = id,
EquipmentId = equipmentId,
Name = id,
AlarmType = "AlarmCondition",
Severity = 500,
MessageTemplate = $"{id} alarm",
PredicateScriptId = scriptId,
HistorizeToAveva = true,
Retain = true,
Enabled = true,
};
}