Phase 2 Stream D Option B — archive v1 surface + new Driver.Galaxy.E2E parity suite. Non-destructive intermediate state: the v1 OtOpcUa.Host + Historian.Aveva + Tests + IntegrationTests projects all still build (494 v1 unit + 6 v1 integration tests still pass when run explicitly), but solution-level dotnet test ZB.MOM.WW.OtOpcUa.slnx now skips them via IsTestProject=false on the test projects + archive-status PropertyGroup comments on the src projects. The destructive deletion is reserved for Phase 2 PR 3 with explicit operator review per CLAUDE.md "only use destructive operations when truly the best approach". tests/ZB.MOM.WW.OtOpcUa.Tests/ renamed via git mv to tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/; csproj <AssemblyName> kept as the original ZB.MOM.WW.OtOpcUa.Tests so v1 OtOpcUa.Host's [InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.Tests")] still matches and the project rebuilds clean. tests/ZB.MOM.WW.OtOpcUa.IntegrationTests gets <IsTestProject>false</IsTestProject>. src/ZB.MOM.WW.OtOpcUa.Host + src/ZB.MOM.WW.OtOpcUa.Historian.Aveva get PropertyGroup archive-status comments documenting they're functionally superseded but kept in-build because cascading dependencies (Historian.Aveva → Host; IntegrationTests → Host) make a single-PR deletion high blast-radius. New tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ project (.NET 10) with ParityFixture that spawns OtOpcUa.Driver.Galaxy.Host.exe (net48 x86) as a Process.Start subprocess with OTOPCUA_GALAXY_BACKEND=db env vars, awaits 2s for the PipeServer to bind, then exposes a connected GalaxyProxyDriver; skips on non-Windows / Administrator shells (PipeAcl denies admins per decision #76) / ZB unreachable / Host EXE not built — each skip carries a SkipReason string the test method reads via Assert.Skip(SkipReason). RecordingAddressSpaceBuilder captures every Folder/Variable/AddProperty registration so parity tests can assert on the same shape v1 LmxNodeManager produced. HierarchyParityTests (3) — Discover returns gobjects with attributes; attribute full references match the tag.attribute Galaxy reference grammar; HistoryExtension flag flows through correctly. StabilityFindingsRegressionTests (4) — one test per 2026-04-13 stability finding from commits c76ab8f and 7310925: phantom probe subscription doesn't corrupt unrelated host status; HostStatusChangedEventArgs structurally carries a specific HostName + OldState + NewState (event signature mathematically prevents the v1 cross-host quality-clear bug); all GalaxyProxyDriver capability methods return Task or Task<T> (sync-over-async would deadlock OPC UA stack thread); AcknowledgeAsync completes before returning (no fire-and-forget background work that could race shutdown). Solution test count: 470 pass / 7 skip (E2E on admin shell) / 1 pre-existing Phase 0 baseline. Run archived suites explicitly: dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive (494 pass) + dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests (6 pass). docs/v2/V1_ARCHIVE_STATUS.md inventories every archived surface with run-it-explicitly instructions + a 10-step deletion plan for PR 3 + rollback procedure (git revert restores all four projects). docs/v2/implementation/exit-gate-phase-2-final.md supersedes the two partial-exit docs with the per-stream status table (A/B/C/D/E all addressed, D split across PR 2/3 per safety protocol), the test count breakdown, fresh adversarial review of PR 2 deltas (4 new findings: medium IsTestProject=false safety net loss, medium structural-vs-behavioral stability tests, low backend=db default, low Process.Start env inheritance), the 8 carried-forward findings from exit-gate-phase-2.md, the recommended PR order (1 → 2 → 3 → 4). docs/v2/implementation/pr-2-body.md is the Gitea web-UI paste-in for opening PR 2 once pushed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-18 00:56:21 -04:00
parent 50f81a156d
commit a3d16a28f1
76 changed files with 692 additions and 2 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);
}
}
}