242 lines
9.3 KiB
C#
242 lines
9.3 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
public sealed class Phase7ComposerPurityTests
|
|
{
|
|
/// <summary>Verifies empty inputs produce empty result.</summary>
|
|
[Fact]
|
|
public void Empty_inputs_produce_empty_result()
|
|
{
|
|
var result = Phase7Composer.Compose(
|
|
equipment: Array.Empty<Equipment>(),
|
|
driverInstances: Array.Empty<DriverInstance>(),
|
|
scriptedAlarms: Array.Empty<ScriptedAlarm>());
|
|
|
|
result.EquipmentNodes.ShouldBeEmpty();
|
|
result.DriverInstancePlans.ShouldBeEmpty();
|
|
result.ScriptedAlarmPlans.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies same inputs in different order produce structurally equal results.</summary>
|
|
[Fact]
|
|
public void Same_inputs_in_different_order_produce_structurally_equal_results()
|
|
{
|
|
var e1 = NewEquipment("eq-1");
|
|
var e2 = NewEquipment("eq-2");
|
|
var d1 = NewDriver("drv-1");
|
|
var d2 = NewDriver("drv-2");
|
|
var a1 = NewAlarm("a-1", "eq-1");
|
|
var a2 = NewAlarm("a-2", "eq-2");
|
|
|
|
var r1 = Phase7Composer.Compose(
|
|
equipment: new[] { e1, e2 },
|
|
driverInstances: new[] { d1, d2 },
|
|
scriptedAlarms: new[] { a1, a2 });
|
|
|
|
var r2 = Phase7Composer.Compose(
|
|
equipment: new[] { e2, e1 },
|
|
driverInstances: new[] { d2, d1 },
|
|
scriptedAlarms: new[] { a2, a1 });
|
|
|
|
r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes);
|
|
r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans);
|
|
r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans);
|
|
}
|
|
|
|
/// <summary>Verifies Compose is pure with identical repeated calls.</summary>
|
|
[Fact]
|
|
public void Compose_is_pure_repeated_call_returns_element_identical_output()
|
|
{
|
|
var equipment = new[] { NewEquipment("eq-a"), NewEquipment("eq-b") };
|
|
var drivers = new[] { NewDriver("drv-x") };
|
|
var alarms = new[] { NewAlarm("alarm-1", "eq-a") };
|
|
|
|
var r1 = Phase7Composer.Compose(equipment, drivers, alarms);
|
|
var r2 = Phase7Composer.Compose(equipment, drivers, alarms);
|
|
|
|
// Record equality won't help here — IReadOnlyList<T> uses reference equality. Compare
|
|
// element-wise to verify the pure-function contract.
|
|
r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes);
|
|
r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans);
|
|
r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans);
|
|
}
|
|
|
|
/// <summary>Verifies output is sorted by natural key.</summary>
|
|
[Fact]
|
|
public void Output_is_sorted_by_natural_key()
|
|
{
|
|
var equipment = new[] { NewEquipment("z"), NewEquipment("a"), NewEquipment("m") };
|
|
var result = Phase7Composer.Compose(equipment, Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
|
|
|
result.EquipmentNodes.Select(e => e.EquipmentId)
|
|
.ShouldBe(new[] { "a", "m", "z" });
|
|
}
|
|
|
|
/// <summary>Verifies UNS equipment folders browse by the friendly UNS <c>Name</c> segment
|
|
/// (not the colloquial MachineCode, not the logical EquipmentId) while the NodeId stays the
|
|
/// logical EquipmentId — so browse-path resolution + ACLs are unaffected (decision #3).</summary>
|
|
[Fact]
|
|
public void Equipment_node_DisplayName_is_the_UNS_Name_not_MachineCode()
|
|
{
|
|
var equipment = new[] { NewEquipment("filling-eq") }; // Name="filling-eq", MachineCode="FILLING-EQ"
|
|
|
|
var node = Phase7Composer.Compose(equipment, Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>())
|
|
.EquipmentNodes.ShouldHaveSingleItem();
|
|
|
|
node.EquipmentId.ShouldBe("filling-eq"); // NodeId stays the logical Id
|
|
node.DisplayName.ShouldBe("filling-eq"); // browse name = UNS Name segment
|
|
node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode
|
|
}
|
|
|
|
[Fact]
|
|
public void Composition_carries_empty_equipment_virtualtags_by_default()
|
|
{
|
|
var r = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
r.EquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps()
|
|
{
|
|
var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64",
|
|
"return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
|
|
new[] { "TestMachine_001.TestDouble" });
|
|
p.VirtualTagId.ShouldBe("vt-1");
|
|
p.EquipmentId.ShouldBe("eq-1");
|
|
p.Name.ShouldBe("speed-rpm");
|
|
p.DependencyRefs.ShouldHaveSingleItem();
|
|
}
|
|
|
|
/// <summary>Compose joins a <see cref="VirtualTag"/> to its <see cref="Script"/> by ScriptId,
|
|
/// emitting one <see cref="EquipmentVirtualTagPlan"/> carrying the script source as the
|
|
/// Expression and the parsed <c>ctx.GetTag("…")</c> literals as DependencyRefs.</summary>
|
|
[Fact]
|
|
public void Compose_emits_equipment_virtualtag_plan_joined_to_script()
|
|
{
|
|
var vt = new VirtualTag
|
|
{
|
|
VirtualTagId = "vt-1",
|
|
EquipmentId = "eq-1",
|
|
Name = "speed-rpm",
|
|
DataType = "Float64",
|
|
ScriptId = "s-1",
|
|
};
|
|
var script = new Script
|
|
{
|
|
ScriptId = "s-1",
|
|
Name = "speed-script",
|
|
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
|
|
SourceHash = "hash-1",
|
|
};
|
|
|
|
var result = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
virtualTags: new[] { vt },
|
|
scripts: new[] { script });
|
|
|
|
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
|
|
plan.VirtualTagId.ShouldBe("vt-1");
|
|
plan.EquipmentId.ShouldBe("eq-1");
|
|
plan.FolderPath.ShouldBe("");
|
|
plan.Name.ShouldBe("speed-rpm");
|
|
plan.DataType.ShouldBe("Float64");
|
|
plan.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
|
|
plan.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
|
|
}
|
|
|
|
/// <summary>DependencyRefs are the distinct <c>ctx.GetTag("…")</c> literals in first-seen
|
|
/// order — a repeated ref collapses to one.</summary>
|
|
[Fact]
|
|
public void Compose_extracts_distinct_dependency_refs()
|
|
{
|
|
var vt = new VirtualTag
|
|
{
|
|
VirtualTagId = "vt-1",
|
|
EquipmentId = "eq-1",
|
|
Name = "sum",
|
|
DataType = "Float64",
|
|
ScriptId = "s-1",
|
|
};
|
|
var script = new Script
|
|
{
|
|
ScriptId = "s-1",
|
|
Name = "sum-script",
|
|
SourceCode = "return ctx.GetTag(\"A.X\").Value + ctx.GetTag(\"B.Y\").Value + ctx.GetTag(\"A.X\").Value;",
|
|
SourceHash = "hash-1",
|
|
};
|
|
|
|
var result = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
virtualTags: new[] { vt },
|
|
scripts: new[] { script });
|
|
|
|
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
|
|
plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
|
|
}
|
|
|
|
/// <summary>A <see cref="VirtualTag"/> whose <c>ScriptId</c> has no matching <see cref="Script"/>
|
|
/// in the supplied list falls back to an empty Expression and an empty DependencyRefs —
|
|
/// the plan is always emitted (never dropped) and never carries a null Expression.</summary>
|
|
[Fact]
|
|
public void Compose_virtualtag_with_missing_script_yields_empty_expression_and_deps()
|
|
{
|
|
var vt = new VirtualTag
|
|
{
|
|
VirtualTagId = "vt-missing",
|
|
EquipmentId = "eq-1",
|
|
Name = "mystery-tag",
|
|
DataType = "Float64",
|
|
ScriptId = "s-does-not-exist",
|
|
};
|
|
|
|
var result = Phase7Composer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
|
|
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
|
virtualTags: new[] { vt },
|
|
scripts: Array.Empty<Script>());
|
|
|
|
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
|
|
plan.VirtualTagId.ShouldBe("vt-missing");
|
|
plan.Expression.ShouldBe(string.Empty);
|
|
plan.DependencyRefs.ShouldBeEmpty();
|
|
}
|
|
|
|
private static Equipment NewEquipment(string id) => new()
|
|
{
|
|
EquipmentId = id,
|
|
DriverInstanceId = "drv-1",
|
|
UnsLineId = "line-1",
|
|
Name = id,
|
|
MachineCode = id.ToUpperInvariant(),
|
|
};
|
|
|
|
private static DriverInstance NewDriver(string id) => new()
|
|
{
|
|
DriverInstanceId = id,
|
|
ClusterId = "cluster-1",
|
|
NamespaceId = "ns-1",
|
|
Name = id,
|
|
DriverType = "Stub",
|
|
DriverConfig = "{\"k\":\"v\"}",
|
|
};
|
|
|
|
private static ScriptedAlarm NewAlarm(string id, string equipmentId) => new()
|
|
{
|
|
ScriptedAlarmId = id,
|
|
EquipmentId = equipmentId,
|
|
Name = id,
|
|
AlarmType = "AlarmCondition",
|
|
MessageTemplate = "{TagPath} alarm",
|
|
PredicateScriptId = "script-1",
|
|
};
|
|
}
|