Implement LmxOpcUa server — all 6 phases complete
Full OPC UA server on .NET Framework 4.8 (x86) exposing AVEVA System Platform Galaxy tags via MXAccess. Mirrors Galaxy object hierarchy as OPC UA address space, translating contained-name browse paths to tag-name runtime references. Components implemented: - Configuration: AppConfiguration with 4 sections, validator - Domain: ConnectionState, Quality, Vtq, MxDataTypeMapper, error codes - MxAccess: StaComThread, MxAccessClient (partial classes), MxProxyAdapter using strongly-typed ArchestrA.MxAccess COM interop - Galaxy Repository: SQL queries (hierarchy, attributes, change detection), ChangeDetectionService with auto-rebuild on deploy - OPC UA Server: LmxNodeManager (CustomNodeManager2), LmxOpcUaServer, OpcUaServerHost with programmatic config, SecurityPolicy None - Status Dashboard: HTTP server with HTML/JSON/health endpoints - Integration: Full 14-step startup, graceful shutdown, component wiring 175 tests (174 unit + 1 integration), all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
122
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/DataValueConverterTests.cs
Normal file
122
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/DataValueConverterTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class DataValueConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromVtq_Boolean()
|
||||
{
|
||||
var vtq = Vtq.Good(true);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(true);
|
||||
Opc.Ua.StatusCode.IsGood(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_Int32()
|
||||
{
|
||||
var vtq = Vtq.Good(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_Float()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14f);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_Double()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14159);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_String()
|
||||
{
|
||||
var vtq = Vtq.Good("hello");
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_TimeSpan_ConvertedToSeconds()
|
||||
{
|
||||
var vtq = Vtq.Good(TimeSpan.FromMinutes(2.5));
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(150.0);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_BadQuality_MapsToStatusCode()
|
||||
{
|
||||
var vtq = Vtq.Bad(Quality.BadCommFailure);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
Opc.Ua.StatusCode.IsBad(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_UncertainQuality()
|
||||
{
|
||||
var vtq = Vtq.Uncertain(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
Opc.Ua.StatusCode.IsUncertain(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_NullValue()
|
||||
{
|
||||
var vtq = Vtq.Good(null);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
Normal file
109
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class LmxNodeManagerBuildTests
|
||||
{
|
||||
private static (List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes) CreateTestData()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, IsArea = true },
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea", ParentGobjectId = 1, IsArea = true },
|
||||
new GalaxyObjectInfo { GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false },
|
||||
new GalaxyObjectInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false },
|
||||
};
|
||||
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID", FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "BatchItems", FullTagReference = "TestMachine_001.BatchItems[]", MxDataType = 5, IsArray = true, ArrayDimension = 50 },
|
||||
};
|
||||
|
||||
return (hierarchy, attributes);
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_ArrayVariable_HasCorrectInfo()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_Areas_AreNotCountedAsObjects()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true },
|
||||
new GalaxyObjectInfo { 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
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_RootNodes_AreTopLevel()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Root1", BrowseName = "Root1", ParentGobjectId = 0, IsArea = true },
|
||||
new GalaxyObjectInfo { 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
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_DataTypeMappings()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Obj", BrowseName = "Obj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "BoolAttr", FullTagReference = "Obj.BoolAttr", MxDataType = 1, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "IntAttr", FullTagReference = "Obj.IntAttr", MxDataType = 2, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "FloatAttr", FullTagReference = "Obj.FloatAttr", MxDataType = 3, IsArray = false },
|
||||
new GalaxyAttributeInfo { 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,72 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class LmxNodeManagerRebuildTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rebuild_NewBuild_ReplacesOldData()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "OldObj", BrowseName = "OldObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs1 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { 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 GalaxyObjectInfo { GobjectId = 2, TagName = "NewObj", BrowseName = "NewObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs2 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rebuild_UpdatesNodeCounts()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false },
|
||||
new GalaxyObjectInfo { 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 GalaxyObjectInfo { GobjectId = 3, TagName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, new List<GalaxyAttributeInfo>());
|
||||
model2.ObjectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[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,60 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class OpcUaQualityMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Good_MapsToGoodStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Good);
|
||||
StatusCode.IsGood(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bad_MapsToBadStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Bad);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uncertain_MapsToUncertainStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Uncertain);
|
||||
StatusCode.IsUncertain(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadCommFailure_MapsCorrectly()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.BadCommFailure);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_Good()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Good);
|
||||
q.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_Bad()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Bad);
|
||||
q.ShouldBe(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_Uncertain()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Uncertain);
|
||||
q.ShouldBe(Quality.Uncertain);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user