9818d0cba8
IReadOnlyList<string> DependencyRefs compared by reference in the auto-generated record equality, causing every VirtualTag with dependencies to be flagged "Changed" on every parse (fresh list instances from composer and artifact-decoder). Add Equals/GetHashCode overrides with element-wise ordinal comparison so Phase7Plan.IsEmpty short-circuits a no-op redeploy. Add regression test Identical_virtualtag_snapshots_diff_to_empty_plan (separate list instances, same contents → IsEmpty true). Add TODO comment in Phase7Applier near needsRebuild predicate.
307 lines
14 KiB
C#
307 lines
14 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
public sealed class Phase7PlannerTests
|
|
{
|
|
/// <summary>Verifies that empty inputs produce an empty plan.</summary>
|
|
[Fact]
|
|
public void Empty_inputs_produce_empty_plan()
|
|
{
|
|
var prev = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
var next = prev;
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies that identical compositions produce an empty plan.</summary>
|
|
[Fact]
|
|
public void Identical_compositions_produce_empty_plan()
|
|
{
|
|
var eq = new EquipmentNode("eq-1", "Eq 1", "line-1");
|
|
var prev = new Phase7CompositionResult(new[] { eq }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(new[] { eq }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change)
|
|
/// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the
|
|
/// IsEmpty gate before materialising the new equipment variables.</summary>
|
|
[Fact]
|
|
public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentTags = new[]
|
|
{
|
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
|
},
|
|
};
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeFalse();
|
|
plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1");
|
|
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
|
plan.ChangedEquipmentTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
|
|
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
|
|
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
|
|
[Fact]
|
|
public void Equipment_virtual_tag_only_change_yields_non_empty_plan_with_added_tag()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = new[]
|
|
{
|
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
|
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
|
|
},
|
|
};
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeFalse();
|
|
plan.AddedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
|
|
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
|
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies a disappeared VirtualTag routes to RemovedEquipmentVirtualTags.</summary>
|
|
[Fact]
|
|
public void Disappeared_virtual_tag_goes_to_RemovedEquipmentVirtualTags()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = new[]
|
|
{
|
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
|
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
|
|
},
|
|
};
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeFalse();
|
|
plan.RemovedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
|
|
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
|
|
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies a VirtualTag with the same id but a different Expression routes to
|
|
/// ChangedEquipmentVirtualTags (the diff identity is VirtualTagId; any field difference,
|
|
/// including the evaluated Expression, moves it from stable to changed).</summary>
|
|
[Fact]
|
|
public void Same_id_with_different_expression_routes_to_ChangedEquipmentVirtualTags()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = new[]
|
|
{
|
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
|
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
|
|
},
|
|
};
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = new[]
|
|
{
|
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
|
Expression: "a - b", DependencyRefs: new[] { "a", "b" }),
|
|
},
|
|
};
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeFalse();
|
|
plan.ChangedEquipmentVirtualTags.Single().Previous.Expression.ShouldBe("a + b");
|
|
plan.ChangedEquipmentVirtualTags.Single().Current.Expression.ShouldBe("a - b");
|
|
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
|
|
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
|
|
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
|
|
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
|
|
/// dependencies would be wrongly flagged "Changed" on every parse, preventing IsEmpty short-circuits).</summary>
|
|
[Fact]
|
|
public void Identical_virtualtag_snapshots_diff_to_empty_plan()
|
|
{
|
|
// Two separate list instances with identical contents — proves structural (not reference) equality.
|
|
var refsA = new[] { "EQ1.Speed", "EQ1.Torque" };
|
|
var refsB = new[] { "EQ1.Speed", "EQ1.Torque" };
|
|
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = new[]
|
|
{
|
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
|
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsA),
|
|
},
|
|
};
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentVirtualTags = new[]
|
|
{
|
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
|
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsB),
|
|
},
|
|
};
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.IsEmpty.ShouldBeTrue();
|
|
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
|
|
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
|
|
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
|
|
[Fact]
|
|
public void New_equipment_goes_to_AddedEquipment()
|
|
{
|
|
var prev = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("eq-1", "A", "line-1") },
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-1");
|
|
plan.RemovedEquipment.ShouldBeEmpty();
|
|
plan.ChangedEquipment.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that disappeared equipment goes to the RemovedEquipment list.</summary>
|
|
[Fact]
|
|
public void Disappeared_equipment_goes_to_RemovedEquipment()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("eq-1", "A", "line-1") },
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-1");
|
|
plan.AddedEquipment.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that equipment with same id but different display name routes to ChangedEquipment.</summary>
|
|
[Fact]
|
|
public void Same_id_with_different_display_name_routes_to_ChangedEquipment()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("eq-1", "Old", "line-1") },
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("eq-1", "New", "line-1") },
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.ChangedEquipment.Single().Previous.DisplayName.ShouldBe("Old");
|
|
plan.ChangedEquipment.Single().Current.DisplayName.ShouldBe("New");
|
|
plan.AddedEquipment.ShouldBeEmpty();
|
|
plan.RemovedEquipment.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that driver config changes route to ChangedDrivers.</summary>
|
|
[Fact]
|
|
public void Driver_config_change_routes_to_ChangedDrivers()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(),
|
|
new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"old\"}") },
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(),
|
|
new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"new\"}") },
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.ChangedDrivers.Single().Current.ConfigJson.ShouldContain("new");
|
|
}
|
|
|
|
/// <summary>Verifies that alarm message template changes route to ChangedAlarms.</summary>
|
|
[Fact]
|
|
public void Alarm_message_template_change_routes_to_ChangedAlarms()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(),
|
|
Array.Empty<DriverInstancePlan>(),
|
|
new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "old") });
|
|
var next = new Phase7CompositionResult(
|
|
Array.Empty<EquipmentNode>(),
|
|
Array.Empty<DriverInstancePlan>(),
|
|
new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "new") });
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.ChangedAlarms.Single().Current.MessageTemplate.ShouldBe("new");
|
|
}
|
|
|
|
/// <summary>Verifies that added and removed lists are sorted by id for deterministic ordering.</summary>
|
|
[Fact]
|
|
public void Added_and_removed_lists_are_sorted_by_id_for_deterministic_ordering()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("z", "Z", "line-1"), new EquipmentNode("a", "A", "line-1") },
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>());
|
|
var next = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.RemovedEquipment.Select(e => e.EquipmentId).ShouldBe(new[] { "a", "z" });
|
|
}
|
|
|
|
/// <summary>Verifies that mixed changes across all three classes are captured in one pass.</summary>
|
|
[Fact]
|
|
public void Mixed_changes_across_all_three_classes_are_captured_in_one_pass()
|
|
{
|
|
var prev = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-drop", "Drop", "line-1") },
|
|
new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":1}") },
|
|
new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1") });
|
|
var next = new Phase7CompositionResult(
|
|
new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-new", "New", "line-1") },
|
|
new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":2}") },
|
|
new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1"), new ScriptedAlarmPlan("a-new", "eq-new", "s2", "t2") });
|
|
|
|
var plan = Phase7Planner.Compute(prev, next);
|
|
|
|
plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-new");
|
|
plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-drop");
|
|
plan.ChangedEquipment.ShouldBeEmpty();
|
|
plan.ChangedDrivers.Single().Current.DriverInstanceId.ShouldBe("drv-change");
|
|
plan.AddedAlarms.Single().ScriptedAlarmId.ShouldBe("a-new");
|
|
}
|
|
}
|