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:
Joseph Doherty
2026-04-17 13:57:47 -04:00
parent 5b8d708c58
commit 3b2defd94f
293 changed files with 841 additions and 722 deletions

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}
}