301 lines
13 KiB
C#
301 lines
13 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Verifies the live-edit compose seam (<see cref="Phase7Composer.Compose"/>) builds the richer
|
|
/// per-equipment scripted-alarm projection (<see cref="EquipmentScriptedAlarmPlan"/>): each alarm
|
|
/// is joined to its predicate <see cref="Script"/> for the source, carries the parsed
|
|
/// <c>ctx.GetTag("…")</c> read refs UNION the <c>{TagPath}</c> tokens referenced in its
|
|
/// <c>MessageTemplate</c> as DependencyRefs, and preserves every flag the host decides on
|
|
/// (including disabled alarms). The projection is deterministic so the upcoming
|
|
/// artifact-byte-parity test (sibling task) is reliable.
|
|
/// </summary>
|
|
public sealed class Phase7ComposerScriptedAlarmTests
|
|
{
|
|
/// <summary>Two equipments, each with a scripted alarm whose predicate script reads one tag via
|
|
/// <c>ctx.GetTag("X.Y")</c>. Each plan must carry the resolved PredicateSource, DependencyRefs
|
|
/// containing the read path, and every scalar field copied through faithfully.</summary>
|
|
[Fact]
|
|
public void Compose_emits_richer_scripted_alarm_plan_per_equipment_joined_to_script()
|
|
{
|
|
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 overheating",
|
|
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 overheating",
|
|
PredicateScriptId = "s-2",
|
|
HistorizeToAveva = false,
|
|
Retain = false,
|
|
Enabled = true,
|
|
};
|
|
|
|
var result = 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 });
|
|
|
|
result.EquipmentScriptedAlarms.Count.ShouldBe(2);
|
|
|
|
var plan1 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
|
|
plan1.EquipmentId.ShouldBe("eq-1");
|
|
plan1.Name.ShouldBe("Overheat-1");
|
|
plan1.AlarmType.ShouldBe("LimitAlarm");
|
|
plan1.Severity.ShouldBe(700);
|
|
plan1.MessageTemplate.ShouldBe("Machine 1 overheating");
|
|
plan1.PredicateScriptId.ShouldBe("s-1");
|
|
plan1.PredicateSource.ShouldBe(script1.SourceCode);
|
|
plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp" });
|
|
plan1.HistorizeToAveva.ShouldBeTrue();
|
|
plan1.Retain.ShouldBeTrue();
|
|
plan1.Enabled.ShouldBeTrue();
|
|
|
|
var plan2 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
|
|
plan2.EquipmentId.ShouldBe("eq-2");
|
|
plan2.AlarmType.ShouldBe("OffNormalAlarm");
|
|
plan2.Severity.ShouldBe(250);
|
|
plan2.PredicateSource.ShouldBe(script2.SourceCode);
|
|
plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp" });
|
|
plan2.HistorizeToAveva.ShouldBeFalse();
|
|
plan2.Retain.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>The <c>{TagPath}</c> tokens in the MessageTemplate are merged into DependencyRefs
|
|
/// alongside the predicate's <c>ctx.GetTag</c> reads, so the host can subscribe to every signal
|
|
/// the alarm references for either the predicate or the message.</summary>
|
|
[Fact]
|
|
public void Compose_merges_message_template_token_paths_into_dependency_refs()
|
|
{
|
|
var script = new Script
|
|
{
|
|
ScriptId = "s-1",
|
|
Name = "hot",
|
|
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Line.Pressure\").Value) > 5;",
|
|
SourceHash = "h1",
|
|
};
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-1",
|
|
EquipmentId = "eq-1",
|
|
Name = "TempHigh",
|
|
AlarmType = "LimitAlarm",
|
|
Severity = 500,
|
|
MessageTemplate = "Temp {Line.Temp} high",
|
|
PredicateScriptId = "s-1",
|
|
};
|
|
|
|
var result = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { alarm },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
scripts: new[] { script });
|
|
|
|
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
|
// Predicate read + message token are both present (predicate reads first, then template tokens).
|
|
plan.DependencyRefs.ShouldBe(new[] { "Line.Pressure", "Line.Temp" });
|
|
}
|
|
|
|
/// <summary>A template token that duplicates a predicate read collapses to one ref, and a
|
|
/// repeated template token collapses too — DependencyRefs are distinct (predicate reads first,
|
|
/// then first-seen template tokens), and the reserved <c>{{equip}}</c> double-brace form is not
|
|
/// harvested as a token.</summary>
|
|
[Fact]
|
|
public void Compose_dependency_refs_are_distinct_and_exclude_the_reserved_equip_token()
|
|
{
|
|
var script = new Script
|
|
{
|
|
ScriptId = "s-1",
|
|
Name = "dup",
|
|
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
|
SourceHash = "h1",
|
|
};
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-1",
|
|
EquipmentId = "eq-1",
|
|
Name = "Dup",
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
// {A.X} duplicates the predicate read; {B.Y} appears twice; {{equip}}.Z must NOT be a token.
|
|
MessageTemplate = "v={A.X} w={B.Y} x={B.Y} base={{equip}}.Z",
|
|
PredicateScriptId = "s-1",
|
|
};
|
|
|
|
var result = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { alarm },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
scripts: new[] { script });
|
|
|
|
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
|
plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
|
|
plan.DependencyRefs.ShouldNotContain("equip");
|
|
}
|
|
|
|
/// <summary>A scripted alarm whose PredicateScriptId matches no Script row is SKIPPED (not in the
|
|
/// result) and compose does NOT throw — the bad row is dropped, the rest of the projection stands.</summary>
|
|
[Fact]
|
|
public void Compose_skips_scripted_alarm_with_missing_predicate_script_without_throwing()
|
|
{
|
|
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",
|
|
};
|
|
var orphanAlarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-orphan",
|
|
EquipmentId = "eq-2",
|
|
Name = "Orphan",
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
MessageTemplate = "orphan",
|
|
PredicateScriptId = "s-does-not-exist",
|
|
};
|
|
|
|
var result = 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 plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
|
plan.ScriptedAlarmId.ShouldBe("al-ok");
|
|
result.EquipmentScriptedAlarms.ShouldNotContain(p => p.ScriptedAlarmId == "al-orphan");
|
|
}
|
|
|
|
/// <summary>A disabled alarm (<c>Enabled=false</c>) is STILL present in the projection — the host
|
|
/// decides whether to host it, the composer never drops it. The flag is carried through.</summary>
|
|
[Fact]
|
|
public void Compose_keeps_disabled_scripted_alarm_with_enabled_flag_carried()
|
|
{
|
|
var script = new Script
|
|
{
|
|
ScriptId = "s-1",
|
|
Name = "off",
|
|
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
|
SourceHash = "h1",
|
|
};
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "al-off",
|
|
EquipmentId = "eq-1",
|
|
Name = "Disabled",
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
MessageTemplate = "off",
|
|
PredicateScriptId = "s-1",
|
|
Enabled = false,
|
|
};
|
|
|
|
var result = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { alarm },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
scripts: new[] { script });
|
|
|
|
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
|
plan.ScriptedAlarmId.ShouldBe("al-off");
|
|
plan.Enabled.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Determinism: the same inputs in different order produce identical ordered output
|
|
/// (stable sort by EquipmentId then ScriptedAlarmId) — this is the contract the upcoming
|
|
/// artifact byte-parity test relies on.</summary>
|
|
[Fact]
|
|
public void Compose_scripted_alarm_projection_is_deterministic_regardless_of_input_order()
|
|
{
|
|
var script1 = new Script { ScriptId = "s-1", Name = "a", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1" };
|
|
var script2 = new Script { ScriptId = "s-2", Name = "b", SourceCode = "return ctx.GetTag(\"B.Y\").Value;", SourceHash = "h2" };
|
|
var script3 = new Script { ScriptId = "s-3", Name = "c", SourceCode = "return ctx.GetTag(\"C.Z\").Value;", SourceHash = "h3" };
|
|
|
|
// Two alarms on the same equipment to exercise the secondary (ScriptedAlarmId) sort key.
|
|
var a1 = NewAlarm("al-1", "eq-2", "s-1");
|
|
var a2 = NewAlarm("al-2", "eq-1", "s-2");
|
|
var a3 = NewAlarm("al-3", "eq-1", "s-3");
|
|
var scripts = new[] { script1, script2, script3 };
|
|
|
|
var r1 = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { a1, a2, a3 },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
|
|
|
|
var r2 = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), new[] { a3, a1, a2 },
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
|
|
|
|
// Sorted by EquipmentId then ScriptedAlarmId: (eq-1,al-2), (eq-1,al-3), (eq-2,al-1).
|
|
r1.EquipmentScriptedAlarms.Select(p => p.ScriptedAlarmId)
|
|
.ShouldBe(new[] { "al-2", "al-3", "al-1" });
|
|
r1.EquipmentScriptedAlarms.ShouldBe(r2.EquipmentScriptedAlarms);
|
|
}
|
|
|
|
/// <summary>The new projection defaults to empty when no scripts/alarms are supplied (init-only
|
|
/// member, no positional-parameter break for existing callers).</summary>
|
|
[Fact]
|
|
public void Composition_carries_empty_scripted_alarms_by_default()
|
|
{
|
|
var r = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
r.EquipmentScriptedAlarms.ShouldBeEmpty();
|
|
}
|
|
|
|
private static ScriptedAlarm NewAlarm(string id, string equipmentId, string scriptId) => new()
|
|
{
|
|
ScriptedAlarmId = id,
|
|
EquipmentId = equipmentId,
|
|
Name = id,
|
|
AlarmType = "AlarmCondition",
|
|
Severity = 500,
|
|
MessageTemplate = $"{id} alarm",
|
|
PredicateScriptId = scriptId,
|
|
};
|
|
}
|