Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*
Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
209
tests/ZB.MOM.WW.OtOpcUa.Tests/OpcUa/AddressSpaceDiffTests.cs
Normal file
209
tests/ZB.MOM.WW.OtOpcUa.Tests/OpcUa/AddressSpaceDiffTests.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class AddressSpaceDiffTests
|
||||
{
|
||||
private static GalaxyObjectInfo Obj(int id, string tag, int parent = 0, bool isArea = false)
|
||||
{
|
||||
return new GalaxyObjectInfo
|
||||
{
|
||||
GobjectId = id, TagName = tag, BrowseName = tag, ContainedName = tag, ParentGobjectId = parent,
|
||||
IsArea = isArea
|
||||
};
|
||||
}
|
||||
|
||||
private static GalaxyAttributeInfo Attr(int gobjectId, string name, string tagName = "Obj", int mxDataType = 5)
|
||||
{
|
||||
return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = gobjectId, AttributeName = name, FullTagReference = $"{tagName}.{name}",
|
||||
MxDataType = mxDataType, TagName = tagName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that identical Galaxy hierarchy and attribute snapshots produce no incremental rebuild work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NoChanges_ReturnsEmptySet()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var a = new List<GalaxyAttributeInfo> { Attr(1, "X") };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, a, h, a);
|
||||
changed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that newly deployed Galaxy objects are flagged for OPC UA subtree creation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddedObject_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var newH = new List<GalaxyObjectInfo> { Obj(1, "A"), Obj(2, "B") };
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(2);
|
||||
changed.ShouldNotContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removed Galaxy objects are flagged so their OPC UA subtree can be torn down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemovedObject_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A"), Obj(2, "B") };
|
||||
var newH = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(2);
|
||||
changed.ShouldNotContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that browse-name changes are treated as address-space changes for the affected Galaxy object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ModifiedObject_BrowseNameChange_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var newH = new List<GalaxyObjectInfo>
|
||||
{ new() { GobjectId = 1, TagName = "A", BrowseName = "A_Renamed", ContainedName = "A" } };
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that parent changes are treated as subtree moves that require rebuilding the affected object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ModifiedObject_ParentChange_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A"), Obj(2, "B", 1) };
|
||||
var newH = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, "A"),
|
||||
new() { GobjectId = 2, TagName = "B", BrowseName = "B", ContainedName = "B", ParentGobjectId = 0 }
|
||||
};
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that adding a Galaxy attribute marks the owning object for OPC UA variable rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeAdded_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo> { Attr(1, "X") };
|
||||
var newA = new List<GalaxyAttributeInfo> { Attr(1, "X"), Attr(1, "Y") };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removing a Galaxy attribute marks the owning object for OPC UA variable rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeRemoved_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo> { Attr(1, "X"), Attr(1, "Y") };
|
||||
var newA = new List<GalaxyAttributeInfo> { Attr(1, "X") };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changes to attribute field metadata such as MX data type trigger rebuild of the owning object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeFieldChange_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo> { Attr(1, "X", mxDataType: 5) };
|
||||
var newA = new List<GalaxyAttributeInfo> { Attr(1, "X", mxDataType: 2) };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that security-classification changes are treated as address-space changes for the owning attribute.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeSecurityChange_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo>
|
||||
{ new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X", SecurityClassification = 1 } };
|
||||
var newA = new List<GalaxyAttributeInfo>
|
||||
{ new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X", SecurityClassification = 2 } };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subtree expansion includes all descendants of a changed Galaxy object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExpandToSubtrees_IncludesChildren()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, "Root"),
|
||||
Obj(2, "Child", 1),
|
||||
Obj(3, "Grandchild", 2),
|
||||
Obj(4, "Sibling", 1),
|
||||
Obj(5, "Unrelated")
|
||||
};
|
||||
|
||||
var changed = new HashSet<int> { 1 };
|
||||
var expanded = AddressSpaceDiff.ExpandToSubtrees(changed, h);
|
||||
|
||||
expanded.ShouldContain(1);
|
||||
expanded.ShouldContain(2);
|
||||
expanded.ShouldContain(3);
|
||||
expanded.ShouldContain(4);
|
||||
expanded.ShouldNotContain(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subtree expansion does not introduce unrelated nodes when the changed object is already a leaf.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExpandToSubtrees_LeafNode_NoExpansion()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, "Root"),
|
||||
Obj(2, "Child", 1),
|
||||
Obj(3, "Sibling", 1)
|
||||
};
|
||||
|
||||
var changed = new HashSet<int> { 2 };
|
||||
var expanded = AddressSpaceDiff.ExpandToSubtrees(changed, h);
|
||||
|
||||
expanded.ShouldContain(2);
|
||||
expanded.ShouldNotContain(1);
|
||||
expanded.ShouldNotContain(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
tests/ZB.MOM.WW.OtOpcUa.Tests/OpcUa/DataValueConverterTests.cs
Normal file
165
tests/ZB.MOM.WW.OtOpcUa.Tests/OpcUa/DataValueConverterTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies how bridge VTQ values are translated to and from OPC UA data values for the published namespace.
|
||||
/// </summary>
|
||||
public class DataValueConverterTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that boolean runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Boolean()
|
||||
{
|
||||
var vtq = Vtq.Good(true);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(true);
|
||||
StatusCode.IsGood(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that integer runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Int32()
|
||||
{
|
||||
var vtq = Vtq.Good(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that float runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Float()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14f);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that double runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Double()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14159);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that string runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_String()
|
||||
{
|
||||
var vtq = Vtq.Good("hello");
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that UTC timestamps remain UTC when a VTQ is converted for OPC UA clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_DateTime_IsUtc()
|
||||
{
|
||||
var utcTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var vtq = new Vtq(utcTime, utcTime, Quality.Good);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
((DateTime)dv.Value).Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that elapsed-time values are exposed to OPC UA clients in seconds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_TimeSpan_ConvertedToSeconds()
|
||||
{
|
||||
var vtq = Vtq.Good(TimeSpan.FromMinutes(2.5));
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(150.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that string arrays remain arrays when exposed through OPC UA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_StringArray()
|
||||
{
|
||||
var arr = new[] { "a", "b", "c" };
|
||||
var vtq = Vtq.Good(arr);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(arr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that integer arrays remain arrays when exposed through OPC UA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_IntArray()
|
||||
{
|
||||
var arr = new[] { 1, 2, 3 };
|
||||
var vtq = Vtq.Good(arr);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(arr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that bad runtime quality is translated to a bad OPC UA status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_BadQuality_MapsToStatusCode()
|
||||
{
|
||||
var vtq = Vtq.Bad(Quality.BadCommFailure);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
StatusCode.IsBad(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that uncertain runtime quality is translated to an uncertain OPC UA status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_UncertainQuality()
|
||||
{
|
||||
var vtq = Vtq.Uncertain(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
StatusCode.IsUncertain(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that null runtime values remain null when converted for OPC UA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_NullValue()
|
||||
{
|
||||
var vtq = Vtq.Good(null);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a data value can round-trip back into a VTQ without losing the process value or quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToVtq_RoundTrip()
|
||||
{
|
||||
var original = new Vtq(42, DateTime.UtcNow, Quality.Good);
|
||||
var dv = DataValueConverter.FromVtq(original);
|
||||
var roundTrip = DataValueConverter.ToVtq(dv);
|
||||
|
||||
roundTrip.Value.ShouldBe(42);
|
||||
roundTrip.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
}
|
||||
}
|
||||
184
tests/ZB.MOM.WW.OtOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
Normal file
184
tests/ZB.MOM.WW.OtOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the in-memory address-space model built from Galaxy hierarchy and attribute rows.
|
||||
/// </summary>
|
||||
public class LmxNodeManagerBuildTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates representative Galaxy hierarchy and attribute rows for address-space builder tests.
|
||||
/// </summary>
|
||||
/// <returns>The hierarchy and attribute rows used by the tests.</returns>
|
||||
private static (List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes) CreateTestData()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0,
|
||||
IsArea = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea",
|
||||
ParentGobjectId = 1, IsArea = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001",
|
||||
BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver",
|
||||
BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false
|
||||
}
|
||||
};
|
||||
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID",
|
||||
FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath",
|
||||
FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber",
|
||||
FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "BatchItems",
|
||||
FullTagReference = "TestMachine_001.BatchItems[]", MxDataType = 5, IsArray = true,
|
||||
ArrayDimension = 50
|
||||
}
|
||||
};
|
||||
|
||||
return (hierarchy, attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that object and variable counts are computed correctly from the seeded Galaxy model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_CreatesCorrectNodeCounts()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.ObjectCount.ShouldBe(2); // TestMachine_001, DelmiaReceiver
|
||||
model.VariableCount.ShouldBe(4); // MachineID, DownloadPath, JobStepNumber, BatchItems
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that runtime tag references are populated for every published variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_TagReferencesPopulated()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that array attributes are represented in the tag-reference map.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_ArrayVariable_HasCorrectInfo()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true);
|
||||
model.NodeIdToTagReference["TestMachine_001.BatchItems"].ShouldBe("TestMachine_001.BatchItems[]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that Galaxy areas are not counted as object nodes in the resulting model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_Areas_AreNotCountedAsObjects()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true },
|
||||
new() { GobjectId = 2, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, new List<GalaxyAttributeInfo>());
|
||||
model.ObjectCount.ShouldBe(1); // Only Obj1, not Area1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that only top-level Galaxy nodes are returned as roots in the model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_RootNodes_AreTopLevel()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Root1", BrowseName = "Root1", ParentGobjectId = 0, IsArea = true },
|
||||
new() { GobjectId = 2, TagName = "Child1", BrowseName = "Child1", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, new List<GalaxyAttributeInfo>());
|
||||
model.RootNodes.Count.ShouldBe(1); // Only Root1 is a root
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that variables for multiple MX data types are included in the model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_DataTypeMappings()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Obj", BrowseName = "Obj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "BoolAttr", FullTagReference = "Obj.BoolAttr",
|
||||
MxDataType = 1, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "IntAttr", FullTagReference = "Obj.IntAttr",
|
||||
MxDataType = 2, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "FloatAttr", FullTagReference = "Obj.FloatAttr",
|
||||
MxDataType = 3, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "StrAttr", FullTagReference = "Obj.StrAttr",
|
||||
MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
model.VariableCount.ShouldBe(4);
|
||||
model.NodeIdToTagReference.Count.ShouldBe(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies rebuild behavior by comparing address-space models before and after metadata changes.
|
||||
/// </summary>
|
||||
public class LmxNodeManagerRebuildTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that rebuilding with new metadata replaces the old tag-reference set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_NewBuild_ReplacesOldData()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "OldObj", BrowseName = "OldObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs1 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "OldObj", AttributeName = "OldAttr", FullTagReference = "OldObj.OldAttr",
|
||||
MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model1 = AddressSpaceBuilder.Build(hierarchy1, attrs1);
|
||||
model1.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(true);
|
||||
|
||||
// Rebuild with new data
|
||||
var hierarchy2 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 2, TagName = "NewObj", BrowseName = "NewObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs2 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "NewObj", AttributeName = "NewAttr", FullTagReference = "NewObj.NewAttr",
|
||||
MxDataType = 2, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, attrs2);
|
||||
|
||||
// Old nodes not in new model, new nodes present
|
||||
model2.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(false);
|
||||
model2.NodeIdToTagReference.ContainsKey("NewObj.NewAttr").ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that object counts are recalculated from the latest rebuild input.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_UpdatesNodeCounts()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false },
|
||||
new() { GobjectId = 2, TagName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model1 = AddressSpaceBuilder.Build(hierarchy1, new List<GalaxyAttributeInfo>());
|
||||
model1.ObjectCount.ShouldBe(2);
|
||||
|
||||
var hierarchy2 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 3, TagName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, new List<GalaxyAttributeInfo>());
|
||||
model2.ObjectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that empty metadata produces an empty address-space model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmptyHierarchy_ProducesEmptyModel()
|
||||
{
|
||||
var model = AddressSpaceBuilder.Build(new List<GalaxyObjectInfo>(), new List<GalaxyAttributeInfo>());
|
||||
model.RootNodes.ShouldBeEmpty();
|
||||
model.NodeIdToTagReference.ShouldBeEmpty();
|
||||
model.ObjectCount.ShouldBe(0);
|
||||
model.VariableCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that subscription and unsubscription failures in the MXAccess client
|
||||
/// are handled gracefully by the node manager instead of silently lost.
|
||||
/// </summary>
|
||||
public class LmxNodeManagerSubscriptionFaultTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a faulted SubscribeAsync is caught and logged rather than silently discarded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("COM connection lost")
|
||||
};
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
|
||||
// SubscribeTag should catch the fault — not throw and not hang
|
||||
Should.NotThrow(() => nodeManager.SubscribeTag("TestMachine_001.MachineID"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a faulted UnsubscribeAsync is caught and logged rather than silently discarded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
|
||||
// Subscribe first (succeeds)
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
// Now inject fault for unsubscribe
|
||||
mxClient.UnsubscribeException = new InvalidOperationException("COM connection lost");
|
||||
|
||||
// UnsubscribeTag should catch the fault — not throw and not hang
|
||||
Should.NotThrow(() => nodeManager.UnsubscribeTag("TestMachine_001.MachineID"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscription failure does not corrupt the ref-count bookkeeping,
|
||||
/// allowing a retry to succeed after the fault clears.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeTag_AfterFaultClears_CanSubscribeAgain()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("transient fault")
|
||||
};
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
|
||||
// First subscribe faults (caught)
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0); // subscribe failed
|
||||
|
||||
// Clear the fault
|
||||
mxClient.SubscribeException = null;
|
||||
|
||||
// Unsubscribe to reset ref count, then subscribe again
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies translation between bridge quality values and OPC UA status codes.
|
||||
/// </summary>
|
||||
public class OpcUaQualityMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that good bridge quality maps to an OPC UA good status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Good_MapsToGoodStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Good);
|
||||
StatusCode.IsGood(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that bad bridge quality maps to an OPC UA bad status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bad_MapsToBadStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Bad);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that uncertain bridge quality maps to an OPC UA uncertain status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Uncertain_MapsToUncertainStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Uncertain);
|
||||
StatusCode.IsUncertain(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that communication failures map to a bad OPC UA status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BadCommFailure_MapsCorrectly()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.BadCommFailure);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA good status maps back to bridge good quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromStatusCode_Good()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Good);
|
||||
q.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA bad status maps back to bridge bad quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromStatusCode_Bad()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Bad);
|
||||
q.ShouldBe(Quality.Bad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA uncertain status maps back to bridge uncertain quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromStatusCode_Uncertain()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Uncertain);
|
||||
q.ShouldBe(Quality.Uncertain);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user