chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Tests <see cref="GalaxyDiscoverer"/>'s translation of <see cref="GalaxyObject"/> rows
|
||||
/// into <see cref="IAddressSpaceBuilder"/> calls. The fake builder records every Folder /
|
||||
/// Variable / MarkAsAlarmCondition call so assertions can target shape without booting
|
||||
/// a real OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDiscovererTests
|
||||
{
|
||||
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
|
||||
{
|
||||
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(objects);
|
||||
}
|
||||
|
||||
private sealed record FolderCall(string BrowseName, string DisplayName);
|
||||
private sealed record VariableCall(string FolderBrowseName, string AttributeName, DriverAttributeInfo Info);
|
||||
|
||||
private sealed class FakeBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<FolderCall> Folders { get; } = [];
|
||||
public List<VariableCall> Variables { get; } = [];
|
||||
public Dictionary<string, AlarmConditionInfo> AlarmDeclarations { get; } = [];
|
||||
|
||||
private readonly string? _currentFolder;
|
||||
|
||||
public FakeBuilder() : this(null) { }
|
||||
private FakeBuilder(string? folder) { _currentFolder = folder; }
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(new FolderCall(browseName, displayName));
|
||||
// Return a child scoped to this folder; nested folders inherit the parent reference.
|
||||
return new ChildBuilder(this, browseName);
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
var folder = _currentFolder ?? "<root>";
|
||||
Variables.Add(new VariableCall(folder, browseName, attributeInfo));
|
||||
return new FakeVariableHandle(this, attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
// Child folder routes Variable calls back to the parent's lists with its own scope.
|
||||
private sealed class ChildBuilder(FakeBuilder parent, string folderBrowseName) : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
parent.Folders.Add(new FolderCall(browseName, displayName));
|
||||
return new ChildBuilder(parent, browseName);
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo));
|
||||
return new FakeVariableHandle(parent, attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
}
|
||||
|
||||
private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullRef;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
owner.AlarmDeclarations[FullReference] = info;
|
||||
return new NoopSink();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
|
||||
private static GalaxyAttribute Attr(
|
||||
string name, int mxDataType = 0, bool isArray = false, int arrayDim = 0,
|
||||
int securityClass = 0, bool isHistorized = false, bool isAlarm = false,
|
||||
string? fullTagReference = null)
|
||||
{
|
||||
var a = new GalaxyAttribute
|
||||
{
|
||||
AttributeName = name,
|
||||
MxDataType = mxDataType,
|
||||
IsArray = isArray,
|
||||
ArrayDimension = arrayDim,
|
||||
ArrayDimensionPresent = arrayDim > 0,
|
||||
SecurityClassification = securityClass,
|
||||
IsHistorized = isHistorized,
|
||||
IsAlarm = isAlarm,
|
||||
};
|
||||
if (fullTagReference is not null) a.FullTagReference = fullTagReference;
|
||||
return a;
|
||||
}
|
||||
|
||||
private static GalaxyObject Obj(string tagName, string? containedName = null, params GalaxyAttribute[] attributes)
|
||||
{
|
||||
var o = new GalaxyObject
|
||||
{
|
||||
TagName = tagName,
|
||||
ContainedName = containedName ?? tagName,
|
||||
};
|
||||
o.Attributes.AddRange(attributes);
|
||||
return o;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_BuildsOneFolderPerObject_AndOneVariablePerAttribute()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2 /*Float32*/), Attr("SP", mxDataType: 2)),
|
||||
Obj("Tank1_Pump", "Pump", Attr("Running", mxDataType: 0 /*Boolean*/)),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBe(2);
|
||||
builder.Folders[0].BrowseName.ShouldBe("Level");
|
||||
builder.Folders[1].BrowseName.ShouldBe("Pump");
|
||||
builder.Variables.Count.ShouldBe(3);
|
||||
builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "PV");
|
||||
builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "SP");
|
||||
builder.Variables.ShouldContain(v => v.FolderBrowseName == "Pump" && v.AttributeName == "Running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_FullReference_DefaultsToTagDotAttribute()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2)),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables[0].Info.FullName.ShouldBe("Tank1_Level.PV");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_FullReference_PrefersGwSuppliedFullTagReference()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2, fullTagReference: "explicit.full.ref")),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables[0].Info.FullName.ShouldBe("explicit.full.ref");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_BrowseName_FallsBackToTagName_WhenContainedEmpty()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", containedName: "", attributes: Attr("PV")),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders[0].BrowseName.ShouldBe("Tank1_Level");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_AttributeMetadata_PropagatesEveryField()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("T", "T", Attr("PV",
|
||||
mxDataType: 3 /*Float64*/,
|
||||
isArray: true, arrayDim: 16,
|
||||
securityClass: 2 /*SecuredWrite*/,
|
||||
isHistorized: true, isAlarm: false)),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var info = builder.Variables[0].Info;
|
||||
info.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
info.IsArray.ShouldBeTrue();
|
||||
info.ArrayDim.ShouldBe(16u);
|
||||
info.SecurityClass.ShouldBe(SecurityClassification.SecuredWrite);
|
||||
info.IsHistorized.ShouldBeTrue();
|
||||
info.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_AlarmAttribute_PopulatesAllFiveSubAttributeRefs()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.AlarmDeclarations.ShouldContainKey("Tank1_Level.HiHi");
|
||||
var info = builder.AlarmDeclarations["Tank1_Level.HiHi"];
|
||||
info.SourceName.ShouldBe("Tank1_Level.HiHi");
|
||||
info.InAlarmRef.ShouldBe("Tank1_Level.HiHi.InAlarm");
|
||||
info.PriorityRef.ShouldBe("Tank1_Level.HiHi.Priority");
|
||||
info.DescAttrNameRef.ShouldBe("Tank1_Level.HiHi.DescAttrName");
|
||||
info.AckedRef.ShouldBe("Tank1_Level.HiHi.Acked");
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("T", "T", Attr("PV", isAlarm: false), Attr("HiHi", isAlarm: true)),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.AlarmDeclarations.Count.ShouldBe(1);
|
||||
builder.AlarmDeclarations.ShouldContainKey("T.HiHi");
|
||||
builder.AlarmDeclarations.ShouldNotContainKey("T.PV");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_SkipsObjectsWithEmptyIdentity()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
new GalaxyObject { TagName = "", ContainedName = "" }, // skip
|
||||
Obj("Real", "Real", Attr("PV")),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBe(1);
|
||||
builder.Folders[0].BrowseName.ShouldBe("Real");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_SkipsAttributesWithEmptyName()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("T", "T", new GalaxyAttribute { AttributeName = "", MxDataType = 0 }, Attr("PV")),
|
||||
]);
|
||||
var discoverer = new GalaxyDiscoverer(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Count.ShouldBe(1);
|
||||
builder.Variables[0].AttributeName.ShouldBe("PV");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriverDiscoverAsync_RoutesThroughInjectedSource()
|
||||
{
|
||||
var src = new FakeHierarchySource([Obj("T", "T", Attr("PV"))]);
|
||||
var driver = new GalaxyDriverHelper().CreateWithFakeSource(src);
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await driver.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBe(1);
|
||||
builder.Variables.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Helper that exercises the internal ctor (test seam) without exposing it publicly.</summary>
|
||||
private sealed class GalaxyDriverHelper
|
||||
{
|
||||
public GalaxyDriver CreateWithFakeSource(IGalaxyHierarchySource source)
|
||||
=> new GalaxyDriver(
|
||||
"galaxy-test",
|
||||
new Config.GalaxyDriverOptions(
|
||||
new Config.GalaxyGatewayOptions("https://x", "k"),
|
||||
new Config.GalaxyMxAccessOptions("OtOpcUa-T"),
|
||||
new Config.GalaxyRepositoryOptions(),
|
||||
new Config.GalaxyReconnectOptions()),
|
||||
source,
|
||||
logger: null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user