feat(scripted-alarms): DeploymentArtifact byte-parity for the alarm plan (T6)
This commit is contained in:
+242
@@ -0,0 +1,242 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user