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:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,281 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
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="DeployWatcher"/>'s consumption of <see cref="IGalaxyDeployWatchSource"/>:
/// bootstrap suppression, change detection, presence-flip handling, clean shutdown,
/// and reconnect-on-error backoff.
/// </summary>
public sealed class DeployWatcherTests
{
/// <summary>
/// Test helper exposing a <see cref="Channel{T}"/> as the event source plus an
/// optional fault hook so reconnect / retry paths can be exercised deterministically.
/// </summary>
private sealed class FakeDeployWatchSource : IGalaxyDeployWatchSource
{
private readonly Func<int, Channel<DeployEvent>> _channelFactory;
public List<DateTimeOffset?> LastSeenTimes { get; } = [];
public int CallCount { get; private set; }
public Func<int, Exception?>? ThrowOnIteration { get; init; }
public FakeDeployWatchSource(Channel<DeployEvent> channel)
{
_channelFactory = _ => channel;
}
public FakeDeployWatchSource(Func<int, Channel<DeployEvent>> channelFactory)
{
_channelFactory = channelFactory;
}
public async IAsyncEnumerable<DeployEvent> WatchAsync(
DateTimeOffset? lastSeenDeployTime,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
int iteration = ++CallCount;
LastSeenTimes.Add(lastSeenDeployTime);
if (ThrowOnIteration?.Invoke(iteration) is { } ex)
{
throw ex;
}
var channel = _channelFactory(iteration);
await foreach (var ev in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return ev;
}
}
}
private static DeployEvent Event(ulong sequence, DateTimeOffset? deployTime)
{
var ev = new DeployEvent
{
Sequence = sequence,
ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
TimeOfLastDeployPresent = deployTime is not null,
};
if (deployTime is { } t)
{
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(t);
}
return ev;
}
private static List<RediscoveryEventArgs> CaptureRediscoverEvents(DeployWatcher watcher)
{
var captured = new List<RediscoveryEventArgs>();
watcher.OnRediscoveryNeeded += (_, args) =>
{
lock (captured) captured.Add(args);
};
return captured;
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(10).ConfigureAwait(false);
}
throw new TimeoutException("Condition was not met within timeout.");
}
[Fact]
public async Task BootstrapEventIsSuppressed()
{
var channel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(channel);
using var watcher = new DeployWatcher(source);
var captured = CaptureRediscoverEvents(watcher);
await watcher.StartAsync(CancellationToken.None);
// Push only the bootstrap event.
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.Parse("2026-01-01T00:00:00Z")));
// Give the loop a moment to consume + ack.
await WaitUntilAsync(() => source.CallCount > 0 && channel.Reader.Count == 0, TimeSpan.FromSeconds(2));
await Task.Delay(50);
captured.ShouldBeEmpty();
await watcher.StopAsync();
}
[Fact]
public async Task DeployTimeChangeFiresRediscover()
{
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
var t1 = DateTimeOffset.Parse("2026-01-02T12:00:00Z");
var channel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(channel);
using var watcher = new DeployWatcher(source);
var captured = CaptureRediscoverEvents(watcher);
await watcher.StartAsync(CancellationToken.None);
await channel.Writer.WriteAsync(Event(0, t0)); // bootstrap
await channel.Writer.WriteAsync(Event(1, t1)); // real change
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
captured.Count.ShouldBe(1);
captured[0].Reason.ShouldBe("deploy-time-changed");
captured[0].ScopeHint.ShouldNotBeNull();
DateTimeOffset.Parse(captured[0].ScopeHint!).ToUniversalTime()
.ShouldBe(t1.ToUniversalTime());
await watcher.StopAsync();
}
[Fact]
public async Task SameDeployTimeDoesNotFire()
{
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
var channel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(channel);
using var watcher = new DeployWatcher(source);
var captured = CaptureRediscoverEvents(watcher);
await watcher.StartAsync(CancellationToken.None);
await channel.Writer.WriteAsync(Event(0, t0)); // bootstrap
await channel.Writer.WriteAsync(Event(2, t0)); // duplicate state — gateway re-sent
await WaitUntilAsync(() => channel.Reader.Count == 0, TimeSpan.FromSeconds(2));
await Task.Delay(50);
captured.ShouldBeEmpty();
await watcher.StopAsync();
}
[Fact]
public async Task TimeOfLastDeployPresentFlipFiresRediscover()
{
var t1 = DateTimeOffset.Parse("2026-03-01T08:00:00Z");
var channel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(channel);
using var watcher = new DeployWatcher(source);
var captured = CaptureRediscoverEvents(watcher);
await watcher.StartAsync(CancellationToken.None);
// Bootstrap with absent deploy time (Galaxy never deployed).
await channel.Writer.WriteAsync(Event(0, deployTime: null));
// Now a deploy lands and the present flag flips.
await channel.Writer.WriteAsync(Event(1, t1));
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
captured.Count.ShouldBe(1);
captured[0].Reason.ShouldBe("deploy-time-changed");
captured[0].ScopeHint.ShouldNotBeNull();
await watcher.StopAsync();
}
[Fact]
public async Task StopCancelsLoopCleanly()
{
var channel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(channel);
using var watcher = new DeployWatcher(source);
await watcher.StartAsync(CancellationToken.None);
// Push bootstrap so the loop enters its enumeration body before stop.
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.UtcNow));
await WaitUntilAsync(() => source.CallCount > 0, TimeSpan.FromSeconds(2));
// StopAsync should complete without throwing and within a reasonable window.
var stopTask = watcher.StopAsync();
var completed = await Task.WhenAny(stopTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.ShouldBe(stopTask);
await stopTask; // observe (no) exception
}
[Fact]
public async Task DisposeStopsRunningWatcher()
{
var channel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(channel);
var watcher = new DeployWatcher(source);
await watcher.StartAsync(CancellationToken.None);
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.UtcNow));
await WaitUntilAsync(() => source.CallCount > 0, TimeSpan.FromSeconds(2));
// Should not throw, should not hang.
var disposeTask = Task.Run(watcher.Dispose);
var completed = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.ShouldBe(disposeTask);
await disposeTask;
}
[Fact]
public async Task SourceExceptionTriggersRetryWithBackoff()
{
var t0 = DateTimeOffset.Parse("2026-04-01T00:00:00Z");
var t1 = DateTimeOffset.Parse("2026-04-02T00:00:00Z");
var firstChannel = Channel.CreateUnbounded<DeployEvent>();
var secondChannel = Channel.CreateUnbounded<DeployEvent>();
var source = new FakeDeployWatchSource(iteration => iteration switch
{
1 => firstChannel,
_ => secondChannel,
})
{
ThrowOnIteration = i => i == 1 ? new InvalidOperationException("transport drop") : null,
};
// Tiny backoff so the test doesn't sit in Task.Delay.
using var watcher = new DeployWatcher(
source,
logger: null,
initialBackoff: TimeSpan.FromMilliseconds(10),
maxBackoff: TimeSpan.FromMilliseconds(50),
jitter: _ => TimeSpan.Zero);
var captured = CaptureRediscoverEvents(watcher);
await watcher.StartAsync(CancellationToken.None);
// Wait for the second iteration (post-retry) to start.
await WaitUntilAsync(() => source.CallCount >= 2, TimeSpan.FromSeconds(2));
// Now feed bootstrap + real event into the second channel.
await secondChannel.Writer.WriteAsync(Event(0, t0));
await secondChannel.Writer.WriteAsync(Event(1, t1));
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
captured.Count.ShouldBe(1);
captured[0].Reason.ShouldBe("deploy-time-changed");
// The retry call passed null lastSeenDeployTime because no events were seen
// before the throw — confirms baseline tracking is per-instance, not per-stream.
source.LastSeenTimes.Count.ShouldBeGreaterThanOrEqualTo(2);
source.LastSeenTimes[0].ShouldBeNull();
source.LastSeenTimes[1].ShouldBeNull();
await watcher.StopAsync();
}
}

View File

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